Skip to content
Merged
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
110 changes: 110 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
name: CI

on:
push:
branches: [master]
pull_request:
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# Least privilege: nothing here needs to write to the repo. Declaring this at
# the workflow level also overrides a permissive repo/org default.
permissions:
contents: read

env:
# Assert uv.lock is up to date with pyproject.toml and install exactly what
# it pins, rather than silently resolving something different from what
# developers run locally. (UV_LOCKED, not UV_FROZEN: --frozen skips
# re-locking without checking, --locked fails when the lock is stale.)
UV_LOCKED: "1"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

jobs:
lint:
name: Lint and type-check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
# Don't leave GITHUB_TOKEN in .git/config for later steps to reach.
persist-credentials: false

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Install dependencies
run: uv sync --all-extras --dev

- name: ruff check
run: uv run ruff check .

- name: ruff format --check
run: uv run ruff format --check .

- name: mypy
run: uv run mypy src/myinvois

test:
name: Test (Python ${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
with:
# Don't leave GITHUB_TOKEN in .git/config for later steps to reach.
persist-credentials: false

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

- name: Install dependencies
run: uv sync --all-extras --dev

# Live tests need real MyInvois sandbox credentials, so they are
# deselected here. They skip on their own if MYINVOIS_CLIENT_ID is
# unset, but deselecting keeps the CI summary honest about what ran.
- name: pytest
run: uv run pytest -m "not live"

package:
name: Build and verify distributions
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Don't leave GITHUB_TOKEN in .git/config for later steps to reach.
persist-credentials: false

- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true

- name: Build sdist and wheel
run: uv build

# Guards two things that are easy to break and painful to un-publish:
# 1. The PEP 561 marker and the JSON code tables must ship, or
# `from myinvois.codes import ...` fails at runtime in a wheel
# install and type-checkers ignore the package.
# 2. The test signing key/cert must NEVER ship. They are force-tracked
# in git for the byte-parity tests (see tests/fixtures/cert/README.md)
# which makes an accidental include plausible.
- name: Verify distribution contents
run: uv run --no-project python scripts/check_dist.py

- uses: actions/upload-artifact@v4
with:
name: distributions
path: dist/
31 changes: 21 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,18 +116,19 @@ Steps (mirror PHP `AbstractDocumentBuilder::createSignature` exactly):
- [x] Phase 4: digital signature (XmlSigner + JsonSigner, byte-for-byte PHP parity, commit `9ce6d48`, 232 tests)
- [x] Phase 5: submit + state services (SubmissionsService + document-state mutations, commit `f4327a1`, 260 tests)
- [x] Phase 6a: async mirror (`AsyncMyInvoisClient` + 5 async services, commit `80e7e2d` / PR #4, 284 tests)
- [ ] Phase 6b: polish + publish (CI workflow, PyPI Trusted Publishing, live sandbox verification)
- [~] Phase 6b: polish + publish CI + packaging metadata DONE; PyPI Trusted Publishing, remaining 7 document types and live sandbox verification still TODO

## CURRENT_STATE
Phases 0-6a done and committed. **284 tests passing** (up from 260). `ruff check`, `ruff format --check`, `mypy src` all clean. Working tree clean; HEAD is `80e7e2d` on `master`.
Phases 0-6a done; Phase 6b partially done (CI + packaging). **284 tests passing** (up from 260). All four gates verified green *as of the Phase 6b commit* — `ruff check .`, `ruff format --check .`, `mypy src/myinvois`, `pytest -m "not live"`. The format gate only became green in that commit; see FORMATTING DRIFT.

The full pipeline is implemented end-to-end and verified to run: build `Invoice` -> `JsonEnvelopeBuilder`/`XmlEnvelopeBuilder` -> `JsonSigner`/`XmlSigner` -> `build_submission_payload` -> `client.submissions.submit_documents`.

Remaining before 1.0:

- CI workflow + PyPI release automation (see GITHUB section).
- PyPI release automation (CI itself is now in place — see GITHUB section).
- Document types `02`/`03`/`04`/`11`-`14` (only `01` Invoice is modelled).
- Live sandbox verification against LHDN preprod.
- Async test coverage gap: token cache-hit / proactive refresh and HTTP-status -> exception mapping are sync-side only.

## CODE_STATE
- `src/myinvois/codes/__init__.py` implements curated `StrEnum(_EnumLookupMixin)` tables + `_CodeTable` loader instances; `src/myinvois/codes/_data/*.json` 8 tables = 3,637 rows. Re-extract via `uv run python scripts/extract_codes.py`.
Expand All @@ -151,12 +152,20 @@ Phase 4 commit = `9ce6d48`. Phase 5 commit = `f4327a1`. Phase 6a commit = `80e7e
Repo: **https://github.com/danieyal/myinvois-python** (public).
Remote `origin` → `https://github.com/danieyal/myinvois-python.git`. Default branch is `master`. Topics: `myinvois`, `lhdn`, `e-invoice`, `malaysia`, `python`, `pydantic`, `ubl`, `xades`, `digital-signature`, `sdk`.

CI (TODO when added): `.github/workflows/ci.yml` running `uv run ruff check . && uv run ruff format --check . && uv run mypy src/myinvois && uv run pytest`. Release automation (TODO): GitHub Actions Trusted Publishing → PyPI on tag `v*`.
CI (DONE, Phase 6b): `.github/workflows/ci.yml`, three jobs — `lint` (ruff check + ruff format --check + mypy), `test` (pytest matrix 3.11/3.12/3.13, `-m "not live"`), `package` (uv build + `scripts/check_dist.py`, uploads the dists as an artifact). Runs on push-to-master, every PR, and `workflow_dispatch`. Workflow-level `permissions: contents: read` and `persist-credentials: false` on every checkout (least privilege; nothing in CI writes to the repo). `UV_LOCKED=1` so a stale `uv.lock` fails the build instead of silently resolving differently — **`UV_LOCKED`, not `UV_FROZEN`**: `--frozen` skips re-locking *without* checking, so it would not catch the drift.

Release automation (TODO): GitHub Actions Trusted Publishing → PyPI on tag `v*`. Gate it on the `package` job. **Publishing under the `myinvois` name is outward-facing and irreversible per-version — confirm with the user before the first release.**

Test-only signing fixtures `tests/fixtures/cert/dummy_signing_{cert,key}.pem` are intentionally force-tracked (see `tests/fixtures/cert/README.md`): the Phase 4 byte-parity tests pin against PHP-generated goldens that were signed with this exact self-signed dummy keypair.

## CHANGES
- `pyproject.toml` has `[tool.uv.build-backend]` `data-includes` shipping `py.typed` + `_data/*.json` (PEP 561 marker). Codes symbols re-exported from top-level `myinvois/__init__.py`.
- `scripts/check_dist.py` (Phase 6b) verifies a built sdist+wheel: the 9 required data members are present, and no `*.pem|key|p12|pfx` / `tests/` / `fixtures/` path ships. Negative-tested (tamper a wheel → both failure classes are caught). Run after `uv build` via `uv run --no-project python scripts/check_dist.py`.

## FORMATTING DRIFT — resolved, and why CI exists
Before Phase 6b, `ruff format --check .` **failed on 4 files** on `master`: `_async_client.py`, `services/async_document_types.py`, `services/models.py`, `tests/unit/test_async_client.py`. All four had been wrapped at ruff's default 88 columns instead of this project's configured `line-length = 100`, i.e. Phase 6a was committed without `ruff format` ever being run against the project config. The fix was pure line-rejoining (no semantic change) and is included in the Phase 6b CI commit.

**Consequence for this file's own claims:** several CURRENT_STATE entries above asserted "`ruff check`, `ruff format --check`, `mypy src` all clean" while the format gate was in fact red. Do NOT copy a green-gates claim forward from a previous entry — re-run the four commands and report what they actually print.

## PENDING
- [x] Phase 3b: UBL document models (Invoice-first)
Expand All @@ -165,7 +174,7 @@ Test-only signing fixtures `tests/fixtures/cert/dummy_signing_{cert,key}.pem` ar
- [x] Phase 4: digital signature
- [x] Phase 5: submit + state services
- [x] Phase 6a: async mirror (`AsyncMyInvoisClient` + 5 async services)
- [ ] Phase 6b: polish + publish (CI, PyPI, live sandbox verification, remaining 7 document types)
- [~] Phase 6b: polish + publish — CI + packaging metadata DONE; PyPI Trusted Publishing, remaining 7 document types and live sandbox verification still TODO

## PHASE 4 — Digital signature (TDD, in flight)

Expand Down Expand Up @@ -595,9 +604,11 @@ for token cache-hit / proactive refresh (the `AsyncTokenManager` refresh-ahead
path and its `asyncio.Lock` are untested), and no async test for HTTP-status ->
exception mapping. Both are covered on the sync side only.

## PHASE 6b — Polish + publish (TODO)
## PHASE 6b — Polish + publish (partially DONE)

1. **CI** — `.github/workflows/ci.yml`: `uv run ruff check . && uv run ruff format --check . && uv run mypy src/myinvois && uv run pytest`. Matrix over Python 3.11/3.12/3.13.
2. **Release** — GitHub Actions Trusted Publishing to PyPI on tag `v*`. Confirm the wheel excludes `tests/fixtures/cert/*` (see `CERTIFY_BEFORE_PUBLIC` in the Phase 4 notes) and includes `py.typed` + `codes/_data/*.json`.
3. **Remaining document types** — `02` Credit Note, `03` Debit Note, `04` Refund Note, `11`-`14` self-billed variants. Per the Phase 3b design decision these reuse the Invoice models; self-billed swaps supplier/customer roles via a single builder function. The envelope builders already dispatch on the document tag (`ENVELOPE_DOCUMENT_TAGS`), so the work is model-side plus new golden fixtures from the PHP SDK.
4. **Live sandbox verification** — run the full build/sign/submit pipeline against `preprod-api.myinvois.hasil.gov.my` with a real LHDN cert. Everything so far is verified against PHP-SDK goldens, which is a proxy for (not proof of) LHDN validator acceptance.
1. **CI** — **DONE.** `.github/workflows/ci.yml` with `lint` / `test` (3.11-3.13 matrix) / `package` jobs. See the GITHUB section. Adding it surfaced 4 pre-existing `ruff format` failures — see the FORMATTING DRIFT section.
2. **Packaging metadata** — **DONE.** `pyproject.toml` `[project.urls]` had shipped placeholder `https://github.com/your-org/myinvois` URLs into the wheel METADATA; corrected to `danieyal/myinvois-python`. `Development Status` classifier bumped `3 - Alpha` -> `4 - Beta` to match the README.
3. **`CERTIFY_BEFORE_PUBLIC` — RESOLVED.** Verified empirically: neither the wheel nor the sdist ships `tests/` or `tests/fixtures/cert/*`. The wheel carries only `myinvois/**` + `dist-info`; the sdist carries `PKG-INFO`, `README.md`, `pyproject.toml`, `src/`. `scripts/check_dist.py` now enforces this in CI, so the Phase 4 worry is closed rather than merely observed.
4. **Release** — TODO. GitHub Actions Trusted Publishing to PyPI on tag `v*`, gated on the `package` job. **Outward-facing and irreversible per-version: confirm with the user before the first publish.**
5. **Remaining document types** — TODO. `02` Credit Note, `03` Debit Note, `04` Refund Note, `11`-`14` self-billed variants. Per the Phase 3b design decision these reuse the Invoice models; self-billed swaps supplier/customer roles via a single builder function. The envelope builders already dispatch on the document tag (`ENVELOPE_DOCUMENT_TAGS`), so the work is model-side plus new golden fixtures from the PHP SDK.
6. **Live sandbox verification** — TODO. Run the full build/sign/submit pipeline against `preprod-api.myinvois.hasil.gov.my` with a real LHDN cert. Everything so far is verified against PHP-SDK goldens, which is a proxy for (not proof of) LHDN validator acceptance.
10 changes: 5 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ license = "MIT"
requires-python = ">=3.11"
keywords = ["myinvois", "lhdn", "e-invoice", "malaysia", "ubl", "invoice"]
classifiers = [
"Development Status :: 3 - Alpha",
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
Expand All @@ -37,10 +37,10 @@ test = [
]

[project.urls]
Homepage = "https://github.com/your-org/myinvois"
Documentation = "https://github.com/your-org/myinvois#readme"
Repository = "https://github.com/your-org/myinvois"
Issues = "https://github.com/your-org/myinvois/issues"
Homepage = "https://github.com/danieyal/myinvois-python"
Documentation = "https://github.com/danieyal/myinvois-python#readme"
Repository = "https://github.com/danieyal/myinvois-python"
Issues = "https://github.com/danieyal/myinvois-python/issues"

[build-system]
requires = ["uv_build>=0.11.28,<0.12.0"]
Expand Down
138 changes: 138 additions & 0 deletions scripts/check_dist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Verify the built sdist and wheel before they are published.

Run from the repo root after `uv build`:

uv run --no-project python scripts/check_dist.py

Two classes of mistake are checked, both of which are unrecoverable once a
release is on PyPI (a version number can never be reused):

1. **Missing runtime data.** `py.typed` and `codes/_data/*.json` are not
importable Python modules, so they only ship because of the explicit
`[tool.uv.build-backend] data-includes` entry. If that entry regresses, the
package still builds and imports, but `myinvois.codes` raises at runtime on
a wheel install and type-checkers silently ignore the package.

2. **Leaked signing material.** `tests/fixtures/cert/` holds a dummy private
key and certificate that are deliberately force-tracked in git so the
byte-parity tests can pin against fixtures signed with them. They are
harmless (self-signed, test-only) but shipping a file named
`*_key.pem` inside a published distribution is the kind of thing that
triggers secret scanners and erodes trust, so it is a hard failure here.

Exits non-zero with a description of every problem found.
"""

from __future__ import annotations

import re
import sys
import tarfile
import zipfile
from pathlib import Path

DIST = Path(__file__).resolve().parents[1] / "dist"

# Non-Python files that must ship, as paths relative to the package root.
REQUIRED_DATA_MEMBERS = (
"myinvois/py.typed",
"myinvois/codes/_data/classification.json",
"myinvois/codes/_data/countries.json",
"myinvois/codes/_data/currencies.json",
"myinvois/codes/_data/msic.json",
"myinvois/codes/_data/payment_means.json",
"myinvois/codes/_data/states.json",
"myinvois/codes/_data/taxes.json",
"myinvois/codes/_data/units.json",
)

# The same files live at different prefixes in each distribution: the wheel is
# already package-rooted, while the sdist keeps the repo's `src/` layout. Both
# are checked -- `pip install --no-binary` builds from the sdist, so a
# `data-includes` regression there breaks installs just as badly, and would go
# unnoticed if only the wheel were validated.
REQUIRED_MEMBER_PREFIXES = {"wheel": "", "sdist": "src/"}

# Anything matching these must NOT appear in either distribution. Keyed on the
# path so a source module such as `ubl/signing/_cert.py` (legitimate) is not
# confused with an actual PEM payload.
FORBIDDEN_PATTERNS = (
re.compile(r"\.(pem|key|p12|pfx)$", re.IGNORECASE),
re.compile(r"(^|/)tests?/", re.IGNORECASE),
re.compile(r"(^|/)fixtures?/", re.IGNORECASE),
)


def _wheel_and_sdist() -> tuple[Path, Path]:
wheels = sorted(DIST.glob("*.whl"))
sdists = sorted(DIST.glob("*.tar.gz"))
if len(wheels) != 1 or len(sdists) != 1:
sys.exit(
f"expected exactly one wheel and one sdist in {DIST}, "
f"found {len(wheels)} wheel(s) and {len(sdists)} sdist(s). "
"Remove stale builds and re-run `uv build`."
)
return wheels[0], sdists[0]


def _wheel_members(wheel: Path) -> list[str]:
with zipfile.ZipFile(wheel) as zf:
return zf.namelist()


def _sdist_members(sdist: Path) -> list[str]:
with tarfile.open(sdist) as tf:
# Strip the leading `myinvois-<version>/` directory so the paths line
# up with how they are written in the repo.
return [name.partition("/")[2] for name in tf.getnames()]


def main() -> int:
if not DIST.is_dir():
sys.exit(f"{DIST} does not exist -- run `uv build` first.")

wheel, sdist = _wheel_and_sdist()
wheel_members = _wheel_members(wheel)
sdist_members = _sdist_members(sdist)

problems: list[str] = []

for kind, label, members in (
("wheel", wheel.name, wheel_members),
("sdist", sdist.name, sdist_members),
):
prefix = REQUIRED_MEMBER_PREFIXES[kind]
present = set(members)
for member in REQUIRED_DATA_MEMBERS:
if f"{prefix}{member}" not in present:
problems.append(
f"{label}: missing required member {prefix + member!r} -- check the "
"`[tool.uv.build-backend] data-includes` entry in pyproject.toml"
)

for label, members in ((wheel.name, wheel_members), (sdist.name, sdist_members)):
for member in members:
if not member or member.endswith("/"):
continue
for pattern in FORBIDDEN_PATTERNS:
if pattern.search(member):
problems.append(
f"{label}: must not ship {member!r} (matched {pattern.pattern})"
)
break

if problems:
print(f"Distribution check FAILED ({len(problems)} problem(s)):", file=sys.stderr)
for problem in problems:
print(f" - {problem}", file=sys.stderr)
return 1

scanned = len(wheel_members) + len(sdist_members)
print(f"Distribution check passed: {wheel.name}, {sdist.name}")
print(f" {len(REQUIRED_DATA_MEMBERS)} required members present in wheel and sdist")
print(f" no test fixtures or key material in {scanned} entries")
return 0


if __name__ == "__main__":
raise SystemExit(main())
4 changes: 1 addition & 3 deletions src/myinvois/_async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,7 @@ def __init__(
http_client: httpx.AsyncClient | None = None,
) -> None:
self._owns_client = http_client is None
self._http = (
http_client if http_client is not None else httpx.AsyncClient(timeout=30.0)
)
self._http = http_client if http_client is not None else httpx.AsyncClient(timeout=30.0)
self._environment = environment
self._base_api_url = base_api_url(environment)
self._base_portal_url = base_portal_url(environment)
Expand Down
4 changes: 1 addition & 3 deletions src/myinvois/services/async_document_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ async def get(self, id_: int | str) -> DocumentType:
raise TypeError(f"Expected dict from API, got {type(raw).__name__}")
return DocumentType.model_validate(raw)

async def get_version(
self, id_: int | str, version_id: int | str
) -> DocumentTypeVersion:
async def get_version(self, id_: int | str, version_id: int | str) -> DocumentTypeVersion:
raw = await self._client.request("GET", f"{self.BASE_PATH}/{id_}/versions/{version_id}")
if not isinstance(raw, dict):
raise TypeError(f"Expected dict from API, got {type(raw).__name__}")
Expand Down
Loading
Loading