Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
7885bed
chore(ty): drop mypy-coded ignores; enable invalid-argument-type/assi…
antosubash Oct 19, 2026
1d51b58
chore(ty): framework clean under invalid-argument-type/assignment; ch…
antosubash Oct 19, 2026
3767e11
docs: ty rule policy after #507 (CLAUDE.md, models docs, conventions …
antosubash Oct 19, 2026
396c04c
chore(ty): settings and branding clean under invalid-argument-type/as…
antosubash Oct 19, 2026
1b2009f
chore(ty): modules/users clean under invalid-argument-type/assignment…
antosubash Oct 19, 2026
3df3bed
fix(users): accept cookie_samesite case-insensitively now that it is …
antosubash Oct 19, 2026
b8a5105
chore(ty): drop workarounds made redundant by generic register_module…
antosubash Oct 19, 2026
ccf935e
chore: keep migrations.py under the 300-line cap
antosubash Oct 19, 2026
3c69f5d
chore(ty): tenants/api_tokens/permissions/feature_flags/audit_log/sit…
antosubash Oct 19, 2026
b8488b7
chore(ty): tenants service stays under size cap (#507)
antosubash Oct 19, 2026
dc6a003
chore(ty): fix misplaced ignore in tenants service (#507)
antosubash Oct 19, 2026
5d8b5e2
chore(ty): tenants service ignore placement (#507)
antosubash Oct 19, 2026
fb224c9
chore(ty): drop register_module_settings ignores made unused by the g…
antosubash Oct 19, 2026
bcbd2c0
chore(ty): background_tasks + file_storage clean under invalid-argume…
antosubash Oct 19, 2026
8688672
chore(ty): drop now-unused register_module_settings ignores (#507)
antosubash Oct 19, 2026
fb2baf2
test(settings): lenient-log secrecy test ignores records from before …
antosubash Oct 19, 2026
05e9f6a
docs(changelog): #507
antosubash Oct 19, 2026
50b4aca
fix: address code review findings (round 1, pass 1)
antosubash Oct 19, 2026
65654b7
fix: address code review findings (round 1, pass 1b)
antosubash Oct 19, 2026
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,24 @@ All notable changes to this project are documented in this file. The format is b
## [Unreleased]

### Changed
- `ty` now checks `invalid-argument-type` and `invalid-assignment` in shipped
code (tests stay exempt), and the ~150 mypy-style `# type: ignore[...]`
comments, which suppressed nothing under ty, are gone (#507). Remaining
third-party false positives carry `# ty: ignore[<rule>]`.
`settings.registration.register_module_settings` is generic over the settings
class, so `services_factory` receives the concrete type.
- **Behaviour change (#507):** a tenant resolver that returns a `UUID` (bare,
in a pair or in `TenantResolution`) now binds its string form; any other
non-`str` tenant id or source raises `TypeError` instead of flowing into
tenant filters.
- **Behaviour change (#507):** `UsersSettings.cookie_samesite` only accepts
`lax`, `strict` or `none` (case-insensitive). Any other stored value is
dropped with a WARNING and the default `lax` applies.
The settings editor treats a `Literal[...]` of strings as a string select
(it was filed under `json` with a free-text box).
- `get_file_storage_service` raises a clear `RuntimeError` when the storage
backend has not been initialised, instead of building a service around
`None` (#507).
- `RequiresPermission` raises `simple_module_core.permission_denied.PermissionDenied`
(an `HTTPException` carrying `.permission`); the 403 handler reads the
attribute instead of parsing the message (#518). `PERMISSION_DENIED_PREFIX`
Expand Down
13 changes: 9 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,10 +369,15 @@ development and testing (`session_cookie_secure`).
2 spaces, width 100) lints `host/client_app`, `packages`, module `pages/` and
`components/` dirs; `tsc` runs per workspace, and a module with `.tsx` but no
`tsconfig.json` fails `make ci-js-typecheck`.
- **`ty` false positives** from SQLModel (`unresolved-attribute`,
`unsupported-operator`, `unknown-argument`, `no-matching-overload`,
`invalid-argument-type`, `invalid-assignment`) are ignored in `pyproject.toml`.
Don't re-enable them; tests catch the real bugs.
- **`ty` rules.** SQLModel false positives (`unresolved-attribute`,
`unsupported-operator`, `unknown-argument`, `no-matching-overload`) are
ignored globally in `pyproject.toml`; don't re-enable them.
`invalid-argument-type` and `invalid-assignment` are **on** for shipped code
and ignored only for tests (`[[tool.ty.overrides]]`). Fix what they report;
for a genuine third-party false positive add `# ty: ignore[<rule>]` on that
line. Never write `# type: ignore[<mypy-code>]`: ty matches the bracket
against its own rule names, so it suppresses nothing and is never reported
unused.

## Adding a module or package

Expand Down
5 changes: 4 additions & 1 deletion docs/database/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,13 @@ SQLModel declares fields with plain Python types (`str`, `int`) at class definit
- `unsupported-operator`
- `unknown-argument`
- `no-matching-overload`
- `invalid-argument-type`

Do not re-enable these rules in module-local configs. Real bugs caused by wrong field access surface in the test suite — these rules produce only noise.

In this repository `invalid-argument-type` and `invalid-assignment` stay **on** for shipped code (tests are exempt through `[[tool.ty.overrides]]`), because they catch real bugs. A query-building line that still trips them, such as `selectinload(User.roles)` or `model_config = ConfigDict(...)`, gets a line-level `# ty: ignore[<rule>]`. Apps scaffolded by `smpy new` ignore both rules globally, as before.

Write suppressions as `# ty: ignore[<rule>]`. A mypy-style `# type: ignore[<code>]` is matched against ty's rule names, so it silences nothing.

## Next steps

- [Per-module Base](/database/per-module-base) — how `create_module_base` and `build_module_metadata` work.
Expand Down
3 changes: 2 additions & 1 deletion framework/cli/simple_module_cli/scaffolding.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import shutil
from collections.abc import Sequence
from pathlib import Path
from typing import TypeGuard

# The template engine lives in _templating.py; the private names are imported
# here because long-standing tests monkeypatch them on this module.
Expand Down Expand Up @@ -91,7 +92,7 @@ def is_inside_existing_repo(dest: Path) -> bool:
return False


def _should_pin_framework_version(version: str | None) -> bool:
def _should_pin_framework_version(version: str | None) -> TypeGuard[str]:
"""Whether ``version`` is a concrete pin rather than a skip sentinel.

Both scaffolders skip pinning for ``None`` (a library caller that wants the
Expand Down
2 changes: 1 addition & 1 deletion framework/core/simple_module_core/i18n.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@


@lru_cache(maxsize=64)
def _plural_rule(locale: str): # type: ignore[no-untyped-def]
def _plural_rule(locale: str):
"""Cached CLDR plural rule for a locale tag (e.g. 'en', 'ru', 'pt_BR')."""
return Locale.parse(locale).plural_form

Expand Down
2 changes: 1 addition & 1 deletion framework/core/simple_module_core/stored_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def field_error(
title=model.__name__,
line_errors=[
InitErrorDetails(
type=PydanticCustomError(error_type, message, {RELATED_FIELDS_CTX: list(related)}),
type=PydanticCustomError(error_type, message, {RELATED_FIELDS_CTX: list(related)}), # ty: ignore[invalid-argument-type]
loc=(field,),
input=value,
)
Expand Down
2 changes: 1 addition & 1 deletion framework/core/tests/test_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ async def test_load_failure_non_strict_logs_and_skips(self, monkeypatch, caplog)

async def test_meta_must_be_modulemeta_instance(self, monkeypatch):
class BadMeta(ModuleBase):
meta = "not a ModuleMeta" # type: ignore[assignment]
meta = "not a ModuleMeta"

_patch_entry_points(monkeypatch, [_FakeEntryPoint("bad", BadMeta)])

Expand Down
2 changes: 1 addition & 1 deletion framework/core/tests/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ async def handler(event: OrderCreated):
await bus.publish(OrderCreated(order_id=42))

assert len(received) == 1
assert received[0].order_id == 42 # type: ignore[attr-defined]
assert received[0].order_id == 42

async def test_multiple_handlers(self):
bus = EventBus()
Expand Down
4 changes: 2 additions & 2 deletions framework/core/tests/test_feature_flags.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ async def test_is_flag_enabled_reads_tenant_from_request_state(self):
app=SimpleNamespace(state=SimpleNamespace(sm=SimpleNamespace(feature_flags=reg))),
state=SimpleNamespace(tenant_id="acme"),
)
assert is_flag_enabled(request, "beta_ui") is True # type: ignore[arg-type]
assert is_flag_enabled(request, "beta_ui") is True

request.state.tenant_id = "other"
assert is_flag_enabled(request, "beta_ui") is False # type: ignore[arg-type]
assert is_flag_enabled(request, "beta_ui") is False
4 changes: 2 additions & 2 deletions framework/core/tests/test_feature_flags_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ async def test_is_flag_enabled_accepts_definition(self):
app=SimpleNamespace(state=SimpleNamespace(sm=SimpleNamespace(feature_flags=reg))),
state=SimpleNamespace(tenant_id=None),
)
assert is_flag_enabled(request, FLAG_BETA_UI) is True # type: ignore[arg-type]
assert is_flag_enabled(request, FLAG_BETA_UI) is True

async def test_flag_enabled_and_require_flag_accept_definition(self):
reg = FeatureFlagRegistry()
Expand Down Expand Up @@ -147,4 +147,4 @@ def handler(request: Request) -> str:
app=SimpleNamespace(state=SimpleNamespace(sm=SimpleNamespace(feature_flags=reg))),
state=SimpleNamespace(tenant_id=None),
)
assert handler(request) == "ok" # type: ignore[arg-type]
assert handler(request) == "ok"
4 changes: 2 additions & 2 deletions framework/core/tests/test_module_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ async def test_custom_fields(self):
async def test_frozen(self):
meta = ModuleMeta(name="Frozen")
with pytest.raises(AttributeError):
meta.name = "Changed" # type: ignore[misc]
meta.name = "Changed"


class DummyModule(ModuleBase):
Expand All @@ -55,7 +55,7 @@ async def test_subclass_has_meta(self):

async def test_register_routes_override(self):
mod = DummyModule()
mod.register_routes(None, None) # type: ignore[arg-type]
mod.register_routes(None, None)
assert mod.routes_registered is True

async def test_default_noop_methods(self):
Expand Down
30 changes: 15 additions & 15 deletions framework/core/tests/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ async def test_services_is_frozen(self) -> None:
"""Mutation after construction must raise — singletons don't change at runtime."""
s = _make_services()
with pytest.raises((AttributeError, TypeError)):
s.settings = None # type: ignore[misc,assignment]
s.settings = None

async def test_services_has_slots(self) -> None:
"""Slotted dataclass prevents silent attribute additions (the original bloat pattern)."""
s = _make_services()
with pytest.raises((AttributeError, TypeError)):
s.rogue_new_attribute = 42 # type: ignore[attr-defined]
s.rogue_new_attribute = 42

async def test_services_round_trip_field_access(self) -> None:
"""Every declared field must be readable after construction."""
Expand Down Expand Up @@ -58,18 +58,18 @@ async def test_services_round_trip_field_access(self) -> None:
def _make_services() -> Services:
"""Construct a Services instance with sentinel values for structural tests."""
return Services(
settings=_SENTINEL_SETTINGS, # type: ignore[arg-type]
db=_SENTINEL_DB, # type: ignore[arg-type]
event_bus=_SENTINEL_EVENT_BUS, # type: ignore[arg-type]
menu_registry=_SENTINEL_MENU, # type: ignore[arg-type]
permissions=_SENTINEL_PERMS, # type: ignore[arg-type]
feature_flags=_SENTINEL_FLAGS, # type: ignore[arg-type]
health_registry=_SENTINEL_HEALTH, # type: ignore[arg-type]
public_routes=_SENTINEL_PUBLIC_ROUTES, # type: ignore[arg-type]
setup_registry=_SENTINEL_SETUP, # type: ignore[arg-type]
design_packs=_SENTINEL_DESIGN_PACKS, # type: ignore[arg-type]
audit_links=_SENTINEL_AUDIT_LINKS, # type: ignore[arg-type]
i18n_registry=_SENTINEL_I18N, # type: ignore[arg-type]
inertia_config=_SENTINEL_INERTIA, # type: ignore[arg-type]
settings=_SENTINEL_SETTINGS,
db=_SENTINEL_DB,
event_bus=_SENTINEL_EVENT_BUS,
menu_registry=_SENTINEL_MENU,
permissions=_SENTINEL_PERMS,
feature_flags=_SENTINEL_FLAGS,
health_registry=_SENTINEL_HEALTH,
public_routes=_SENTINEL_PUBLIC_ROUTES,
setup_registry=_SENTINEL_SETUP,
design_packs=_SENTINEL_DESIGN_PACKS,
audit_links=_SENTINEL_AUDIT_LINKS,
i18n_registry=_SENTINEL_I18N,
inertia_config=_SENTINEL_INERTIA,
modules=(),
)
4 changes: 2 additions & 2 deletions framework/db/simple_module_db/_core_joins.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ def _join(self, node: Join) -> tuple[Any, list[Any]]:
on, owed = owed_right, owed_left
if not on and left is node.left and right is node.right:
return node, owed
onclause = and_(node.onclause, *_flatten(on)) if on else node.onclause
onclause = and_(node.onclause, *_flatten(on)) if on else node.onclause # ty: ignore[invalid-argument-type]
return Join(left, right, onclause, isouter=node.isouter, full=node.full), owed


Expand Down Expand Up @@ -181,7 +181,7 @@ def _scope_setup_join(stmt: Select, index: int, scoper: _Scoper, owed: list[tupl
on = [*target_owed, *(_or_padded(o) for o in left_owed)]
if full:
owed.extend(target_owed)
return (target, and_(onclause, *_flatten(on)), from_, flags)
return (target, and_(onclause, *_flatten(on)), from_, flags) # ty: ignore[invalid-argument-type]


def _resolved_join(stmt: Select, index: int) -> Join | None:
Expand Down
6 changes: 3 additions & 3 deletions framework/db/simple_module_db/_core_walk.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

from typing import Any

from sqlalchemy.sql.elements import ColumnClause, ColumnElement
from sqlalchemy.sql.elements import ClauseElement, ColumnClause, ColumnElement
from sqlalchemy.sql.selectable import Alias, Join, Select, TableClause
from sqlalchemy.sql.traversals import _get_children

Expand Down Expand Up @@ -63,7 +63,7 @@ def core_where_froms(stmt: Select) -> list[Any]:
references are scoped where that select is visited).
"""
found: list[Any] = []
stack = list(stmt._where_criteria)
stack: list[ClauseElement] = list(stmt._where_criteria)
while stack:
el = stack.pop()
if _is_orm(el) or not el._from_objects:
Expand Down Expand Up @@ -173,7 +173,7 @@ def _table_of(el: Any) -> Any:
via_comparator = _COMPARATOR_LOOKUP.get(cls)
if via_comparator is None:
via_comparator = _COMPARATOR_LOOKUP[cls] = (
cls.__getattr__ is ColumnElement.__getattr__ # type: ignore[attr-defined]
cls.__getattr__ is ColumnElement.__getattr__
if issubclass(cls, ColumnElement)
and cls.__dictoffset__ != 0
and not any("table" in vars(k) for k in cls.__mro__)
Expand Down
2 changes: 1 addition & 1 deletion framework/db/simple_module_db/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def create_module_base(module_name: str) -> type[SQLModel]:
},
)

ModuleBase.__module_name__ = module_name # type: ignore[attr-defined]
ModuleBase.__module_name__ = module_name

_base_cache[module_name] = ModuleBase
_register_base(ModuleBase)
Expand Down
7 changes: 4 additions & 3 deletions framework/db/simple_module_db/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,10 @@

# Matches alembic.context.configure's include_object signature exactly so
# type-checkers accept the returned filter without casts or ignores.
_SchemaItemType = Literal[
"schema", "table", "column", "index", "unique_constraint", "foreign_key_constraint"
]
_SchemaItemType = (
Literal["schema", "table", "column", "index", "unique_constraint", "foreign_key_constraint"]
| Literal["check_constraint"]
)
IncludeObjectFn = Callable[[SchemaItem, str | None, _SchemaItemType, bool, SchemaItem | None], bool]
ProcessRevisionDirectivesFn = Callable[[object, object, list], None]

Expand Down
4 changes: 2 additions & 2 deletions framework/db/simple_module_db/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def _configure_sqlite(
apply_wal = wal and _is_file_database(database_url)

@event.listens_for(engine.sync_engine, "connect")
def _set_sqlite_pragmas(dbapi_connection, _connection_record) -> None: # type: ignore[misc]
def _set_sqlite_pragmas(dbapi_connection, _connection_record) -> None:
cursor = dbapi_connection.cursor()
try:
if foreign_keys:
Expand All @@ -168,7 +168,7 @@ def _set_sqlite_pragmas(dbapi_connection, _connection_record) -> None: # type:
cursor.close()

@event.listens_for(engine.sync_engine, "savepoint")
def _begin_before_outermost_savepoint(conn, _name) -> None: # type: ignore[misc]
def _begin_before_outermost_savepoint(conn, _name) -> None:
# pysqlite only emits BEGIN lazily before DML, so a SAVEPOINT that is
# the first statement would *start* the transaction and its RELEASE
# would COMMIT it, leaving the outer rollback nothing to undo (GH #350).
Expand Down
4 changes: 3 additions & 1 deletion framework/db/simple_module_db/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
import json
import logging
import time
from collections.abc import MutableMapping
from typing import Any

from sqlalchemy.ext.asyncio import AsyncSession

Expand Down Expand Up @@ -138,7 +140,7 @@ async def rollback_session(session: AsyncSession) -> None:
)


def register_request_session(scope: dict, session: AsyncSession) -> None:
def register_request_session(scope: MutableMapping[str, Any], session: AsyncSession) -> None:
"""Enlist ``session`` for commit-before-response, if the middleware is installed.

A no-op when it isn't: ``get_db`` still commits in its own exit code, just
Expand Down
2 changes: 1 addition & 1 deletion framework/db/simple_module_db/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def __repr__(self) -> str:

# Let Postgres reflection (autogenerate, ``alembic check``) return ``LTree``
# instead of warning "Did not recognize type 'ltree'" and falling back to NullType.
postgresql.base.ischema_names["ltree"] = LTree
postgresql.base.ischema_names["ltree"] = LTree # ty: ignore[invalid-assignment]


class TSVector(TypeDecorator):
Expand Down
10 changes: 5 additions & 5 deletions framework/db/tests/_audit_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
AuditBase = create_module_base("test_audit")


class AuditTestItem(AuditBase, AuditMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base]
class AuditTestItem(AuditBase, AuditMixin, table=True): # ty: ignore[unsupported-base]
"""Standard audited entity for testing."""

__tablename__ = "test_audit_item"
Expand All @@ -24,7 +24,7 @@ class AuditTestItem(AuditBase, AuditMixin, table=True): # type: ignore[call-arg
value: int = Field(default=0)


class ExcludedModel(AuditBase, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base]
class ExcludedModel(AuditBase, table=True): # ty: ignore[unsupported-base]
"""Model that opts out of auditing entirely."""

__tablename__ = "test_audit_excluded"
Expand All @@ -33,7 +33,7 @@ class ExcludedModel(AuditBase, table=True): # type: ignore[call-arg] # ty: ign
name: str = Field(max_length=100)


class PartialExcludeModel(AuditBase, AuditMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base]
class PartialExcludeModel(AuditBase, AuditMixin, table=True): # ty: ignore[unsupported-base]
"""Model with specific fields excluded from audit tracking."""

__tablename__ = "test_audit_partial"
Expand All @@ -43,15 +43,15 @@ class PartialExcludeModel(AuditBase, AuditMixin, table=True): # type: ignore[ca
password_hash: str = Field(max_length=255, default="")


class SoftDeleteItem(AuditBase, AuditMixin, SoftDeleteMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base]
class SoftDeleteItem(AuditBase, AuditMixin, SoftDeleteMixin, table=True): # ty: ignore[unsupported-base]
"""Audited entity with soft-delete support for testing."""

__tablename__ = "test_audit_soft_delete_item"
id: int | None = Field(default=None, primary_key=True)
title: str = Field(max_length=100)


class IntPKItem(AuditBase, AuditMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base]
class IntPKItem(AuditBase, AuditMixin, table=True): # ty: ignore[unsupported-base]
"""Entity with a DB-assigned integer primary key (BUG-002 regression case)."""

__tablename__ = "test_audit_int_pk_item"
Expand Down
8 changes: 4 additions & 4 deletions framework/db/tests/_core_scope_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,26 @@
_Base = create_module_base("cscope")


class Tag(_Base, MultiTenantMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base]
class Tag(_Base, MultiTenantMixin, table=True): # ty: ignore[unsupported-base]
__tablename__ = "cscope_tag"
id: int | None = Field(default=None, primary_key=True)
name: str = Field(default="", max_length=100)


class Link(_Base, MultiTenantMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base]
class Link(_Base, MultiTenantMixin, table=True): # ty: ignore[unsupported-base]
__tablename__ = "cscope_link"
id: int | None = Field(default=None, primary_key=True)
tag_id: int | None = None
article_id: int = 0


class Soft(_Base, SoftDeleteMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base]
class Soft(_Base, SoftDeleteMixin, table=True): # ty: ignore[unsupported-base]
__tablename__ = "cscope_soft"
id: int | None = Field(default=None, primary_key=True)
tag_id: int | None = None


class Plain(_Base, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base]
class Plain(_Base, table=True): # ty: ignore[unsupported-base]
__tablename__ = "cscope_plain"
id: int | None = Field(default=None, primary_key=True)
name: str = Field(default="", max_length=100)
Expand Down
Loading
Loading