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
30 changes: 28 additions & 2 deletions pyrightconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@
// repeated here rather than silently dropped.
"exclude": ["**/node_modules", "**/.*", "**/__pycache__", ".venv", "dist"],
"pythonVersion": "3.11",
"typeCheckingMode": "standard",
"reportMissingTypeStubs": false

// Strict, minus the rules that are pure noise for this codebase's idioms.
// Each exception is a whole category we would otherwise suppress inline
// dozens of times, which hides real findings rather than surfacing them.
"typeCheckingMode": "strict",

"reportMissingTypeStubs": false,

// lxml-stubs and Pydantic's internals expose many values as Unknown. These
// fire ~90 times across the models and builders with nothing actionable
// behind them; turning them off keeps strict's genuine checks visible.
"reportUnknownVariableType": false,
"reportUnknownArgumentType": false,
"reportUnknownMemberType": false,
"reportUnknownParameterType": false,
"reportUnknownLambdaType": false,

// The UBL models share a deliberate cross-module internal API (`_leaf`,
// `_money`, `_UblModel`, `_Base`). That is by design, not a leak.
"reportPrivateUsage": false,

// Pydantic `mode="before"` validators genuinely receive `Any` at runtime,
// so the isinstance/type checks they perform are necessary, not redundant.
"reportUnnecessaryIsInstance": false,

// Requiring an explicit annotation on every test helper and lambda is noise
// for a test suite; src is covered by mypy --strict regardless.
"reportMissingParameterType": false
}
12 changes: 11 additions & 1 deletion scripts/extract_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,23 @@
import json
import re
from pathlib import Path
from typing import TypedDict


class _TableSpec(TypedDict):
out: str
columns: list[tuple[str, str]]


PHPSDK = Path("/tmp/phpsdk/src/Ubl/Constant")
OUT = Path(__file__).resolve().parents[1] / "src" / "myinvois" / "codes" / "_data"
OUT.mkdir(parents=True, exist_ok=True)

# Map: PHP class => {output_filename, columns[(php_const, json_key)]}
TABLES = {
#: Per-table extraction spec. A TypedDict so ``spec["out"]`` types as ``str``
#: and ``spec["columns"]`` as the column list, rather than unifying to a union
#: that neither ``Path / spec["out"]`` nor the column loop can use.
TABLES: dict[str, _TableSpec] = {
"StateCodes": {
"out": "states.json",
"columns": [("CODE", "code"), ("STATE", "name")],
Expand Down
10 changes: 0 additions & 10 deletions src/myinvois/services/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,6 @@ class DocumentTypeList(_Base):
result: list[DocumentType]


# A small helper used by services to wrap raw dicts as models where the LHDN
# response shape is a plain list (no `result` wrapper).
def _as_items(raw: Any, *, model: type[BaseModel], key: str = "result") -> list[BaseModel]:
if isinstance(raw, dict) and key in raw and isinstance(raw[key], list):
return [model.model_validate(item) for item in raw[key]]
if isinstance(raw, list):
return [model.model_validate(item) for item in raw]
return []


# ---------------------------------------------------------------------------
# Phase 5 — Submit documents + Get submission response models
# Spec: https://sdk.myinvois.hasil.gov.my/einvoicingapi/02-submit-documents/
Expand Down
6 changes: 6 additions & 0 deletions src/myinvois/ubl/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@

from pydantic import BaseModel, ConfigDict

# These are the module's internal API, imported by the sibling model modules
# (address.py, party.py, tax.py, …) rather than used within this file. Naming
# them in __all__ marks them as exported so a "used only via import" checker
# does not read them as dead.
__all__ = ["_UblModel", "_leaf", "_money"]


class _UblModel(BaseModel):
"""Base for every UBL structural model.
Expand Down
6 changes: 5 additions & 1 deletion src/myinvois/ubl/invoice.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,11 @@ def _dl_list(seq: list[Any]) -> list[dict[str, Any]]:
"IssueTime": _leaf(self.issue_date_time.strftime("%H:%M:%SZ")),
}

if self.invoice_type_code is not None:
# Statically always-true (the field is required), but kept as a guard
# for models built via `model_construct()`, which bypasses validation
# and can leave a required field as None. Serializing such a model
# should skip the key, not raise.
if self.invoice_type_code is not None: # pyright: ignore[reportUnnecessaryComparison]
tc_val = (
self.invoice_type_code.value
if isinstance(self.invoice_type_code, DocumentTypeCode)
Expand Down
4 changes: 3 additions & 1 deletion src/myinvois/ubl/reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ def _must_have_id(self) -> OrderReference:
@model_serializer
def _ser(self) -> dict[str, Any]:
out: dict[str, Any] = {}
if self.id is not None:
# Statically always-true (id is required), but kept as a guard for
# models built via `model_construct()`, which bypasses validation.
if self.id is not None: # pyright: ignore[reportUnnecessaryComparison]
out["ID"] = _leaf(self.id)
if self.sales_order_id is not None:
out["SalesOrderID"] = _leaf(self.sales_order_id)
Expand Down
5 changes: 4 additions & 1 deletion tests/unit/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ def test_oauth2_token_from_response() -> None:


def test_oauth2_token_defaults_scheme_to_bearer() -> None:
resp = {"access_token": "X", "expires_in": 60} # no token_type
# Annotated `dict[str, object]`: an inferred `dict[str, str | int]` is not
# assignable to `from_response`'s `dict[str, object]` parameter, because
# dict value types are invariant.
resp: dict[str, object] = {"access_token": "X", "expires_in": 60} # no token_type
assert OAuth2Token.from_response(resp, fetched_at=0.0).token_type == "Bearer"


Expand Down
Loading