Skip to content

Commit 7878ce1

Browse files
authored
chore: pyright strict mode (#21)
1 parent 101e653 commit 7878ce1

7 files changed

Lines changed: 57 additions & 16 deletions

File tree

pyrightconfig.json

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,32 @@
1010
// repeated here rather than silently dropped.
1111
"exclude": ["**/node_modules", "**/.*", "**/__pycache__", ".venv", "dist"],
1212
"pythonVersion": "3.11",
13-
"typeCheckingMode": "standard",
14-
"reportMissingTypeStubs": false
13+
14+
// Strict, minus the rules that are pure noise for this codebase's idioms.
15+
// Each exception is a whole category we would otherwise suppress inline
16+
// dozens of times, which hides real findings rather than surfacing them.
17+
"typeCheckingMode": "strict",
18+
19+
"reportMissingTypeStubs": false,
20+
21+
// lxml-stubs and Pydantic's internals expose many values as Unknown. These
22+
// fire ~90 times across the models and builders with nothing actionable
23+
// behind them; turning them off keeps strict's genuine checks visible.
24+
"reportUnknownVariableType": false,
25+
"reportUnknownArgumentType": false,
26+
"reportUnknownMemberType": false,
27+
"reportUnknownParameterType": false,
28+
"reportUnknownLambdaType": false,
29+
30+
// The UBL models share a deliberate cross-module internal API (`_leaf`,
31+
// `_money`, `_UblModel`, `_Base`). That is by design, not a leak.
32+
"reportPrivateUsage": false,
33+
34+
// Pydantic `mode="before"` validators genuinely receive `Any` at runtime,
35+
// so the isinstance/type checks they perform are necessary, not redundant.
36+
"reportUnnecessaryIsInstance": false,
37+
38+
// Requiring an explicit annotation on every test helper and lambda is noise
39+
// for a test suite; src is covered by mypy --strict regardless.
40+
"reportMissingParameterType": false
1541
}

scripts/extract_codes.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,23 @@
1010
import json
1111
import re
1212
from pathlib import Path
13+
from typing import TypedDict
14+
15+
16+
class _TableSpec(TypedDict):
17+
out: str
18+
columns: list[tuple[str, str]]
19+
1320

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

1825
# Map: PHP class => {output_filename, columns[(php_const, json_key)]}
19-
TABLES = {
26+
#: Per-table extraction spec. A TypedDict so ``spec["out"]`` types as ``str``
27+
#: and ``spec["columns"]`` as the column list, rather than unifying to a union
28+
#: that neither ``Path / spec["out"]`` nor the column loop can use.
29+
TABLES: dict[str, _TableSpec] = {
2030
"StateCodes": {
2131
"out": "states.json",
2232
"columns": [("CODE", "code"), ("STATE", "name")],

src/myinvois/services/models.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,6 @@ class DocumentTypeList(_Base):
7575
result: list[DocumentType]
7676

7777

78-
# A small helper used by services to wrap raw dicts as models where the LHDN
79-
# response shape is a plain list (no `result` wrapper).
80-
def _as_items(raw: Any, *, model: type[BaseModel], key: str = "result") -> list[BaseModel]:
81-
if isinstance(raw, dict) and key in raw and isinstance(raw[key], list):
82-
return [model.model_validate(item) for item in raw[key]]
83-
if isinstance(raw, list):
84-
return [model.model_validate(item) for item in raw]
85-
return []
86-
87-
8878
# ---------------------------------------------------------------------------
8979
# Phase 5 — Submit documents + Get submission response models
9080
# Spec: https://sdk.myinvois.hasil.gov.my/einvoicingapi/02-submit-documents/

src/myinvois/ubl/_base.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818

1919
from pydantic import BaseModel, ConfigDict
2020

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

2228
class _UblModel(BaseModel):
2329
"""Base for every UBL structural model.

src/myinvois/ubl/invoice.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,11 @@ def _dl_list(seq: list[Any]) -> list[dict[str, Any]]:
235235
"IssueTime": _leaf(self.issue_date_time.strftime("%H:%M:%SZ")),
236236
}
237237

238-
if self.invoice_type_code is not None:
238+
# Statically always-true (the field is required), but kept as a guard
239+
# for models built via `model_construct()`, which bypasses validation
240+
# and can leave a required field as None. Serializing such a model
241+
# should skip the key, not raise.
242+
if self.invoice_type_code is not None: # pyright: ignore[reportUnnecessaryComparison]
239243
tc_val = (
240244
self.invoice_type_code.value
241245
if isinstance(self.invoice_type_code, DocumentTypeCode)

src/myinvois/ubl/reference.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,9 @@ def _must_have_id(self) -> OrderReference:
148148
@model_serializer
149149
def _ser(self) -> dict[str, Any]:
150150
out: dict[str, Any] = {}
151-
if self.id is not None:
151+
# Statically always-true (id is required), but kept as a guard for
152+
# models built via `model_construct()`, which bypasses validation.
153+
if self.id is not None: # pyright: ignore[reportUnnecessaryComparison]
152154
out["ID"] = _leaf(self.id)
153155
if self.sales_order_id is not None:
154156
out["SalesOrderID"] = _leaf(self.sales_order_id)

tests/unit/test_auth.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,10 @@ def test_oauth2_token_from_response() -> None:
5656

5757

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

6265

0 commit comments

Comments
 (0)