Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions .github/workflows/test-unit-openbb-government-ca.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
name: 🧪 test openbb-government-ca

# Clarification 1 — coverage boundary:
# The `--cov` target below is the `openbb_government_ca` package ONLY.
# `hatch_build.py` lives at the package root (alongside `pyproject.toml`),
# OUTSIDE the measured package, mirroring how OECD excludes its own
# `hatch_build.py`. Consequently the build-hook tests (task 8) are NOT
# required to reach the 100% coverage gate — only code under
# `openbb_government_ca/` is measured by `--cov-fail-under=100`.

on:
pull_request:
branches: [develop, main, v5]
paths:
- "openbb_platform/providers/government_ca/**"
- ".github/workflows/test-unit-openbb-government-ca.yml"
workflow_dispatch:
inputs:
force_cache_rebuild:
description: "Force the build hook to regenerate the government-ca cache from live SDMX (ignores actions/cache)."
type: boolean
default: false

permissions:
contents: read

concurrency:
# Cancel in-flight matrix runs when a new commit lands on the same PR.
group: test-openbb-government-ca-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
build:
name: Build sdist + wheel
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: openbb_platform/providers/government_ca
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v6
with:
python-version: "3.11"

- run: python -m pip install --upgrade pip uv

- name: Restore government-ca SDMX cache
id: government-ca-cache
uses: actions/cache@v5
with:
path: openbb_platform/providers/government_ca/openbb_government_ca/assets/government_ca_cache.json.xz
key: government-ca-sdmx-${{ hashFiles('openbb_platform/providers/government_ca/openbb_government_ca/utils/generate_cache.py') }}
restore-keys: |
government-ca-sdmx-

- name: Build sdist + wheel
env:
OPENBB_GOVERNMENT_CA_FORCE_CACHE_REBUILD: ${{ inputs.force_cache_rebuild == true && '1' || '0' }}
run: uv build --no-sources

- uses: actions/upload-artifact@v5
with:
name: openbb-government-ca-dist
path: openbb_platform/providers/government_ca/dist/
if-no-files-found: error
retention-days: 7

lint:
name: Lint (ruff + ty)
needs: build
runs-on: ubuntu-latest
defaults:
run:
shell: bash
working-directory: openbb_platform/providers/government_ca
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v6
with:
python-version: "3.11"

- run: python -m pip install --upgrade pip uv

- uses: actions/download-artifact@v5
with:
name: openbb-government-ca-dist
path: openbb_platform/providers/government_ca/dist

- name: Seed the cache asset from the built wheel
run: |
WHEEL=$(ls dist/openbb_government_ca-*.whl | head -1)
mkdir -p openbb_government_ca/assets
python -c "
import sys, zipfile, shutil
wheel = sys.argv[1]
dest = sys.argv[2]
with zipfile.ZipFile(wheel) as z:
with z.open('openbb_government_ca/assets/government_ca_cache.json.xz') as src, open(dest, 'wb') as out:
shutil.copyfileobj(src, out)
" "$WHEEL" openbb_government_ca/assets/government_ca_cache.json.xz
ls -la openbb_government_ca/assets/government_ca_cache.json.xz

- name: Install openbb-government-ca in editable mode (+ dev group)
run: |
uv pip install --system -e .
uv pip install --system --group dev

- name: ruff check
run: ruff check openbb_government_ca tests hatch_build.py

- name: ruff format --check
run: ruff format --check openbb_government_ca tests hatch_build.py

- name: ty check
run: ty check openbb_government_ca

test:
name: Test (${{ matrix.os }} / Python ${{ matrix.python_version }})
needs: build
strategy:
fail-fast: false
matrix:
include:
- { os: ubuntu-latest, python_version: "3.10" }
- { os: ubuntu-latest, python_version: "3.11" }
- { os: ubuntu-latest, python_version: "3.13" }
- { os: macos-latest, python_version: "3.10" }
- { os: macos-latest, python_version: "3.13" }
- { os: windows-latest, python_version: "3.10" }
- { os: windows-latest, python_version: "3.13" }
runs-on: ${{ matrix.os }}
defaults:
run:
shell: bash
working-directory: openbb_platform/providers/government_ca
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python_version }}
allow-prereleases: true

- uses: actions/download-artifact@v5
with:
name: openbb-government-ca-dist
path: openbb_platform/providers/government_ca/dist

- run: python -m pip install --upgrade pip uv

- name: Seed the cache asset from the built wheel
run: |
WHEEL=$(ls dist/openbb_government_ca-*.whl | head -1)
mkdir -p openbb_government_ca/assets
python -c "
import sys, zipfile, shutil
wheel = sys.argv[1]
dest = sys.argv[2]
with zipfile.ZipFile(wheel) as z:
with z.open('openbb_government_ca/assets/government_ca_cache.json.xz') as src, open(dest, 'wb') as out:
shutil.copyfileobj(src, out)
" "$WHEEL" openbb_government_ca/assets/government_ca_cache.json.xz

- name: Install openbb-government-ca from source (+ dev group)
run: |
uv pip install --system -e .
uv pip install --system --group dev

- name: Run unit tests
# --cov target is the openbb_government_ca package ONLY (Clarification 1);
# hatch_build.py at the package root is outside the measured package.
run: pytest tests --cov=openbb_government_ca --cov-report=term-missing --cov-fail-under=100
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ cli/openbb_cli/assets/styles/user/*

# Platform
openbb_platform/core/openbb/package/*
openbb_platform/providers/government_ca/openbb_government_ca/assets/*

# Dev Container env
obb/*
Expand Down
47 changes: 47 additions & 0 deletions openbb_platform/providers/government_ca/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Government of Canada Provider Extension

This package provides access to Canadian government and central-bank data, integrating
the Bank of Canada Valet API and the Statistics Canada SDMX REST API.

## Installation

Install into a Python environment (3.10 - 3.13) from PyPI with:

```sh
pip install openbb-government-ca
```

Then build the Python static assets:

```sh
openbb-build
```

## Credentials

Credentials are not required. Both the Bank of Canada and Statistics Canada APIs are public.

## Coverage

Coverage is added incrementally. See the package namespaces:

- `obb.boc.*` — Bank of Canada data
- `obb.statscan.*` — Statistics Canada data

## Usage

The package can be used as a Python module, or a REST API.

### REST API

Start the server over localhost with:

```sh
openbb-api
```

### Python

```python
from openbb import obb
```
161 changes: 161 additions & 0 deletions openbb_platform/providers/government_ca/hatch_build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"""Hatchling build hook: materialize the StatsCan SDMX cache before packaging.

The compressed catalog (``openbb_government_ca/assets/government_ca_cache.json.xz``)
is gitignored — it must be produced fresh at build time by
``openbb_government_ca.utils.generate_cache.main`` against StatsCan SDMX
endpoints and bundled into the sdist + wheel + editable install.
"""

from __future__ import annotations

import os
import subprocess
import sys
from contextlib import suppress
from pathlib import Path
from typing import TextIO

# ``hatchling`` is a PEP 517 build-time dependency (declared in
# ``[build-system].requires`` of ``pyproject.toml``), not a runtime or
# test/lint dependency. Type-check environments don't install it, so we
# tell ty the unresolved import is intentional rather than pulling
# hatchling into the lint env.
from hatchling.builders.hooks.plugin.interface import ( # ty: ignore[unresolved-import]
BuildHookInterface,
)

_ROOT = Path(__file__).resolve().parent
_CACHE_PATH = _ROOT / "openbb_government_ca" / "assets" / "government_ca_cache.json.xz"
_GENERATE_CACHE = _ROOT / "openbb_government_ca" / "utils" / "generate_cache.py"
_FORCE_ENV = "OPENBB_GOVERNMENT_CA_FORCE_CACHE_REBUILD"


def _open_tty() -> TextIO | None:
"""Open ``/dev/tty`` for direct terminal output, or ``None`` on Windows / CI.

PEP 517 build frontends (pip, uv, build) capture the backend's
stdout/stderr and only surface them in error summaries — which
means a printed-and-flushed message inside a build hook is
completely invisible to the user during the StatsCan fetch.
Writing to ``/dev/tty`` is the standard escape hatch: it's the
controlling terminal, untouched by pip's capture pipeline.
"""
with suppress(OSError):
return open("/dev/tty", "w", buffering=1, encoding="utf-8") # noqa: SIM115
return None


class GovernmentCaCacheBuildHook(BuildHookInterface):
"""Build hook that materializes ``government_ca_cache.json.xz`` before packaging."""

PLUGIN_NAME = "government-ca-cache"

# Class-level flag — Hatch runs ``build_sdist`` and ``build_wheel`` in
# the same Python process, so this state survives between target builds
# and lets the second target reuse the cache the first target just
# produced even when ``OPENBB_GOVERNMENT_CA_FORCE_CACHE_REBUILD=1`` is
# set. Without this guard a release build would fetch from StatsCan
# twice (once per target).
_generated_this_session: bool = False

def initialize(self, version: str, build_data: dict) -> None:
"""Generate (or reuse) the cache, then mark it for inclusion."""
# Fires for every target: sdist + wheel + editable. The cache is
# bundled into all three. Editable installs need it in the source
# tree so the runtime catalog loader resolves the shipped asset.
tty = _open_tty()

def _say(msg: str) -> None:
"""Emit *msg* to the controlling terminal AND to stderr.

``/dev/tty`` bypasses pip / uv / build's stdout+stderr
capture so the user sees progress live. The stderr copy is
for log files, CI captures, and the eventual pip error
summary when something does go wrong.
"""
line = msg if msg.endswith("\n") else msg + "\n"
if tty is not None:
with suppress(OSError):
tty.write(line)
tty.flush()
sys.stderr.write(line)
sys.stderr.flush()

cls = type(self)

# ``force`` is honored at most once per ``uv build`` process: the
# first target (sdist) regenerates against live StatsCan, then the
# ``_generated_this_session`` flag short-circuits the second
# target (wheel) into reusing the just-written file. Without
# this gate a release build hits StatsCan twice.
force = (
os.environ.get(_FORCE_ENV, "").lower() in {"1", "true", "yes"}
and not cls._generated_this_session
)
try:
if _CACHE_PATH.exists() and not force:
reuse_reason = (
"this build session already regenerated it"
if cls._generated_this_session
else f"set {_FORCE_ENV}=1 to rebuild"
)
_say(
f"government-ca-cache: reusing {_CACHE_PATH.name} ({reuse_reason})"
)
else:
_say(
"government-ca-cache: regenerating from StatsCan SDMX..."
)
# Invoke ``generate_cache.py`` by **path**, not by
# module name, so the isolated PEP 517 build env
# doesn't import ``openbb_government_ca/__init__.py``
# (which pulls in ``openbb_core`` — not listed as a
# build-time requirement). The script itself is
# self-contained: stdlib + ``requests``, and
# ``requests`` is in ``[build-system].requires``.
#
# Stream stdout line-by-line, tee each line to both
# ``/dev/tty`` (live, escapes pip/uv capture) AND
# stderr (so CI logs, ``-v`` mode, and the error
# summary still have the trace). ``PYTHONUNBUFFERED=1``
# keeps the subprocess from line-buffering since its
# stdout isn't a TTY.
env = {**os.environ, "PYTHONUNBUFFERED": "1"}
proc = subprocess.Popen( # noqa: S603
[sys.executable, str(_GENERATE_CACHE)],
cwd=str(_ROOT),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
assert proc.stdout is not None # noqa: S101
for line in proc.stdout:
if tty is not None:
with suppress(OSError):
tty.write(line)
tty.flush()
sys.stderr.write(line)
sys.stderr.flush()
returncode = proc.wait()

if returncode != 0 or not _CACHE_PATH.exists():
self.app.abort(
"government-ca-cache: failed to generate "
"government_ca_cache.json.xz "
f"(exit code {returncode}). See the "
"``generate_cache`` output above for details. "
"StatsCan SDMX (www150.statcan.gc.ca) must be "
"reachable during build."
)

cls._generated_this_session = True
finally:
if tty is not None:
tty.close()

# Force inclusion regardless of VCS status (the file is gitignored).
rel = _CACHE_PATH.relative_to(_ROOT).as_posix()
build_data.setdefault("force_include", {})[str(_CACHE_PATH)] = rel
build_data.setdefault("artifacts", []).append(rel)
Loading