From 7885bedc5bd36e8c6013e76388aed85429e9cf89 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:23:34 +0200 Subject: [PATCH 01/19] chore(ty): drop mypy-coded ignores; enable invalid-argument-type/assignment outside tests (#507) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- framework/core/simple_module_core/i18n.py | 2 +- framework/core/tests/test_discovery.py | 2 +- framework/core/tests/test_events.py | 2 +- framework/core/tests/test_feature_flags.py | 4 +-- .../tests/test_feature_flags_decorator.py | 4 +-- framework/core/tests/test_module_base.py | 4 +-- framework/core/tests/test_services.py | 30 +++++++++---------- framework/db/simple_module_db/_core_walk.py | 2 +- framework/db/simple_module_db/base.py | 2 +- framework/db/simple_module_db/session.py | 4 +-- framework/db/tests/_audit_models.py | 10 +++---- framework/db/tests/_core_scope_models.py | 8 ++--- framework/db/tests/test_base.py | 2 +- .../db/tests/test_db_core_full_setup_join.py | 4 +-- framework/db/tests/test_db_logging.py | 4 +-- framework/db/tests/test_mixins.py | 8 ++--- .../db/tests/test_query_filter_outer_joins.py | 8 ++--- .../test_query_filter_rel_outer_joins.py | 4 +-- framework/db/tests/test_query_filters.py | 4 +-- framework/db/tests/test_writes.py | 4 +-- .../hosting/simple_module_hosting/_openapi.py | 2 +- .../simple_module_hosting/_route_guard.py | 4 +-- .../hosting/simple_module_hosting/_tenant.py | 2 +- .../hosting/simple_module_hosting/logging.py | 2 +- .../hosting/tests/test_hosting_permissions.py | 4 +-- framework/hosting/tests/test_i18n_routes.py | 2 +- .../hosting/tests/test_invalidation_wiring.py | 26 ++++++++-------- .../hosting/tests/test_lifespan_order.py | 4 +-- framework/hosting/tests/test_logging.py | 16 +++++----- framework/hosting/tests/test_route_guard.py | 2 +- .../testing/simple_module_test/fake_events.py | 2 +- .../testing/simple_module_test/plugin.py | 2 +- .../tests/test_entity_label_batching.py | 4 +-- .../tests/test_bg_invalidation_redis.py | 2 +- .../tests/test_log_context.py | 10 +++---- modules/branding/tests/test_branding.py | 4 +-- modules/branding/tests/test_ship_review.py | 2 +- .../file_storage/backends/s3_multipart.py | 2 +- .../file_storage/tests/test_bulk_delete.py | 2 +- .../test_file_storage_delete_variants.py | 2 +- .../tests/test_file_storage_filters.py | 2 +- modules/file_storage/tests/test_service.py | 6 ++-- .../tests/test_upload_failure_modes.py | 8 ++--- .../tests/test_keycloak_bearer_kind.py | 2 +- .../settings/tests/test_module_settings.py | 28 ++++++++--------- modules/users/tests/test_bootstrap.py | 6 ++-- modules/users/tests/test_invite_flow.py | 4 +-- modules/users/tests/test_invite_reuse.py | 2 +- modules/users/tests/test_mailer.py | 20 ++++++------- .../users/tests/test_session_revocation.py | 2 +- modules/users/users/admin/queries.py | 8 ++--- modules/users/users/auth_local/token_api.py | 2 +- modules/users/users/backend.py | 4 +-- pyproject.toml | 22 +++++++++----- scripts/bench_core_scope.py | 4 +-- tests/integration/test_i18n_flow.py | 2 +- 56 files changed, 166 insertions(+), 164 deletions(-) diff --git a/framework/core/simple_module_core/i18n.py b/framework/core/simple_module_core/i18n.py index 1b04e5896..2495925d2 100644 --- a/framework/core/simple_module_core/i18n.py +++ b/framework/core/simple_module_core/i18n.py @@ -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 diff --git a/framework/core/tests/test_discovery.py b/framework/core/tests/test_discovery.py index 8f73c6d98..ada0fbf06 100644 --- a/framework/core/tests/test_discovery.py +++ b/framework/core/tests/test_discovery.py @@ -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)]) diff --git a/framework/core/tests/test_events.py b/framework/core/tests/test_events.py index 342c9f36f..f77b754ed 100644 --- a/framework/core/tests/test_events.py +++ b/framework/core/tests/test_events.py @@ -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() diff --git a/framework/core/tests/test_feature_flags.py b/framework/core/tests/test_feature_flags.py index fe2e717d4..83eda3217 100644 --- a/framework/core/tests/test_feature_flags.py +++ b/framework/core/tests/test_feature_flags.py @@ -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 diff --git a/framework/core/tests/test_feature_flags_decorator.py b/framework/core/tests/test_feature_flags_decorator.py index 1922808a4..8a3a825bb 100644 --- a/framework/core/tests/test_feature_flags_decorator.py +++ b/framework/core/tests/test_feature_flags_decorator.py @@ -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() @@ -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" diff --git a/framework/core/tests/test_module_base.py b/framework/core/tests/test_module_base.py index 8c5d7d356..fa4b66446 100644 --- a/framework/core/tests/test_module_base.py +++ b/framework/core/tests/test_module_base.py @@ -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): @@ -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): diff --git a/framework/core/tests/test_services.py b/framework/core/tests/test_services.py index d3eb086e8..fdf771f58 100644 --- a/framework/core/tests/test_services.py +++ b/framework/core/tests/test_services.py @@ -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.""" @@ -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=(), ) diff --git a/framework/db/simple_module_db/_core_walk.py b/framework/db/simple_module_db/_core_walk.py index 9c69ddfc4..ce718ee90 100644 --- a/framework/db/simple_module_db/_core_walk.py +++ b/framework/db/simple_module_db/_core_walk.py @@ -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__) diff --git a/framework/db/simple_module_db/base.py b/framework/db/simple_module_db/base.py index e825e9d9a..cae51dba5 100644 --- a/framework/db/simple_module_db/base.py +++ b/framework/db/simple_module_db/base.py @@ -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) diff --git a/framework/db/simple_module_db/session.py b/framework/db/simple_module_db/session.py index 3012a30c8..709cd31cc 100644 --- a/framework/db/simple_module_db/session.py +++ b/framework/db/simple_module_db/session.py @@ -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: @@ -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). diff --git a/framework/db/tests/_audit_models.py b/framework/db/tests/_audit_models.py index 0a7345a36..5109304b8 100644 --- a/framework/db/tests/_audit_models.py +++ b/framework/db/tests/_audit_models.py @@ -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" @@ -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" @@ -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" @@ -43,7 +43,7 @@ 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" @@ -51,7 +51,7 @@ class SoftDeleteItem(AuditBase, AuditMixin, SoftDeleteMixin, table=True): # typ 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" diff --git a/framework/db/tests/_core_scope_models.py b/framework/db/tests/_core_scope_models.py index a7a11b2e3..91c7c3e32 100644 --- a/framework/db/tests/_core_scope_models.py +++ b/framework/db/tests/_core_scope_models.py @@ -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) diff --git a/framework/db/tests/test_base.py b/framework/db/tests/test_base.py index f375a2960..5fcea603d 100644 --- a/framework/db/tests/test_base.py +++ b/framework/db/tests/test_base.py @@ -24,7 +24,7 @@ async def test_caching_same_name(self): async def test_module_name_stored(self): base = create_module_base("named_mod") - assert base.__module_name__ == "named_mod" # type: ignore[attr-defined] + assert base.__module_name__ == "named_mod" async def test_all_module_bases_is_deduped(self): """Re-creating the same module must not grow ``all_module_bases``.""" diff --git a/framework/db/tests/test_db_core_full_setup_join.py b/framework/db/tests/test_db_core_full_setup_join.py index 3b9c77051..4bdc8a5b6 100644 --- a/framework/db/tests/test_db_core_full_setup_join.py +++ b/framework/db/tests/test_db_core_full_setup_join.py @@ -29,13 +29,13 @@ _Base = create_module_base("cfull") -class Parent(_Base, MultiTenantMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class Parent(_Base, MultiTenantMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "cfull_parent" id: int | None = Field(default=None, primary_key=True) name: str = Field(default="", max_length=50) -class Child(_Base, MultiTenantMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class Child(_Base, MultiTenantMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "cfull_child" id: int | None = Field(default=None, primary_key=True) parent_id: int | None = Field(default=None, foreign_key="cfull_parent.id") diff --git a/framework/db/tests/test_db_logging.py b/framework/db/tests/test_db_logging.py index ad1a49167..7cd8e7c35 100644 --- a/framework/db/tests/test_db_logging.py +++ b/framework/db/tests/test_db_logging.py @@ -45,7 +45,7 @@ async def add_one(session): if r.name == "simple_module.db" and r.message == "db.session.commit" ] assert len(commits) == 1 - assert commits[0].operation == "commit" # type: ignore[attr-defined] + assert commits[0].operation == "commit" assert hasattr(commits[0], "db_duration_ms") async def test_commit_fires_even_after_explicit_flush(self, db_state, caplog): @@ -79,4 +79,4 @@ async def test_read_only_skips_commit(self, db_state, caplog): assert [r for r in records if r.message == "db.session.commit"] == [] read_only = [r for r in records if r.message == "db.session.read_only"] assert len(read_only) == 1 - assert read_only[0].operation == "read_only_rollback" # type: ignore[attr-defined] + assert read_only[0].operation == "read_only_rollback" diff --git a/framework/db/tests/test_mixins.py b/framework/db/tests/test_mixins.py index 773743e72..ae0ca8341 100644 --- a/framework/db/tests/test_mixins.py +++ b/framework/db/tests/test_mixins.py @@ -34,19 +34,19 @@ _MixinsBase = create_module_base("mixins_test") -class _AuditRow(_MixinsBase, AuditMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class _AuditRow(_MixinsBase, AuditMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "mixins_test_audit_row" id: int | None = Field(default=None, primary_key=True) name: str = Field(max_length=100) -class _SoftRow(_MixinsBase, SoftDeleteMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class _SoftRow(_MixinsBase, SoftDeleteMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "mixins_test_soft_row" id: int | None = Field(default=None, primary_key=True) name: str = Field(max_length=100) -class _VersionedRow(_MixinsBase, VersionedMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class _VersionedRow(_MixinsBase, VersionedMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "mixins_test_versioned_row" id: int | None = Field(default=None, primary_key=True) name: str = Field(max_length=100) @@ -59,7 +59,7 @@ class _AllRow( SoftDeleteMixin, VersionedMixin, table=True, -): # type: ignore[call-arg] +): """Composite: confirms every listener stays compatible when stacked.""" __tablename__ = "mixins_test_all_row" diff --git a/framework/db/tests/test_query_filter_outer_joins.py b/framework/db/tests/test_query_filter_outer_joins.py index 85e75c88a..244cb2332 100644 --- a/framework/db/tests/test_query_filter_outer_joins.py +++ b/framework/db/tests/test_query_filter_outer_joins.py @@ -30,26 +30,26 @@ _PlainBase = create_module_base("qfoj_plain") -class _Tag(_TagBase, MultiTenantMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class _Tag(_TagBase, MultiTenantMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "qfoj_tag_tag" id: int | None = Field(default=None, primary_key=True) name: str = Field(default="", max_length=100) -class _TagLink(_LinkBase, MultiTenantMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class _TagLink(_LinkBase, MultiTenantMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "qfoj_link_tag_link" id: int | None = Field(default=None, primary_key=True) tag_id: int | None = None # no FK: each model has its own MetaData article_id: int = 0 -class _SoftLink(_SoftBase, SoftDeleteMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class _SoftLink(_SoftBase, SoftDeleteMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "qfoj_soft_soft_link" id: int | None = Field(default=None, primary_key=True) tag_id: int | None = None # no FK: each model has its own MetaData -class _Plain(_PlainBase, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class _Plain(_PlainBase, table=True): # ty: ignore[unsupported-base] """Neither tenant-scoped nor soft-deletable: a FULL join's left side with no filter.""" __tablename__ = "qfoj_plain_plain" diff --git a/framework/db/tests/test_query_filter_rel_outer_joins.py b/framework/db/tests/test_query_filter_rel_outer_joins.py index f6bbdc4f3..3c842d575 100644 --- a/framework/db/tests/test_query_filter_rel_outer_joins.py +++ b/framework/db/tests/test_query_filter_rel_outer_joins.py @@ -37,7 +37,7 @@ ) -class _RjParent(_RjBase, MultiTenantMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class _RjParent(_RjBase, MultiTenantMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "qfrj_parent" id: int | None = Field(default=None, primary_key=True) name: str = Field(default="", max_length=100) @@ -47,7 +47,7 @@ class _RjParent(_RjBase, MultiTenantMixin, table=True): # type: ignore[call-arg ) -class _RjKid(_RjBase, MultiTenantMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class _RjKid(_RjBase, MultiTenantMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "qfrj_kid" id: int | None = Field(default=None, primary_key=True) parent_id: int | None = Field(default=None, foreign_key="qfrj_parent.id") diff --git a/framework/db/tests/test_query_filters.py b/framework/db/tests/test_query_filters.py index ef00eec87..55589490a 100644 --- a/framework/db/tests/test_query_filters.py +++ b/framework/db/tests/test_query_filters.py @@ -24,13 +24,13 @@ _FilterBase = create_module_base("qfilter_test") -class _Thing(_FilterBase, SoftDeleteMixin, MultiTenantMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class _Thing(_FilterBase, SoftDeleteMixin, MultiTenantMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "qfilter_test_thing" id: int | None = Field(default=None, primary_key=True) name: str = Field(default="", max_length=100) -class _Note(_FilterBase, SoftDeleteMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class _Note(_FilterBase, SoftDeleteMixin, table=True): # ty: ignore[unsupported-base] """Soft-delete only, and referenced by an outer join below.""" __tablename__ = "qfilter_test_note" diff --git a/framework/db/tests/test_writes.py b/framework/db/tests/test_writes.py index 8415e6031..9674bcd13 100644 --- a/framework/db/tests/test_writes.py +++ b/framework/db/tests/test_writes.py @@ -23,13 +23,13 @@ _WritesBase = create_module_base("writes_test") -class _Row(_WritesBase, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class _Row(_WritesBase, table=True): # ty: ignore[unsupported-base] __tablename__ = "writes_test_row" id: int | None = Field(default=None, primary_key=True) name: str = Field(default="", max_length=100) -class _SoftRow(_WritesBase, SoftDeleteMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class _SoftRow(_WritesBase, SoftDeleteMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "writes_test_soft_row" id: int | None = Field(default=None, primary_key=True) name: str = Field(default="", max_length=100) diff --git a/framework/hosting/simple_module_hosting/_openapi.py b/framework/hosting/simple_module_hosting/_openapi.py index 86ee67176..ccee795a4 100644 --- a/framework/hosting/simple_module_hosting/_openapi.py +++ b/framework/hosting/simple_module_hosting/_openapi.py @@ -95,4 +95,4 @@ def openapi() -> dict: assert app.openapi_schema is not None return app.openapi_schema - app.openapi = openapi # type: ignore[method-assign] + app.openapi = openapi diff --git a/framework/hosting/simple_module_hosting/_route_guard.py b/framework/hosting/simple_module_hosting/_route_guard.py index 07023a16e..70de74828 100644 --- a/framework/hosting/simple_module_hosting/_route_guard.py +++ b/framework/hosting/simple_module_hosting/_route_guard.py @@ -36,7 +36,7 @@ try: # private, and absent before FastAPI 0.140 from fastapi.routing import _IncludedRouter except ImportError: # pragma: no cover - older FastAPI flattens eagerly - _IncludedRouter = None # type: ignore[assignment,misc] + _IncludedRouter = None __all__ = ["guard_included_routers"] @@ -97,4 +97,4 @@ def guard_included_routers(app: FastAPI) -> None: if isinstance(route, _IncludedRouter) and not isinstance(route.matches, _PrefixGuard): guard = _PrefixGuard(route) guard._current_prefix() - route.matches = guard # type: ignore[method-assign] + route.matches = guard diff --git a/framework/hosting/simple_module_hosting/_tenant.py b/framework/hosting/simple_module_hosting/_tenant.py index 2844e64ff..5a77445a1 100644 --- a/framework/hosting/simple_module_hosting/_tenant.py +++ b/framework/hosting/simple_module_hosting/_tenant.py @@ -60,7 +60,7 @@ def _normalise(result: object) -> TenantResolution: if isinstance(result, tuple): tenant_id, source = result[0], (result[1] if len(result) > 1 else None) return TenantResolution(tenant_id, source if tenant_id is not None else None) - return TenantResolution(result, "resolver" if result is not None else None) # type: ignore[arg-type] + return TenantResolution(result, "resolver" if result is not None else None) def _tokens(value: str) -> list[str]: diff --git a/framework/hosting/simple_module_hosting/logging.py b/framework/hosting/simple_module_hosting/logging.py index a6d3163df..fedd8ba6f 100644 --- a/framework/hosting/simple_module_hosting/logging.py +++ b/framework/hosting/simple_module_hosting/logging.py @@ -14,7 +14,7 @@ class _CorrelationIdFilter(logging.Filter): """Inject the current correlation ID into every log record.""" def filter(self, record: logging.LogRecord) -> bool: - record.correlation_id = correlation_id.get("") # type: ignore[attr-defined] + record.correlation_id = correlation_id.get("") return True diff --git a/framework/hosting/tests/test_hosting_permissions.py b/framework/hosting/tests/test_hosting_permissions.py index 861d9abec..a3667cddd 100644 --- a/framework/hosting/tests/test_hosting_permissions.py +++ b/framework/hosting/tests/test_hosting_permissions.py @@ -194,7 +194,7 @@ async def _set_user(scope, receive, send): ) await app(scope, receive, send) - transport = ASGITransport(app=_set_user) # type: ignore[arg-type] + transport = ASGITransport(app=_set_user) return AsyncClient(transport=transport, base_url="http://testserver") async def test_role_with_permission_gets_200(self): @@ -224,7 +224,7 @@ async def test_unauthenticated_gets_401(self): app = self._build_app(reg, "products.edit") # No user in state — use the raw app transport - transport = ASGITransport(app=app) # type: ignore[arg-type] + transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://testserver") as client: resp = await client.get("/protected") assert resp.status_code == 401 diff --git a/framework/hosting/tests/test_i18n_routes.py b/framework/hosting/tests/test_i18n_routes.py index 9dd5ef2fd..6091ac6a6 100644 --- a/framework/hosting/tests/test_i18n_routes.py +++ b/framework/hosting/tests/test_i18n_routes.py @@ -88,7 +88,7 @@ def test_accepts_same_origin_absolute_referer() -> None: assert resp.headers["location"] == "/products?q=pen" -async def test_create_app_serves_set_locale_to_anonymous_client(client) -> None: # type: ignore[no-untyped-def] +async def test_create_app_serves_set_locale_to_anonymous_client(client) -> None: """Every host built with ``create_app`` gets the switcher route (GH #462).""" resp = await client.post( "/i18n/set-locale", diff --git a/framework/hosting/tests/test_invalidation_wiring.py b/framework/hosting/tests/test_invalidation_wiring.py index 0c2297d22..33f1cd3ea 100644 --- a/framework/hosting/tests/test_invalidation_wiring.py +++ b/framework/hosting/tests/test_invalidation_wiring.py @@ -45,19 +45,19 @@ def test_the_default_bus_works_rather_than_being_a_placeholder(self): def _services() -> Services: """A ``Services`` with everything but ``invalidation`` left to its default.""" return Services( - settings=None, # type: ignore[arg-type] - db=None, # type: ignore[arg-type] - event_bus=None, # type: ignore[arg-type] - menu_registry=None, # type: ignore[arg-type] - permissions=None, # type: ignore[arg-type] - feature_flags=None, # type: ignore[arg-type] - health_registry=None, # type: ignore[arg-type] - public_routes=None, # type: ignore[arg-type] - setup_registry=None, # type: ignore[arg-type] - design_packs=None, # type: ignore[arg-type] - audit_links=None, # type: ignore[arg-type] - i18n_registry=None, # type: ignore[arg-type] - inertia_config=None, # type: ignore[arg-type] + settings=None, + db=None, + event_bus=None, + menu_registry=None, + permissions=None, + feature_flags=None, + health_registry=None, + public_routes=None, + setup_registry=None, + design_packs=None, + audit_links=None, + i18n_registry=None, + inertia_config=None, modules=(), ) diff --git a/framework/hosting/tests/test_lifespan_order.py b/framework/hosting/tests/test_lifespan_order.py index 0931e7230..807ee55f1 100644 --- a/framework/hosting/tests/test_lifespan_order.py +++ b/framework/hosting/tests/test_lifespan_order.py @@ -30,10 +30,10 @@ class _TrackingModule(ModuleBase): the dependency graph; the hook bodies stay on the base. """ - async def on_startup(self, app: FastAPI) -> None: # type: ignore[override] + async def on_startup(self, app: FastAPI) -> None: _calls.append(f"start:{self.meta.name}") - async def on_shutdown(self, app: FastAPI) -> None: # type: ignore[override] + async def on_shutdown(self, app: FastAPI) -> None: _calls.append(f"stop:{self.meta.name}") diff --git a/framework/hosting/tests/test_logging.py b/framework/hosting/tests/test_logging.py index 3046fe2ed..9f42b29a7 100644 --- a/framework/hosting/tests/test_logging.py +++ b/framework/hosting/tests/test_logging.py @@ -36,7 +36,7 @@ def test_basic_output_is_valid_json(self): args=None, exc_info=None, ) - record.correlation_id = "" # type: ignore[attr-defined] + record.correlation_id = "" output = formatter.format(record) parsed = json.loads(output) @@ -56,11 +56,11 @@ def test_includes_extra_fields(self): args=None, exc_info=None, ) - record.correlation_id = "abc-123" # type: ignore[attr-defined] - record.method = "GET" # type: ignore[attr-defined] - record.path = "/api/test" # type: ignore[attr-defined] - record.status_code = 200 # type: ignore[attr-defined] - record.duration_ms = 12.5 # type: ignore[attr-defined] + record.correlation_id = "abc-123" + record.method = "GET" + record.path = "/api/test" + record.status_code = 200 + record.duration_ms = 12.5 parsed = json.loads(formatter.format(record)) assert parsed["correlation_id"] == "abc-123" @@ -80,7 +80,7 @@ def test_omits_absent_extra_fields(self): args=None, exc_info=None, ) - record.correlation_id = "" # type: ignore[attr-defined] + record.correlation_id = "" parsed = json.loads(formatter.format(record)) assert "method" not in parsed assert "status_code" not in parsed @@ -103,7 +103,7 @@ def test_includes_exception(self): args=None, exc_info=exc_info, ) - record.correlation_id = "" # type: ignore[attr-defined] + record.correlation_id = "" parsed = json.loads(formatter.format(record)) assert "exception" in parsed assert "ValueError: boom" in parsed["exception"] diff --git a/framework/hosting/tests/test_route_guard.py b/framework/hosting/tests/test_route_guard.py index 06587e54e..62a1c32d1 100644 --- a/framework/hosting/tests/test_route_guard.py +++ b/framework/hosting/tests/test_route_guard.py @@ -118,7 +118,7 @@ def spy(scope): descended += 1 return alpha_matches(scope) - alpha.matches = spy # type: ignore[method-assign] + alpha.matches = spy assert (await _get(app, "/api/beta/ping")).status_code == 200 assert descended == 0, "the guard let a request for /api/beta into /api/alpha's routes" diff --git a/framework/testing/simple_module_test/fake_events.py b/framework/testing/simple_module_test/fake_events.py index 91de8fab2..5684b6346 100644 --- a/framework/testing/simple_module_test/fake_events.py +++ b/framework/testing/simple_module_test/fake_events.py @@ -32,7 +32,7 @@ def __init__(self) -> None: super().__init__() self.events: list[RecordedEvent] = [] - async def publish(self, event: Event) -> None: # type: ignore[override] + async def publish(self, event: Event) -> None: self.events.append(RecordedEvent(event=event)) return await super().publish(event) diff --git a/framework/testing/simple_module_test/plugin.py b/framework/testing/simple_module_test/plugin.py index 820ea24a3..16341b0c6 100644 --- a/framework/testing/simple_module_test/plugin.py +++ b/framework/testing/simple_module_test/plugin.py @@ -132,7 +132,7 @@ def _init(self, password_hash: PasswordHash | None = None) -> None: ) original_init(self, password_hash) - PasswordHelper.__init__ = _init # type: ignore[method-assign] + PasswordHelper.__init__ = _init _cheap_password_hashing() diff --git a/modules/audit_log/tests/test_entity_label_batching.py b/modules/audit_log/tests/test_entity_label_batching.py index 205dcb7e2..54b871243 100644 --- a/modules/audit_log/tests/test_entity_label_batching.py +++ b/modules/audit_log/tests/test_entity_label_batching.py @@ -95,11 +95,11 @@ async def db_boom(_db, _ids): registry.register( AuditLink(entity_type="Setting", url_template="/s/{id}", label_resolver=db_boom) ) - db_session.rollback = spy # type: ignore[method-assign] + db_session.rollback = spy try: labels = await resolve_entity_labels(db_session, registry, [("Setting", "1")], UNGATED) finally: - db_session.rollback = original # type: ignore[method-assign] + db_session.rollback = original assert labels == {} assert rollbacks == [True] diff --git a/modules/background_tasks/tests/test_bg_invalidation_redis.py b/modules/background_tasks/tests/test_bg_invalidation_redis.py index ee69b7513..67afccb18 100644 --- a/modules/background_tasks/tests/test_bg_invalidation_redis.py +++ b/modules/background_tasks/tests/test_bg_invalidation_redis.py @@ -109,7 +109,7 @@ async def test_redis_delivers_data_as_bytes_the_bus_can_decode(self, redis_serve async def capture(message): raw.append(message) - bus.deliver = capture # type: ignore[method-assign] + bus.deliver = capture listener = await _subscribed(bus, redis_server) import redis.asyncio as aioredis diff --git a/modules/background_tasks/tests/test_log_context.py b/modules/background_tasks/tests/test_log_context.py index c77ecdf4c..f406a7a22 100644 --- a/modules/background_tasks/tests/test_log_context.py +++ b/modules/background_tasks/tests/test_log_context.py @@ -79,9 +79,9 @@ def test_injects_bound_keys_onto_record(self) -> None: with bind_task_context(task_id="t-1", task_name="reports.ingest", job_id=99): log_filter.filter(record) - assert record.task_id == "t-1" # type: ignore[attr-defined] - assert record.task_name == "reports.ingest" # type: ignore[attr-defined] - assert record.job_id == 99 # type: ignore[attr-defined] + assert record.task_id == "t-1" + assert record.task_name == "reports.ingest" + assert record.job_id == 99 def test_no_op_when_unbound(self) -> None: log_filter = LogContextFilter() @@ -93,12 +93,12 @@ def test_no_op_when_unbound(self) -> None: def test_does_not_overwrite_explicit_extra(self) -> None: log_filter = LogContextFilter() record = _make_record() - record.job_id = "from-extra" # type: ignore[attr-defined] + record.job_id = "from-extra" with bind_task_context(job_id="from-context"): log_filter.filter(record) - assert record.job_id == "from-extra" # type: ignore[attr-defined] + assert record.job_id == "from-extra" class TestInstallLogFilter: diff --git a/modules/branding/tests/test_branding.py b/modules/branding/tests/test_branding.py index d27dceb6f..3454fded4 100644 --- a/modules/branding/tests/test_branding.py +++ b/modules/branding/tests/test_branding.py @@ -89,7 +89,7 @@ def test_branding_payload_set() -> None: async def test_provider_returns_empty_when_state_absent() -> None: request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace())) - assert await branding_shared_props(request) == {} # type: ignore[arg-type] + assert await branding_shared_props(request) == {} async def test_provider_emits_branding_block() -> None: @@ -100,7 +100,7 @@ async def test_provider_emits_branding_block() -> None: headers={"x-inertia": "true"}, url=SimpleNamespace(path="/"), ) - out = await branding_shared_props(request) # type: ignore[arg-type] + out = await branding_shared_props(request) assert out["branding"]["appName"] == "Acme" diff --git a/modules/branding/tests/test_ship_review.py b/modules/branding/tests/test_ship_review.py index c2aa8fa42..6d436503f 100644 --- a/modules/branding/tests/test_ship_review.py +++ b/modules/branding/tests/test_ship_review.py @@ -79,7 +79,7 @@ class state: # noqa: N801 other = _Other for target in (app, other): cache = target.state.branding.tenant_cache - cache.entries["acme"] = ({}, _system(), None) # type: ignore[assignment] + cache.entries["acme"] = ({}, _system(), None) tenant_branding.forget(app, "acme") assert "acme" not in app.state.branding.tenant_cache.entries assert "acme" in other.state.branding.tenant_cache.entries diff --git a/modules/file_storage/file_storage/backends/s3_multipart.py b/modules/file_storage/file_storage/backends/s3_multipart.py index 8679ef550..854104bfb 100644 --- a/modules/file_storage/file_storage/backends/s3_multipart.py +++ b/modules/file_storage/file_storage/backends/s3_multipart.py @@ -37,7 +37,7 @@ def _client(self): # provided by the concrete backend def _presign_client(self): if not self.public_endpoint_url: return self._client() - return self._session.client( # type: ignore[attr-defined] + return self._session.client( "s3", **{**self.client_kwargs, "endpoint_url": self.public_endpoint_url} ) diff --git a/modules/file_storage/tests/test_bulk_delete.py b/modules/file_storage/tests/test_bulk_delete.py index a43ca481d..c6ce663bb 100644 --- a/modules/file_storage/tests/test_bulk_delete.py +++ b/modules/file_storage/tests/test_bulk_delete.py @@ -33,7 +33,7 @@ def _upload_file(name: str) -> UploadFile: return UploadFile( filename=name, file=BytesIO(b"payload"), - headers={"content-type": "text/plain"}, # type: ignore[arg-type] + headers={"content-type": "text/plain"}, ) diff --git a/modules/file_storage/tests/test_file_storage_delete_variants.py b/modules/file_storage/tests/test_file_storage_delete_variants.py index 062f4a810..26bc24fbe 100644 --- a/modules/file_storage/tests/test_file_storage_delete_variants.py +++ b/modules/file_storage/tests/test_file_storage_delete_variants.py @@ -30,7 +30,7 @@ async def _service_with_variant(tmp_path, db_session): upload = UploadFile( filename="p.png", file=BytesIO(b"x"), - headers={"content-type": "image/png"}, # type: ignore[arg-type] + headers={"content-type": "image/png"}, ) out = await svc.upload(upload) variant = thumbnails.variant_key(out.key, 128) diff --git a/modules/file_storage/tests/test_file_storage_filters.py b/modules/file_storage/tests/test_file_storage_filters.py index 61036af03..03b0b1fd8 100644 --- a/modules/file_storage/tests/test_file_storage_filters.py +++ b/modules/file_storage/tests/test_file_storage_filters.py @@ -22,7 +22,7 @@ def _upload(name: str, content_type: str) -> UploadFile: return UploadFile( filename=name, file=BytesIO(b"payload"), - headers={"content-type": content_type}, # type: ignore[arg-type] + headers={"content-type": content_type}, ) diff --git a/modules/file_storage/tests/test_service.py b/modules/file_storage/tests/test_service.py index da506e093..51553ff4a 100644 --- a/modules/file_storage/tests/test_service.py +++ b/modules/file_storage/tests/test_service.py @@ -28,7 +28,7 @@ def _upload(name: str, data: bytes, content_type: str = "application/octet-stream") -> UploadFile: - return UploadFile(filename=name, file=BytesIO(data), headers={"content-type": content_type}) # type: ignore[arg-type] + return UploadFile(filename=name, file=BytesIO(data), headers={"content-type": content_type}) def _settings(tmp_path, **overrides) -> FileStorageSettings: @@ -86,7 +86,7 @@ async def test_upload_compensates_on_db_failure(tmp_path, db_session: AsyncSessi deleted_keys: list[str] = [] class _RecordingBackend(FilesystemBackend): - async def delete(self, key: str) -> None: # type: ignore[override] + async def delete(self, key: str) -> None: deleted_keys.append(key) await super().delete(key) @@ -188,7 +188,7 @@ async def presigned_get_url(self, key, ttl_seconds, **headers): self.headers = headers return f"https://signed/{key}" - pb: StorageBackend = _PresigningBackend() # type: ignore[assignment] + pb: StorageBackend = _PresigningBackend() svc.backend = pb download2 = await svc.download(out.id) assert isinstance(download2, RedirectDownload) diff --git a/modules/file_storage/tests/test_upload_failure_modes.py b/modules/file_storage/tests/test_upload_failure_modes.py index 06c92476b..d6cc9445f 100644 --- a/modules/file_storage/tests/test_upload_failure_modes.py +++ b/modules/file_storage/tests/test_upload_failure_modes.py @@ -26,7 +26,7 @@ def _upload(name: str, data: bytes, content_type: str = "application/octet-stream") -> UploadFile: - return UploadFile(filename=name, file=BytesIO(data), headers={"content-type": content_type}) # type: ignore[arg-type] + return UploadFile(filename=name, file=BytesIO(data), headers={"content-type": content_type}) def _settings(tmp_path, **overrides) -> FileStorageSettings: @@ -40,7 +40,7 @@ def _settings(tmp_path, **overrides) -> FileStorageSettings: class _BackendThatFailsOnPut(FilesystemBackend): """Stand-in for "disk full" / "S3 timeout" — ``put`` always raises.""" - async def put(self, key, stream, *, content_type, size): # type: ignore[override] + async def put(self, key, stream, *, content_type, size): # Drain the stream first to keep parity with what real backends do # before they discover they can't actually persist; this exercises # the size/hash counters in the service too. @@ -56,7 +56,7 @@ class _BackendDeleteAlsoFails(FilesystemBackend): *original* RuntimeError (the trigger) to escape, not OSError. """ - async def delete(self, key: str) -> None: # type: ignore[override] + async def delete(self, key: str) -> None: raise OSError("simulated delete failure during compensation") @@ -94,7 +94,7 @@ async def test_compensation_delete_failure_does_not_mask_original_error( async def boom_then_real(*args, **kwargs): if not getattr(boom_then_real, "fired", False): - boom_then_real.fired = True # type: ignore[attr-defined] + boom_then_real.fired = True raise RuntimeError("DB write failure") return await real_flush(*args, **kwargs) diff --git a/modules/keycloak/tests/test_keycloak_bearer_kind.py b/modules/keycloak/tests/test_keycloak_bearer_kind.py index f9bf2443f..735beeeaa 100644 --- a/modules/keycloak/tests/test_keycloak_bearer_kind.py +++ b/modules/keycloak/tests/test_keycloak_bearer_kind.py @@ -23,7 +23,7 @@ async def validate_jwt(self, token: str) -> dict: async def test_jwt_bearer_principal_is_bearer_kind_and_unscoped(monkeypatch, scheme): state = KeycloakState(settings=KeycloakSettings()) provider = KeycloakAuthProvider(state.settings, state=state) - provider.jwks_cache = _Jwks() # type: ignore[assignment] + provider.jwks_cache = _Jwks() async def upsert(request, claims) -> str: return "00000000-0000-0000-0000-0000000000aa" diff --git a/modules/settings/tests/test_module_settings.py b/modules/settings/tests/test_module_settings.py index 43b4bd101..2562090e5 100644 --- a/modules/settings/tests/test_module_settings.py +++ b/modules/settings/tests/test_module_settings.py @@ -40,20 +40,20 @@ def test_collect_exposes_type_requires_restart_group(): app.state.demo = _DemoServices(settings=_DemoCfg()) app.state.sm = Services( - settings=None, # type: ignore[arg-type] - db=None, # type: ignore[arg-type] - event_bus=None, # type: ignore[arg-type] - menu_registry=None, # type: ignore[arg-type] - permissions=None, # type: ignore[arg-type] - feature_flags=None, # type: ignore[arg-type] - health_registry=None, # type: ignore[arg-type] - public_routes=None, # type: ignore[arg-type] - setup_registry=None, # type: ignore[arg-type] - design_packs=None, # type: ignore[arg-type] - audit_links=None, # type: ignore[arg-type] - i18n_registry=None, # type: ignore[arg-type] - inertia_config=None, # type: ignore[arg-type] - modules=(_DemoModule(),), # type: ignore[arg-type] + settings=None, + db=None, + event_bus=None, + menu_registry=None, + permissions=None, + feature_flags=None, + health_registry=None, + public_routes=None, + setup_registry=None, + design_packs=None, + audit_links=None, + i18n_registry=None, + inertia_config=None, + modules=(_DemoModule(),), ) views = collect_module_settings(app) diff --git a/modules/users/tests/test_bootstrap.py b/modules/users/tests/test_bootstrap.py index 4238ee6c2..adfaf4483 100644 --- a/modules/users/tests/test_bootstrap.py +++ b/modules/users/tests/test_bootstrap.py @@ -187,7 +187,7 @@ async def test_bootstrap_from_env_noop_when_unset(users_app_empty) -> None: bootstrap_password="", ) # Patch fake_app.state onto a real app-like object - await bootstrap_admin_from_env(fake_app) # type: ignore[arg-type] + await bootstrap_admin_from_env(fake_app) # No users should have been created async with users_app_empty.state.sm.db.session_factory() as s: @@ -217,7 +217,7 @@ async def test_bootstrap_from_env_noop_when_table_nonempty(users_app_empty) -> N bootstrap_email="admin@test.example", bootstrap_password="AdminPass1!", ) - await bootstrap_admin_from_env(fake_app) # type: ignore[arg-type] + await bootstrap_admin_from_env(fake_app) # Only the original dummy user should exist async with users_app_empty.state.sm.db.session_factory() as s: @@ -236,7 +236,7 @@ async def test_bootstrap_from_env_creates_admin_when_empty_and_configured(users_ bootstrap_email="bootstrap@test.example", bootstrap_password="BootPass1!", ) - await bootstrap_admin_from_env(fake_app) # type: ignore[arg-type] + await bootstrap_admin_from_env(fake_app) async with users_app_empty.state.sm.db.session_factory() as s: user = ( diff --git a/modules/users/tests/test_invite_flow.py b/modules/users/tests/test_invite_flow.py index 4264b11f7..5e95b683e 100644 --- a/modules/users/tests/test_invite_flow.py +++ b/modules/users/tests/test_invite_flow.py @@ -35,7 +35,7 @@ async def test_full_invite_flow(self, admin_client, anon_client, caplog): assert len(invite_records) == 1, ( f"Expected 1 invite log record, got {len(invite_records)}: {caplog.records}" ) - link = invite_records[0].link # type: ignore[attr-defined] + link = invite_records[0].link # Link format: http://testserver/users/invite/accept?token= token = link.split("token=", 1)[1] assert len(token) > 20 @@ -76,7 +76,7 @@ async def test_accept_invite_weak_password_fails(self, admin_client, anon_client ) invite_records = [r for r in caplog.records if r.getMessage() == "users.invite.email"] - token = invite_records[0].link.split("token=", 1)[1] # type: ignore[attr-defined] + token = invite_records[0].link.split("token=", 1)[1] resp = await anon_client.post( "/api/users/auth/accept-invite", diff --git a/modules/users/tests/test_invite_reuse.py b/modules/users/tests/test_invite_reuse.py index df4eed588..a4e2eed8a 100644 --- a/modules/users/tests/test_invite_reuse.py +++ b/modules/users/tests/test_invite_reuse.py @@ -30,7 +30,7 @@ async def _send_invite_and_grab_token(admin_client, anon_client, caplog, email: assert resp.status_code == 201, resp.text records = [r for r in caplog.records if r.getMessage() == "users.invite.email"] assert records, "ConsoleMailer didn't log an invite.email record" - link = records[-1].link # type: ignore[attr-defined] + link = records[-1].link return link.split("token=", 1)[1] diff --git a/modules/users/tests/test_mailer.py b/modules/users/tests/test_mailer.py index a818fa600..aaa97c1d3 100644 --- a/modules/users/tests/test_mailer.py +++ b/modules/users/tests/test_mailer.py @@ -21,9 +21,9 @@ async def test_send_verification_logs_link(url, caplog): assert len(caplog.records) == 1 record = caplog.records[0] assert record.getMessage() == "users.verify.email" - assert record.to == "user@example.com" # type: ignore[attr-defined] + assert record.to == "user@example.com" expected_link = f"{url.rstrip('/')}/users/verify?token={token}" - assert record.link == expected_link # type: ignore[attr-defined] + assert record.link == expected_link @pytest.mark.parametrize("url", ["http://localhost:8000", "https://app.example.com"]) @@ -40,9 +40,9 @@ async def test_send_password_reset_logs_link(url, caplog): assert len(caplog.records) == 1 record = caplog.records[0] assert record.getMessage() == "users.reset.email" - assert record.to == "user@example.com" # type: ignore[attr-defined] + assert record.to == "user@example.com" expected_link = f"{url.rstrip('/')}/users/reset-password?token={token}" - assert record.link == expected_link # type: ignore[attr-defined] + assert record.link == expected_link @pytest.mark.parametrize("url", ["http://localhost:8000", "https://app.example.com"]) @@ -59,10 +59,10 @@ async def test_send_invite_logs_link(url, caplog): assert len(caplog.records) == 1 record = caplog.records[0] assert record.getMessage() == "users.invite.email" - assert record.to == "newuser@example.com" # type: ignore[attr-defined] + assert record.to == "newuser@example.com" expected_link = f"{url.rstrip('/')}/users/invite/accept?token={token}" - assert record.link == expected_link # type: ignore[attr-defined] - assert record.invited_by == "Alice Admin" # type: ignore[attr-defined] + assert record.link == expected_link + assert record.invited_by == "Alice Admin" @pytest.mark.anyio @@ -75,7 +75,7 @@ async def test_base_url_trailing_slash_stripped(caplog): with caplog.at_level(logging.INFO, logger="users.mailer"): await mailer.send_verification("a@b.com", "tok") - link = caplog.records[0].link # type: ignore[attr-defined] + link = caplog.records[0].link assert not link.startswith("http://localhost:8000//") assert link == "http://localhost:8000/users/verify?token=tok" @@ -92,8 +92,8 @@ async def test_console_logs_configured_app_name(caplog): await branded.send_invite("a@b.com", "tok", "Alice") await default.send_verification("a@b.com", "tok") - assert caplog.records[0].app_name == "Acme" # type: ignore[attr-defined] - assert caplog.records[1].app_name == "SimpleModule" # type: ignore[attr-defined] + assert caplog.records[0].app_name == "Acme" + assert caplog.records[1].app_name == "SimpleModule" @pytest.mark.anyio diff --git a/modules/users/tests/test_session_revocation.py b/modules/users/tests/test_session_revocation.py index c90d94cf3..abf4fe652 100644 --- a/modules/users/tests/test_session_revocation.py +++ b/modules/users/tests/test_session_revocation.py @@ -158,7 +158,7 @@ def test_the_users_module_subscribes_the_channel(self): from users.module import UsersModule bus = InvalidationBus() - UsersModule().register_invalidations(bus, app=None) # type: ignore[arg-type] + UsersModule().register_invalidations(bus, app=None) assert CHANNEL in bus.channels diff --git a/modules/users/users/admin/queries.py b/modules/users/users/admin/queries.py index ca19fe66f..02a070059 100644 --- a/modules/users/users/admin/queries.py +++ b/modules/users/users/admin/queries.py @@ -191,14 +191,10 @@ async def list_users( if sort == "last_login_at": order_clause = ( - sort_col.desc().nulls_last() # type: ignore[union-attr] - if order == "desc" - else sort_col.asc().nulls_last() # type: ignore[union-attr] + sort_col.desc().nulls_last() if order == "desc" else sort_col.asc().nulls_last() ) else: - order_clause = ( - sort_col.desc() if order == "desc" else sort_col.asc() # type: ignore[union-attr] - ) + order_clause = sort_col.desc() if order == "desc" else sort_col.asc() stmt = stmt.order_by(order_clause).offset((page - 1) * per_page).limit(per_page) rows = (await self._db.execute(stmt)).all() diff --git a/modules/users/users/auth_local/token_api.py b/modules/users/users/auth_local/token_api.py index 3bdbb1ba7..4b715f124 100644 --- a/modules/users/users/auth_local/token_api.py +++ b/modules/users/users/auth_local/token_api.py @@ -129,7 +129,7 @@ async def token_refresh( now = datetime.now(UTC) stmt = select(RefreshToken).where( RefreshToken.token == token_uuid, - RefreshToken.revoked_at.is_(None), # type: ignore[union-attr] + RefreshToken.revoked_at.is_(None), RefreshToken.expires_at > now, ) rt = (await db.execute(stmt)).scalar_one_or_none() diff --git a/modules/users/users/backend.py b/modules/users/users/backend.py index 0c5bb2b34..d37400d16 100644 --- a/modules/users/users/backend.py +++ b/modules/users/users/backend.py @@ -60,7 +60,7 @@ def build_cookie_transport( cookie_max_age=cookie_max_age_seconds, cookie_secure=cookie_secure, cookie_httponly=True, - cookie_samesite=cookie_samesite, # type: ignore[arg-type] + cookie_samesite=cookie_samesite, ) @@ -126,4 +126,4 @@ def reconfigure_cookie_transport( transport.cookie_name = settings.cookie_name transport.cookie_max_age = settings.cookie_max_age_seconds transport.cookie_secure = settings.cookie_secure - transport.cookie_samesite = settings.cookie_samesite # type: ignore[assignment] + transport.cookie_samesite = settings.cookie_samesite diff --git a/pyproject.toml b/pyproject.toml index 2b45c7f67..d2cd43663 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,15 +109,21 @@ unresolved-attribute = "ignore" unsupported-operator = "ignore" unknown-argument = "ignore" no-matching-overload = "ignore" -# ``invalid-argument-type`` covers two distinct things: -# 1. Real bugs (e.g. passing ``float`` where ``Decimal`` is required) — useful. -# 2. SQLAlchemy ``where()`` / ``select()`` / ``selectinload()`` complaints when -# passed SQLModel field expressions — false positives. -# The false positives massively outnumber the real bugs and the latter still -# get caught at runtime by tests, so we ignore the rule globally. + +# ``invalid-argument-type`` and ``invalid-assignment`` are on for shipped code +# (GH #507): they catch real bugs such as an unvalidated ``object`` flowing +# into a ``str`` parameter. Tests are exempt below because they pass fakes +# (``SimpleNamespace`` requests, stub apps, echo translators) on purpose. +# A remaining false positive in shipped code gets a line-level +# ``# ty: ignore[]`` with the reason, which ty reports once unused. +# Never write ``# type: ignore[]``: ty matches the bracket against +# its own rule names, so a mypy code suppresses nothing. + +[[tool.ty.overrides]] +include = ["**/tests/**", "**/conftest.py", "tests/**", "scripts/bench_*.py"] + +[tool.ty.overrides.rules] invalid-argument-type = "ignore" -# SQLModel's ``model_config = ConfigDict(...)`` clashes with the internal -# ``SQLModelConfig`` type in newer ty versions. Same root cause as above. invalid-assignment = "ignore" [tool.pytest.ini_options] diff --git a/scripts/bench_core_scope.py b/scripts/bench_core_scope.py index 50e7fb08b..12d4daaf8 100644 --- a/scripts/bench_core_scope.py +++ b/scripts/bench_core_scope.py @@ -28,14 +28,14 @@ _Base = create_module_base("bench559") -class Author(_Base, MultiTenantMixin, SoftDeleteMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class Author(_Base, MultiTenantMixin, SoftDeleteMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "bench559_author" id: int | None = Field(default=None, primary_key=True) name: str = Field(default="", max_length=100) posts: list["Post"] = Relationship(back_populates="author") -class Post(_Base, MultiTenantMixin, SoftDeleteMixin, table=True): # type: ignore[call-arg] # ty: ignore[unsupported-base] +class Post(_Base, MultiTenantMixin, SoftDeleteMixin, table=True): # ty: ignore[unsupported-base] __tablename__ = "bench559_post" id: int | None = Field(default=None, primary_key=True) title: str = Field(default="", max_length=100) diff --git a/tests/integration/test_i18n_flow.py b/tests/integration/test_i18n_flow.py index 181f2a331..1661aa93b 100644 --- a/tests/integration/test_i18n_flow.py +++ b/tests/integration/test_i18n_flow.py @@ -31,7 +31,7 @@ def settings() -> Settings: @pytest.fixture -async def app_with_host_routes(app): # type: ignore[no-untyped-def] +async def app_with_host_routes(app): """``app`` fixture + host-level routers (landing). The stock ``app`` fixture builds via ``create_app()``, which only wires From 1d51b58bc360978860104ece9aeed7a0dda9498d Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:27:31 +0200 Subject: [PATCH 02/19] chore(ty): framework clean under invalid-argument-type/assignment; check resolver tenant ids (#507) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../cli/simple_module_cli/scaffolding.py | 3 +- .../simple_module_core/stored_settings.py | 2 +- framework/db/simple_module_db/_core_joins.py | 4 +- framework/db/simple_module_db/_core_walk.py | 4 +- framework/db/simple_module_db/migrations.py | 8 ++- framework/db/simple_module_db/transaction.py | 4 +- framework/db/simple_module_db/types.py | 2 +- .../simple_module_hosting/_inertia_setup.py | 2 +- .../hosting/simple_module_hosting/_openapi.py | 2 +- .../simple_module_hosting/_phase_helpers.py | 8 +-- .../simple_module_hosting/_route_guard.py | 2 +- .../_settings_registration.py | 6 +- .../hosting/simple_module_hosting/_tenant.py | 28 ++++++-- .../hosting/simple_module_hosting/manifest.py | 4 +- .../tests/test_tenant_resolver_types.py | 70 +++++++++++++++++++ 15 files changed, 126 insertions(+), 23 deletions(-) create mode 100644 framework/hosting/tests/test_tenant_resolver_types.py diff --git a/framework/cli/simple_module_cli/scaffolding.py b/framework/cli/simple_module_cli/scaffolding.py index 912cd2143..a507b6441 100644 --- a/framework/cli/simple_module_cli/scaffolding.py +++ b/framework/cli/simple_module_cli/scaffolding.py @@ -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. @@ -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 diff --git a/framework/core/simple_module_core/stored_settings.py b/framework/core/simple_module_core/stored_settings.py index c5ea52f2a..93a80bad2 100644 --- a/framework/core/simple_module_core/stored_settings.py +++ b/framework/core/simple_module_core/stored_settings.py @@ -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, ) diff --git a/framework/db/simple_module_db/_core_joins.py b/framework/db/simple_module_db/_core_joins.py index bca54d94c..ebad7caa1 100644 --- a/framework/db/simple_module_db/_core_joins.py +++ b/framework/db/simple_module_db/_core_joins.py @@ -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 @@ -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: diff --git a/framework/db/simple_module_db/_core_walk.py b/framework/db/simple_module_db/_core_walk.py index ce718ee90..3f8b6741a 100644 --- a/framework/db/simple_module_db/_core_walk.py +++ b/framework/db/simple_module_db/_core_walk.py @@ -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 @@ -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: diff --git a/framework/db/simple_module_db/migrations.py b/framework/db/simple_module_db/migrations.py index f594b92e5..bc1cc0464 100644 --- a/framework/db/simple_module_db/migrations.py +++ b/framework/db/simple_module_db/migrations.py @@ -46,7 +46,13 @@ # 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" + "schema", + "table", + "column", + "index", + "unique_constraint", + "foreign_key_constraint", + "check_constraint", ] IncludeObjectFn = Callable[[SchemaItem, str | None, _SchemaItemType, bool, SchemaItem | None], bool] ProcessRevisionDirectivesFn = Callable[[object, object, list], None] diff --git a/framework/db/simple_module_db/transaction.py b/framework/db/simple_module_db/transaction.py index 5e3d012a2..92a2c41c1 100644 --- a/framework/db/simple_module_db/transaction.py +++ b/framework/db/simple_module_db/transaction.py @@ -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 @@ -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 diff --git a/framework/db/simple_module_db/types.py b/framework/db/simple_module_db/types.py index 1ce559a8a..89d1db08f 100644 --- a/framework/db/simple_module_db/types.py +++ b/framework/db/simple_module_db/types.py @@ -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): diff --git a/framework/hosting/simple_module_hosting/_inertia_setup.py b/framework/hosting/simple_module_hosting/_inertia_setup.py index fac5bef55..c1213e836 100644 --- a/framework/hosting/simple_module_hosting/_inertia_setup.py +++ b/framework/hosting/simple_module_hosting/_inertia_setup.py @@ -128,7 +128,7 @@ def setup_inertia( templates = Jinja2Templates(directory=directories) # Expose branding metadata to the root template (pre-hydration head tags). - templates.env.globals["branding_head"] = branding_head + templates.env.globals["branding_head"] = branding_head # ty: ignore[invalid-assignment] # The adapter only switches to the asset manifest when environment equals # the literal "production"; normalise anything non-dev (staging, qa, ...) diff --git a/framework/hosting/simple_module_hosting/_openapi.py b/framework/hosting/simple_module_hosting/_openapi.py index ccee795a4..df1fdf94d 100644 --- a/framework/hosting/simple_module_hosting/_openapi.py +++ b/framework/hosting/simple_module_hosting/_openapi.py @@ -95,4 +95,4 @@ def openapi() -> dict: assert app.openapi_schema is not None return app.openapi_schema - app.openapi = openapi + app.openapi = openapi # ty: ignore[invalid-assignment] diff --git a/framework/hosting/simple_module_hosting/_phase_helpers.py b/framework/hosting/simple_module_hosting/_phase_helpers.py index d8e7a48c3..06371f87d 100644 --- a/framework/hosting/simple_module_hosting/_phase_helpers.py +++ b/framework/hosting/simple_module_hosting/_phase_helpers.py @@ -73,11 +73,11 @@ def register_exception_handlers(app: FastAPI, modules: list) -> None: """Install framework-level exception handlers, then per-module handlers.""" app.add_exception_handler( InertiaVersionConflictException, - inertia_version_conflict_exception_handler, + inertia_version_conflict_exception_handler, # ty: ignore[invalid-argument-type] ) - app.add_exception_handler(HTTPException, http_exception_handler) - app.add_exception_handler(NotFoundError, not_found_error_handler) - app.add_exception_handler(RequestValidationError, request_validation_error_handler) + app.add_exception_handler(HTTPException, http_exception_handler) # ty: ignore[invalid-argument-type] + app.add_exception_handler(NotFoundError, not_found_error_handler) # ty: ignore[invalid-argument-type] + app.add_exception_handler(RequestValidationError, request_validation_error_handler) # ty: ignore[invalid-argument-type] app.add_exception_handler(Exception, unhandled_exception_handler) for mod in modules: mod.register_exception_handlers(app) diff --git a/framework/hosting/simple_module_hosting/_route_guard.py b/framework/hosting/simple_module_hosting/_route_guard.py index 70de74828..58a74410a 100644 --- a/framework/hosting/simple_module_hosting/_route_guard.py +++ b/framework/hosting/simple_module_hosting/_route_guard.py @@ -97,4 +97,4 @@ def guard_included_routers(app: FastAPI) -> None: if isinstance(route, _IncludedRouter) and not isinstance(route.matches, _PrefixGuard): guard = _PrefixGuard(route) guard._current_prefix() - route.matches = guard + route.matches = guard # ty: ignore[invalid-assignment] diff --git a/framework/hosting/simple_module_hosting/_settings_registration.py b/framework/hosting/simple_module_hosting/_settings_registration.py index 57a3094b4..1392c4cae 100644 --- a/framework/hosting/simple_module_hosting/_settings_registration.py +++ b/framework/hosting/simple_module_hosting/_settings_registration.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import cast + from fastapi import FastAPI from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel @@ -32,7 +34,9 @@ def register_host_settings(app: FastAPI) -> None: "settings.registration" ).register_module_settings - register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s)) + register_module_settings( + app, "host", HostSettings, lambda s: _HostServices(settings=cast(HostSettings, s)) + ) def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]: diff --git a/framework/hosting/simple_module_hosting/_tenant.py b/framework/hosting/simple_module_hosting/_tenant.py index 5a77445a1..63f7d6f2a 100644 --- a/framework/hosting/simple_module_hosting/_tenant.py +++ b/framework/hosting/simple_module_hosting/_tenant.py @@ -5,6 +5,7 @@ from collections.abc import Awaitable, Callable, MutableMapping from dataclasses import dataclass from typing import Any +from uuid import UUID from simple_module_db import current_tenant_id, is_valid_tenant_id from starlette.datastructures import Headers, MutableHeaders @@ -50,17 +51,33 @@ class TenantResolution: report the source and the headers consulted.""" +def _checked(value: object, what: str) -> str | None: + """A resolver value as ``str | None``; a UUID becomes its string form. + + Anything else is a resolver bug (#507): an ``int`` or object tenant id + would reach string comparisons and tenant filters, so fail loudly here. + """ + if value is None or isinstance(value, str): + return value + if isinstance(value, UUID): + return str(value) + raise TypeError(f"tenant resolver returned a {type(value).__name__} {what}; expected str") + + def _normalise(result: object) -> TenantResolution: if isinstance(result, TenantResolution): + tenant_id = _checked(result.tenant_id, "tenant id") # No tenant, no source (#424) — whatever the resolver said. The vary # names stay: the header's absence was an input too. - if result.tenant_id is None and result.source is not None: + if tenant_id is None: return TenantResolution(None, None, result.vary) - return result + return TenantResolution(tenant_id, _checked(result.source, "source"), result.vary) if isinstance(result, tuple): - tenant_id, source = result[0], (result[1] if len(result) > 1 else None) + tenant_id = _checked(result[0] if result else None, "tenant id") + source = _checked(result[1], "source") if len(result) > 1 else None return TenantResolution(tenant_id, source if tenant_id is not None else None) - return TenantResolution(result, "resolver" if result is not None else None) + tenant_id = _checked(result, "tenant id") + return TenantResolution(tenant_id, "resolver" if tenant_id is not None else None) def _tokens(value: str) -> list[str]: @@ -161,7 +178,8 @@ async def wrapped(message: MutableMapping[str, Any]) -> None: if not any("*" in _tokens(line) for line in lines): merged = merge_vary(", ".join(lines), tuple(names)) del headers["vary"] - headers["vary"] = merged + if merged is not None: + headers["vary"] = merged await send(message) return wrapped diff --git a/framework/hosting/simple_module_hosting/manifest.py b/framework/hosting/simple_module_hosting/manifest.py index f77e92a5e..91a3623a6 100644 --- a/framework/hosting/simple_module_hosting/manifest.py +++ b/framework/hosting/simple_module_hosting/manifest.py @@ -164,7 +164,9 @@ def write_module_pages_manifest( # modules', so every host's resolver finds ``Setup/Wizard`` and its Vite # config serves it from the wheel — with no host-side file to add. framework = framework_assets() - pages_map = {**compute_module_pages(modules), framework.name: framework.pages_dir} + pages_map = dict(compute_module_pages(modules)) + if framework.pages_dir is not None: + pages_map[framework.name] = framework.pages_dir manifest_path = output_dir / "modules.manifest.json" manifest_payload = {name: path.as_posix() for name, path in pages_map.items()} diff --git a/framework/hosting/tests/test_tenant_resolver_types.py b/framework/hosting/tests/test_tenant_resolver_types.py new file mode 100644 index 000000000..bdfb5af2c --- /dev/null +++ b/framework/hosting/tests/test_tenant_resolver_types.py @@ -0,0 +1,70 @@ +"""A tenant resolver's answer is checked at the boundary (#507). + +Before, any object a resolver returned flowed into ``request.state.tenant_id`` +and on into string comparisons and tenant filters. A UUID is coerced to its +string form; anything else that isn't ``str | None`` is a resolver bug. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from uuid import UUID + +import pytest +from simple_module_hosting._tenant import _normalise +from simple_module_hosting.middleware import TenantMiddleware, TenantResolution + +_ID = UUID("12345678-1234-5678-1234-567812345678") + + +@pytest.mark.parametrize( + "result", + [_ID, (_ID, "header"), TenantResolution(_ID, "header")], + ids=["bare", "pair", "resolution"], +) +def test_uuid_becomes_its_string(result): + assert _normalise(result).tenant_id == str(_ID) + + +@pytest.mark.parametrize( + "result", + [42, (42, "header"), ("t1", 7), TenantResolution(42, "header"), object()], + ids=["int", "int-pair", "int-source", "int-resolution", "object"], +) +def test_other_types_are_refused(result): + with pytest.raises(TypeError, match="tenant resolver returned"): + _normalise(result) + + +def test_str_and_none_pass_through_unchanged(): + assert _normalise("t1") == TenantResolution("t1", "resolver") + assert _normalise(None) == TenantResolution(None, None) + assert _normalise(("t1", "session")) == TenantResolution("t1", "session") + assert _normalise(()) == TenantResolution(None, None) + vary = ("Host",) + assert _normalise(TenantResolution(None, "header", vary)) == TenantResolution(None, None, vary) + + +async def test_middleware_refuses_an_int_tenant(): + async def resolver(request): + return 42 + + async def inner(scope, receive, send): # pragma: no cover - never reached + raise AssertionError("request must not reach the app") + + async def receive(): # pragma: no cover + return {"type": "http.request", "body": b"", "more_body": False} + + async def send(message): # pragma: no cover + pass + + scope = { + "type": "http", + "method": "GET", + "path": "/", + "headers": [], + "state": {}, + "app": SimpleNamespace(state=SimpleNamespace(tenant_resolver=resolver)), + } + with pytest.raises(TypeError): + await TenantMiddleware(inner)(scope, receive, send) From 3767e117cb92c91208c46ed514541abf06ed6a89 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:31:54 +0200 Subject: [PATCH 03/19] docs: ty rule policy after #507 (CLAUDE.md, models docs, conventions skill, READMEs) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CLAUDE.md | 13 +++++++++---- docs/database/models.md | 5 ++++- modules/feature_flags/README.md | 2 +- modules/file_storage/README.md | 4 ++-- modules/permissions/README.md | 2 +- modules/users/README.md | 2 +- skills/simple-module-conventions/SKILL.md | 2 +- 7 files changed, 19 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a83630649..71db58fde 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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[]` on that + line. Never write `# type: ignore[]`: ty matches the bracket + against its own rule names, so it suppresses nothing and is never reported + unused. ## Adding a module or package diff --git a/docs/database/models.md b/docs/database/models.md index 954b21b9c..77676561d 100644 --- a/docs/database/models.md +++ b/docs/database/models.md @@ -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[]`. Apps scaffolded by `smpy new` ignore both rules globally, as before. + +Write suppressions as `# ty: ignore[]`. A mypy-style `# type: ignore[]` 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. diff --git a/modules/feature_flags/README.md b/modules/feature_flags/README.md index 26a13f61a..18bcb88a9 100644 --- a/modules/feature_flags/README.md +++ b/modules/feature_flags/README.md @@ -33,7 +33,7 @@ Gate a route using the registry dependency: ```python from fastapi import APIRouter, HTTPException -from feature_flags.deps import FeatureFlagRegistryDep # type: ignore[import-not-found] +from feature_flags.deps import FeatureFlagRegistryDep router = APIRouter() diff --git a/modules/file_storage/README.md b/modules/file_storage/README.md index 6b70103b1..b6cca0951 100644 --- a/modules/file_storage/README.md +++ b/modules/file_storage/README.md @@ -26,8 +26,8 @@ From another module, inject the service via its dependency: ```python from fastapi import Depends, File, UploadFile -from file_storage.deps import get_file_storage_service # type: ignore[import-not-found] -from file_storage.service import FileStorageService # type: ignore[import-not-found] +from file_storage.deps import get_file_storage_service +from file_storage.service import FileStorageService async def attach_receipt( diff --git a/modules/permissions/README.md b/modules/permissions/README.md index 9e59e0f7b..f13833790 100644 --- a/modules/permissions/README.md +++ b/modules/permissions/README.md @@ -37,7 +37,7 @@ Guard a route: ```python from fastapi import APIRouter, Depends -from permissions.deps import RequiresPermission # type: ignore[import-not-found] +from permissions.deps import RequiresPermission router = APIRouter() diff --git a/modules/users/README.md b/modules/users/README.md index fb6d7e3ba..5b9b7709a 100644 --- a/modules/users/README.md +++ b/modules/users/README.md @@ -39,7 +39,7 @@ SM_USERS_BOOTSTRAP_PASSWORD=change-me Program: ```python -from auth.deps import CurrentUser # type: ignore[import-not-found] +from auth.deps import CurrentUser @router.get("/profile") diff --git a/skills/simple-module-conventions/SKILL.md b/skills/simple-module-conventions/SKILL.md index 3ede1443a..137d48d2f 100644 --- a/skills/simple-module-conventions/SKILL.md +++ b/skills/simple-module-conventions/SKILL.md @@ -101,7 +101,7 @@ Locale-related diagnostics: SM013 (missing file), SM014 (missing keys vs default ### 9. Don't re-enable the silenced ty rules -Projects using the `ty` type checker should globally suppress `unresolved-attribute`, `unsupported-operator`, `unknown-argument`, `no-matching-overload`, `invalid-argument-type`, and `invalid-assignment` in `pyproject.toml`. SQLModel runtime-instruments fields as SQLAlchemy attributes, so the type checker can't see what's there (and `model_config = ConfigDict(...)` clashes with ty's internal `SQLModelConfig` type, which `invalid-assignment` covers). Real bugs surface in tests. +Projects using the `ty` type checker should globally suppress `unresolved-attribute`, `unsupported-operator`, `unknown-argument`, `no-matching-overload`, `invalid-argument-type`, and `invalid-assignment` in `pyproject.toml`. SQLModel runtime-instruments fields as SQLAlchemy attributes, so the type checker can't see what's there (and `model_config = ConfigDict(...)` clashes with ty's internal `SQLModelConfig` type, which `invalid-assignment` covers). Real bugs surface in tests. Line-level suppressions use ty's rule names, `# ty: ignore[]`; a mypy-style `# type: ignore[arg-type]` silences nothing under ty. ### 10. Declare external asset origins with `register_csp_sources` From 396c04ceb123da37cc7258dbb998a967bb6b212f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:28:11 +0200 Subject: [PATCH 04/19] chore(ty): settings and branding clean under invalid-argument-type/assignment (#507) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/branding/branding/tenant_branding.py | 2 +- modules/branding/branding/tenant_definitions.py | 5 +++-- modules/settings/settings/_listing.py | 2 +- modules/settings/settings/_module_settings.py | 2 +- modules/settings/settings/contracts/schemas.py | 2 +- modules/settings/settings/hydrate.py | 4 ++-- modules/settings/settings/registration.py | 6 +++--- modules/settings/settings/service.py | 6 +++--- .../tests/test_settings_group_metadata.py | 16 ++++++++++++++++ 9 files changed, 31 insertions(+), 14 deletions(-) create mode 100644 modules/settings/tests/test_settings_group_metadata.py diff --git a/modules/branding/branding/tenant_branding.py b/modules/branding/branding/tenant_branding.py index 818561d73..10ca8630c 100644 --- a/modules/branding/branding/tenant_branding.py +++ b/modules/branding/branding/tenant_branding.py @@ -162,7 +162,7 @@ def merge(system: BrandingSettings, tenant_id: str, overrides: dict[str, str]) - base: dict[str, Any] = system.model_dump() while good: try: - values = {**base, **good} + values: dict[str, Any] = {**base, **good} if "logo_file_id" in good and "logo_dark_file_id" not in good: values["logo_dark_file_id"] = "" merged = BrandingSettings(**values) diff --git a/modules/branding/branding/tenant_definitions.py b/modules/branding/branding/tenant_definitions.py index 2418fd802..a6009c209 100644 --- a/modules/branding/branding/tenant_definitions.py +++ b/modules/branding/branding/tenant_definitions.py @@ -26,7 +26,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from pydantic import ValidationError @@ -61,7 +61,8 @@ async def check(request: Request, tenant_id: str, value: str) -> None: if name in TENANT_IMAGE_FIELDS: raise ValueError(IMAGE_KEY_GENERIC_WRITE_ERROR.format(asset=_ASSET_OF[name])) try: - BrandingSettings(**{name: value}) + values: dict[str, Any] = {name: value} + BrandingSettings(**values) except ValidationError as exc: raise ValueError(exc.errors()[0]["msg"]) from exc if name == "design_pack" and value: diff --git a/modules/settings/settings/_listing.py b/modules/settings/settings/_listing.py index b9d15d800..b28bac760 100644 --- a/modules/settings/settings/_listing.py +++ b/modules/settings/settings/_listing.py @@ -116,6 +116,6 @@ async def list_by_scope_unmasked( def _scope_stmt(scope: SettingScope, scope_id: str): return ( select(Setting) - .where(Setting.scope == scope.value, Setting.scope_id == scope_id) + .where(Setting.scope == scope.value, Setting.scope_id == scope_id) # ty: ignore[invalid-argument-type] .order_by(Setting.key) ) diff --git a/modules/settings/settings/_module_settings.py b/modules/settings/settings/_module_settings.py index dc7e0e3c0..3f7125f56 100644 --- a/modules/settings/settings/_module_settings.py +++ b/modules/settings/settings/_module_settings.py @@ -177,7 +177,7 @@ def hidden(value: Any) -> bool: is_secret=secret, type=value_type, requires_restart=bool(extra.get("requires_restart", False)), - group=extra.get("group"), + group=group if isinstance(group := extra.get("group"), str) else None, env_set=env_set, db_override=name in overridden, env_readable=live_env_var is not None, diff --git a/modules/settings/settings/contracts/schemas.py b/modules/settings/settings/contracts/schemas.py index 307319c9f..7460d0743 100644 --- a/modules/settings/settings/contracts/schemas.py +++ b/modules/settings/settings/contracts/schemas.py @@ -92,7 +92,7 @@ def _validate_value_matches_type(value: str, value_type: SettingValueType) -> No class SettingOut(SQLModel): """A setting returned by the API.""" - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment] id: int scope: SettingScope diff --git a/modules/settings/settings/hydrate.py b/modules/settings/settings/hydrate.py index afd39acb6..937de96be 100644 --- a/modules/settings/settings/hydrate.py +++ b/modules/settings/settings/hydrate.py @@ -11,7 +11,7 @@ import logging import types -from typing import Union, get_args, get_origin +from typing import Any, Union, get_args, get_origin from pydantic import BaseModel from pydantic_settings import ( @@ -153,7 +153,7 @@ def build_settings[T: BaseSettings]( environment sets is dropped here rather than passed in. """ from_env = env_supplied_fields(cls) - parsed: dict[str, object] = {} + parsed: dict[str, Any] = {} for field_name, (raw, vtype) in raw_overrides.items(): if field_name not in cls.model_fields or field_name in from_env: continue diff --git a/modules/settings/settings/registration.py b/modules/settings/settings/registration.py index 3456ead10..cb37e186b 100644 --- a/modules/settings/settings/registration.py +++ b/modules/settings/settings/registration.py @@ -21,11 +21,11 @@ from settings.constants import MODULE_PACKAGE -def register_module_settings( +def register_module_settings[T: BaseSettings]( app: FastAPI, package: str, - settings_cls: type[BaseSettings], - services_factory: Callable[[BaseSettings], Any], + settings_cls: type[T], + services_factory: Callable[[T], Any], manage_url: str | None = None, ) -> None: """Register a module's BaseSettings class and mount its services on app.state. diff --git a/modules/settings/settings/service.py b/modules/settings/settings/service.py index 5364faf53..137d43738 100644 --- a/modules/settings/settings/service.py +++ b/modules/settings/settings/service.py @@ -208,9 +208,9 @@ async def _remove(self, entity: Setting, as_owner: bool) -> None: async def _find(self, scope: SettingScope, scope_id: str, key: str) -> Setting | None: stmt = select(Setting).where( - Setting.scope == scope.value, - Setting.scope_id == scope_id, - Setting.key == key, + Setting.scope == scope.value, # ty: ignore[invalid-argument-type] + Setting.scope_id == scope_id, # ty: ignore[invalid-argument-type] + Setting.key == key, # ty: ignore[invalid-argument-type] ) result = await self.db.execute(stmt) return result.scalar_one_or_none() diff --git a/modules/settings/tests/test_settings_group_metadata.py b/modules/settings/tests/test_settings_group_metadata.py new file mode 100644 index 000000000..faa826ee1 --- /dev/null +++ b/modules/settings/tests/test_settings_group_metadata.py @@ -0,0 +1,16 @@ +"""Group metadata must match the settings view's optional string contract.""" + +import pytest +from pydantic import Field +from pydantic_settings import BaseSettings +from settings._module_settings import _field_view + + +@pytest.mark.parametrize("group", [False, 42, [], {}, None, "SMTP"]) +def test_settings_group_metadata_accepts_only_strings(group): + class Config(BaseSettings): + host: str = Field(default="localhost", json_schema_extra={"group": group}) + + view = _field_view("host", Config(), "SM_DEMO_") + + assert view.group == (group if isinstance(group, str) else None) From 1b2009ff36f0d45e2cd2ed77054a46497e23f2ce Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:31:00 +0200 Subject: [PATCH 05/19] chore(ty): modules/users clean under invalid-argument-type/assignment (#507) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/users/users/admin/queries.py | 33 ++++++++++--------- modules/users/users/admin/service.py | 12 +++---- modules/users/users/auth_local/api.py | 4 +-- modules/users/users/auth_local/lockout.py | 4 +-- .../users/users/auth_local/self_account.py | 10 +++--- modules/users/users/auth_local/token_api.py | 8 ++--- modules/users/users/backend.py | 4 +-- modules/users/users/bootstrap.py | 11 ++++--- modules/users/users/contracts/schemas.py | 2 +- modules/users/users/demo.py | 6 ++-- modules/users/users/module.py | 10 ++++-- modules/users/users/provider.py | 4 +-- modules/users/users/registration.py | 2 +- modules/users/users/settings.py | 3 +- modules/users/users/setup_action.py | 2 +- modules/users/users/token_strategy.py | 10 +++--- 16 files changed, 67 insertions(+), 58 deletions(-) diff --git a/modules/users/users/admin/queries.py b/modules/users/users/admin/queries.py index 02a070059..79db143bc 100644 --- a/modules/users/users/admin/queries.py +++ b/modules/users/users/admin/queries.py @@ -4,6 +4,7 @@ import uuid from datetime import UTC, datetime, timedelta +from typing import Any, cast from simple_module_db import LIKE_ESCAPE_CHAR, like_contains_pattern from sqlalchemy import func, or_, select @@ -80,9 +81,9 @@ def to_list_item(self, user: User) -> UserListItem: async def list_roles(self) -> list[RoleListItem]: stmt = ( - select(Role, func.count(UserRole.user_id)) - .outerjoin(UserRole, UserRole.role_id == Role.id) - .group_by(Role.id) + select(Role, func.count(UserRole.user_id)) # ty: ignore[invalid-argument-type] + .outerjoin(UserRole, UserRole.role_id == Role.id) # ty: ignore[invalid-argument-type] + .group_by(Role.id) # ty: ignore[invalid-argument-type] .order_by(Role.name) ) result = await self._db.execute(stmt) @@ -98,7 +99,7 @@ async def list_roles(self) -> list[RoleListItem]: async def _get_user_with_roles(self, user_id: uuid.UUID) -> User | None: result = await self._db.execute( - select(User).where(User.id == user_id).options(selectinload(User.roles)) + select(User).where(User.id == user_id).options(selectinload(User.roles)) # ty: ignore[invalid-argument-type] ) return result.scalar_one_or_none() @@ -134,17 +135,17 @@ async def list_users( page = max(page, 1) per_page = max(1, min(per_page, 200)) stmt = select( - User.id, - User.email, - User.full_name, - User.is_active, - User.is_verified, - User.is_external, - User.disabled_at, - User.last_login_at, + User.id, # ty: ignore[invalid-argument-type] + User.email, # ty: ignore[invalid-argument-type] + User.full_name, # ty: ignore[invalid-argument-type] + User.is_active, # ty: ignore[invalid-argument-type] + User.is_verified, # ty: ignore[invalid-argument-type] + User.is_external, # ty: ignore[invalid-argument-type] + User.disabled_at, # ty: ignore[invalid-argument-type] + User.last_login_at, # ty: ignore[invalid-argument-type] User.created_at, - User.invited_at, - User.locked_until, + User.invited_at, # ty: ignore[invalid-argument-type] + User.locked_until, # ty: ignore[invalid-argument-type] ) count_stmt = select(func.count()).select_from(User) @@ -217,7 +218,9 @@ async def list_users( # roles): unpack directly. items = [ UserListItem( - **{**r._mapping, "locked_until": _live_lock(r.locked_until)}, + **cast( + "dict[str, Any]", {**r._mapping, "locked_until": _live_lock(r.locked_until)} + ), invite_expires_at=self._invite_expiry(r.invited_at), state=user_state(r.is_active, r.is_verified, r.invited_at), roles=roles_by_user.get(r.id, []), diff --git a/modules/users/users/admin/service.py b/modules/users/users/admin/service.py index 4ddc69110..991caf6a3 100644 --- a/modules/users/users/admin/service.py +++ b/modules/users/users/admin/service.py @@ -54,7 +54,7 @@ async def create_user( is_verified=True, is_superuser=False, ) - user = await self._manager.create(user_create, safe=False) + user = await self._manager.create(user_create, safe=False) # ty: ignore[invalid-argument-type] roles = await self._resolve_roles(role_names) for role in roles: @@ -122,14 +122,14 @@ async def delete_user(self, user_id: uuid.UUID) -> None: would be rolled back as a read-only request.""" result = await self._db.execute( select(User) - .where(User.id == user_id) - .options(noload(User.roles), noload(User.oauth_accounts)) + .where(User.id == user_id) # ty: ignore[invalid-argument-type] + .options(noload(User.roles), noload(User.oauth_accounts)) # ty: ignore[invalid-argument-type] ) user = result.scalar_one_or_none() if user is None: raise UserNotFoundError(user_id) for model in (UserRole, UserAccessToken, OAuthAccount, RefreshToken): - await self._db.execute(delete(model).where(model.user_id == user_id)) + await self._db.execute(delete(model).where(model.user_id == user_id)) # ty: ignore[invalid-argument-type] await self._db.delete(user) await self._db.flush() @@ -156,7 +156,7 @@ async def invite( is_active=True, is_verified=False, ) - user = await self._manager.create(user_create, safe=False) + user = await self._manager.create(user_create, safe=False) # ty: ignore[invalid-argument-type] user.invited_at = datetime.now(UTC) # Assign roles @@ -246,7 +246,7 @@ async def set_roles( user = await self._require_user(user_id) # Delete all existing role assignments for this user - await self._db.execute(delete(UserRole).where(UserRole.user_id == user_id)) + await self._db.execute(delete(UserRole).where(UserRole.user_id == user_id)) # ty: ignore[invalid-argument-type] # Insert new role assignments roles = await self._resolve_roles(role_names) diff --git a/modules/users/users/auth_local/api.py b/modules/users/users/auth_local/api.py index 90e687f54..4c4086871 100644 --- a/modules/users/users/auth_local/api.py +++ b/modules/users/users/auth_local/api.py @@ -206,7 +206,7 @@ async def accept_invite( changes["full_name"] = body.full_name.strip() try: await user_manager.update( - UserUpdate(**changes), + UserUpdate(**changes), # ty: ignore[invalid-argument-type] user, request=request, ) @@ -241,7 +241,7 @@ async def update_me( ): """Update the currently authenticated user's profile.""" return await user_manager.update( - UserUpdate(**data.model_dump(exclude_unset=True)), + UserUpdate(**data.model_dump(exclude_unset=True)), # ty: ignore[invalid-argument-type] user, request=request, ) diff --git a/modules/users/users/auth_local/lockout.py b/modules/users/users/auth_local/lockout.py index 37ce58506..60937be85 100644 --- a/modules/users/users/auth_local/lockout.py +++ b/modules/users/users/auth_local/lockout.py @@ -163,7 +163,7 @@ async def admit(session: AsyncSession, attempt: LoginAttempt) -> Admission: if not source.allowed: raise too_many_attempts(source.retry_after) stmt = select(User.locked_until).where(_by_username(attempt.username)) - rows = (await session.execute(stmt)).scalars().all() + rows = list((await session.execute(stmt)).scalars().all()) if rows: return await _admit_account(session, attempt, rows) return await _admit_unknown(attempt) @@ -282,7 +282,7 @@ async def release(session: AsyncSession, attempt: LoginAttempt, admission: Admis if admission.user_ids: await session.execute( update(User) - .where(User.id.in_(admission.user_ids), User.failed_login_count > 0) + .where(User.id.in_(admission.user_ids), User.failed_login_count > 0) # ty: ignore[invalid-argument-type] .values(failed_login_count=User.failed_login_count - 1, updated_at=User.updated_at) .execution_options(synchronize_session=False) ) diff --git a/modules/users/users/auth_local/self_account.py b/modules/users/users/auth_local/self_account.py index 03a845a24..f4481ca93 100644 --- a/modules/users/users/auth_local/self_account.py +++ b/modules/users/users/auth_local/self_account.py @@ -82,7 +82,7 @@ async def change_my_password( raise HTTPException(status_code=400, detail="Current password is incorrect.") try: - await user_manager.update(UserUpdate(password=body.new_password), user, request=request) + await user_manager.update(UserUpdate(password=body.new_password), user, request=request) # ty: ignore[invalid-argument-type] except fu_exceptions.InvalidPasswordException as exc: raise HTTPException(status_code=400, detail=exc.reason) from exc @@ -109,10 +109,10 @@ async def change_my_password( # what "this password is no longer the one that works" has to mean. await db.execute( update(RefreshToken) - .where(RefreshToken.user_id == user.id, RefreshToken.revoked_at.is_(None)) + .where(RefreshToken.user_id == user.id, RefreshToken.revoked_at.is_(None)) # ty: ignore[invalid-argument-type] .values(revoked_at=datetime.now(UTC)) ) - await db.execute(delete(UserAccessToken).where(UserAccessToken.user_id == user.id)) + await db.execute(delete(UserAccessToken).where(UserAccessToken.user_id == user.id)) # ty: ignore[invalid-argument-type] await db.flush() return Response(status_code=204) @@ -146,10 +146,10 @@ async def revoke_all_my_sessions( db.on_commit(lambda: publish_revocation(request.app, user.id)) await db.execute( update(RefreshToken) - .where(RefreshToken.user_id == user.id, RefreshToken.revoked_at.is_(None)) + .where(RefreshToken.user_id == user.id, RefreshToken.revoked_at.is_(None)) # ty: ignore[invalid-argument-type] .values(revoked_at=now) ) - await db.execute(delete(UserAccessToken).where(UserAccessToken.user_id == user.id)) + await db.execute(delete(UserAccessToken).where(UserAccessToken.user_id == user.id)) # ty: ignore[invalid-argument-type] await db.flush() request.session.clear() diff --git a/modules/users/users/auth_local/token_api.py b/modules/users/users/auth_local/token_api.py index 4b715f124..4b6386a73 100644 --- a/modules/users/users/auth_local/token_api.py +++ b/modules/users/users/auth_local/token_api.py @@ -128,9 +128,9 @@ async def token_refresh( now = datetime.now(UTC) stmt = select(RefreshToken).where( - RefreshToken.token == token_uuid, + RefreshToken.token == token_uuid, # ty: ignore[invalid-argument-type] RefreshToken.revoked_at.is_(None), - RefreshToken.expires_at > now, + RefreshToken.expires_at > now, # ty: ignore[invalid-argument-type] ) rt = (await db.execute(stmt)).scalar_one_or_none() if rt is None: @@ -144,7 +144,7 @@ async def token_refresh( # have been deactivated, or signed everything out. Minting from a stale # ``user_id`` alone would hand back a live credential to an account that no # longer has one. - user = (await db.execute(select(User).where(User.id == rt.user_id))).scalar_one_or_none() + user = (await db.execute(select(User).where(User.id == rt.user_id))).scalar_one_or_none() # ty: ignore[invalid-argument-type] if user is None or not user.is_active or user.disabled_at is not None: raise HTTPException(status_code=401, detail="Invalid or expired refresh token") @@ -163,7 +163,7 @@ async def token_revoke( except (ValueError, TypeError): raise HTTPException(status_code=400, detail="Invalid token format") from None - stmt = select(RefreshToken).where(RefreshToken.token == token_uuid) + stmt = select(RefreshToken).where(RefreshToken.token == token_uuid) # ty: ignore[invalid-argument-type] rt = (await db.execute(stmt)).scalar_one_or_none() if rt and rt.revoked_at is None: rt.revoked_at = datetime.now(UTC) diff --git a/modules/users/users/backend.py b/modules/users/users/backend.py index d37400d16..842f92a0c 100644 --- a/modules/users/users/backend.py +++ b/modules/users/users/backend.py @@ -13,7 +13,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from fastapi import Depends, Request from fastapi_users.authentication import AuthenticationBackend, CookieTransport @@ -53,7 +53,7 @@ def build_cookie_transport( cookie_name: str, cookie_max_age_seconds: int, cookie_secure: bool, - cookie_samesite: str, + cookie_samesite: Literal["lax", "strict", "none"], ) -> CookieTransport: return CookieTransport( cookie_name=cookie_name, diff --git a/modules/users/users/bootstrap.py b/modules/users/users/bootstrap.py index 6de342d13..9345d5933 100644 --- a/modules/users/users/bootstrap.py +++ b/modules/users/users/bootstrap.py @@ -74,11 +74,11 @@ async def create_admin( # Look up by name first (works on both Postgres and SQLite regardless of UUID # storage format), then fall back to id-based lookup in case name was changed. admin_role = ( - await db.execute(select(Role).where(Role.name == ADMIN_ROLE_NAME)) + await db.execute(select(Role).where(Role.name == ADMIN_ROLE_NAME)) # ty: ignore[invalid-argument-type] ).scalar_one_or_none() if admin_role is None: admin_role = ( - await db.execute(select(Role).where(Role.id == ADMIN_ROLE_ID)) + await db.execute(select(Role).where(Role.id == ADMIN_ROLE_ID)) # ty: ignore[invalid-argument-type] ).scalar_one_or_none() if admin_role is None: # The seed migration (e3ce9754e6dc) inserts this row, so in a real @@ -120,7 +120,8 @@ async def create_admin( existing_link = ( await db.execute( select(UserRole).where( - UserRole.user_id == existing.id, UserRole.role_id == admin_role.id + UserRole.user_id == existing.id, # ty: ignore[invalid-argument-type] + UserRole.role_id == admin_role.id, # ty: ignore[invalid-argument-type] ) ) ).scalar_one_or_none() @@ -142,11 +143,11 @@ async def ensure_user_role(db: AsyncSession) -> Role: not assume it exists. """ user_role = ( - await db.execute(select(Role).where(Role.name == USER_ROLE_NAME)) + await db.execute(select(Role).where(Role.name == USER_ROLE_NAME)) # ty: ignore[invalid-argument-type] ).scalar_one_or_none() if user_role is None: user_role = ( - await db.execute(select(Role).where(Role.id == USER_ROLE_ID)) + await db.execute(select(Role).where(Role.id == USER_ROLE_ID)) # ty: ignore[invalid-argument-type] ).scalar_one_or_none() if user_role is None: user_role = Role(id=USER_ROLE_ID, name=USER_ROLE_NAME, description=USER_ROLE_DESCRIPTION) diff --git a/modules/users/users/contracts/schemas.py b/modules/users/users/contracts/schemas.py index 9588a4435..7b28a47fd 100644 --- a/modules/users/users/contracts/schemas.py +++ b/modules/users/users/contracts/schemas.py @@ -38,7 +38,7 @@ class UserRead(CreateUpdateDictModel, SQLModel): - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment] id: uuid.UUID email: str diff --git a/modules/users/users/demo.py b/modules/users/users/demo.py index 86f58dcd1..fcb201937 100644 --- a/modules/users/users/demo.py +++ b/modules/users/users/demo.py @@ -127,11 +127,11 @@ async def _role_row(db: AsyncSession, name: str) -> Role: both, so this only fires for tests built with ``create_all`` and for databases downgraded past the seed revision. """ - role = (await db.execute(select(Role).where(Role.name == name))).scalar_one_or_none() + role = (await db.execute(select(Role).where(Role.name == name))).scalar_one_or_none() # ty: ignore[invalid-argument-type] if role is not None: return role role_id, description = _ROLE_SEEDS[name] - role = (await db.execute(select(Role).where(Role.id == role_id))).scalar_one_or_none() + role = (await db.execute(select(Role).where(Role.id == role_id))).scalar_one_or_none() # ty: ignore[invalid-argument-type] if role is None: role = Role(id=role_id, name=name, description=description) db.add(role) @@ -147,7 +147,7 @@ async def _sync_role(db: AsyncSession, user: User, role_name: str) -> None: sync that only ever added rows would leave the admin grant in place. """ wanted = await _role_row(db, role_name) - links = (await db.execute(select(UserRole).where(UserRole.user_id == user.id))).scalars().all() + links = (await db.execute(select(UserRole).where(UserRole.user_id == user.id))).scalars().all() # ty: ignore[invalid-argument-type] managed = {rid for rid, _ in _ROLE_SEEDS.values()} if not any(link.role_id == wanted.id for link in links): db.add(UserRole(user_id=user.id, role_id=wanted.id)) diff --git a/modules/users/users/module.py b/modules/users/users/module.py index 535a71337..72044aabc 100644 --- a/modules/users/users/module.py +++ b/modules/users/users/module.py @@ -68,7 +68,11 @@ def register_settings(self, app: FastAPI) -> None: "settings.registration" ).register_module_settings - register_module_settings(app, "users", UsersSettings, lambda s: UsersState(settings=s)) + def _build_state(s: object) -> UsersState: + assert isinstance(s, UsersSettings) + return UsersState(settings=s) + + register_module_settings(app, "users", UsersSettings, _build_state) from users.provider import UsersAuthProvider @@ -223,13 +227,13 @@ def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None dependencies=[Depends(auth_local_api.enforce_auth_throughput_limit)], ) api_router.include_router( - fastapi_users.get_verify_router(UserRead), + fastapi_users.get_verify_router(UserRead), # ty: ignore[invalid-argument-type] prefix="/auth", tags=["users-auth"], dependencies=[Depends(auth_local_api.enforce_auth_throughput_limit)], ) api_router.include_router( - fastapi_users.get_register_router(UserRead, UserCreate), + fastapi_users.get_register_router(UserRead, UserCreate), # ty: ignore[invalid-argument-type] prefix="/auth", tags=["users-auth"], dependencies=[ diff --git a/modules/users/users/provider.py b/modules/users/users/provider.py index e75a01f58..c89f45c88 100644 --- a/modules/users/users/provider.py +++ b/modules/users/users/provider.py @@ -269,8 +269,8 @@ async def _load_user(self, scope, user_id: uuid_mod.UUID, session=None) -> UserC # otherwise fire an extra query the UserContext never reads. stmt = ( select(User) - .where(User.id == user_id) - .options(selectinload(User.roles), noload(User.oauth_accounts)) + .where(User.id == user_id) # ty: ignore[invalid-argument-type] + .options(selectinload(User.roles), noload(User.oauth_accounts)) # ty: ignore[invalid-argument-type] ) user = (await db_session.execute(stmt)).scalar_one_or_none() if user is None or not user.is_active or user.disabled_at is not None: diff --git a/modules/users/users/registration.py b/modules/users/users/registration.py index 740bfc079..6800883b9 100644 --- a/modules/users/users/registration.py +++ b/modules/users/users/registration.py @@ -28,7 +28,7 @@ async def attach_default_role(session: AsyncSession, user: User) -> None: from racing to insert it. """ role = ( - await session.execute(select(Role).where(Role.name == USER_ROLE_NAME)) + await session.execute(select(Role).where(Role.name == USER_ROLE_NAME)) # ty: ignore[invalid-argument-type] ).scalar_one_or_none() if role is None: logger.warning("No '%s' role exists; %s registered with no role", USER_ROLE_NAME, user.id) diff --git a/modules/users/users/settings.py b/modules/users/users/settings.py index 2fb0773b7..8727bff72 100644 --- a/modules/users/users/settings.py +++ b/modules/users/users/settings.py @@ -12,6 +12,7 @@ import logging import os +from typing import Literal from pydantic import Field, field_validator, model_validator from pydantic_settings import SettingsConfigDict @@ -97,7 +98,7 @@ def _non_empty_redirect(cls, value: str) -> str: session_version_cache_ttl_seconds: int = SESSION_VERSION_TTL_SECONDS cookie_secure: bool = True # flipped False in dev by the module at startup - cookie_samesite: str = "lax" + cookie_samesite: Literal["lax", "strict", "none"] = "lax" # Mailer mailer: str = Field(default="console", pattern="^(console|smtp)$") diff --git a/modules/users/users/setup_action.py b/modules/users/users/setup_action.py index c2dedabde..fe120c31a 100644 --- a/modules/users/users/setup_action.py +++ b/modules/users/users/setup_action.py @@ -51,7 +51,7 @@ class AdministratorIn(SQLModel): async def _validate_password(password: str, email: str) -> None: try: - await UserManager.validate_password(UserManager, password, SimpleNamespace(email=email)) + await UserManager.validate_password(UserManager, password, SimpleNamespace(email=email)) # ty: ignore[invalid-argument-type] except fu_exceptions.InvalidPasswordException as exc: raise HTTPException(status_code=422, detail=exc.reason) from exc diff --git a/modules/users/users/token_strategy.py b/modules/users/users/token_strategy.py index 697debde7..da46d5122 100644 --- a/modules/users/users/token_strategy.py +++ b/modules/users/users/token_strategy.py @@ -135,9 +135,9 @@ async def resolve_bearer(scope, token: str) -> UserContext | None: now = datetime.now(UTC) cutoff = now - timedelta(seconds=_TOKEN_LIFETIME_SECONDS) stmt = select(UserAccessToken).where( - UserAccessToken.token == token, - UserAccessToken.created_at > cutoff, - UserAccessToken.expires_at > now, + UserAccessToken.token == token, # ty: ignore[invalid-argument-type] + UserAccessToken.created_at > cutoff, # ty: ignore[invalid-argument-type] + UserAccessToken.expires_at > now, # ty: ignore[invalid-argument-type] ) access = (await db_session.execute(stmt)).scalar_one_or_none() if access is None: @@ -146,8 +146,8 @@ async def resolve_bearer(scope, token: str) -> UserContext | None: # otherwise fire an extra query the UserContext never reads. stmt = ( select(User) - .where(User.id == access.user_id) - .options(selectinload(User.roles), noload(User.oauth_accounts)) + .where(User.id == access.user_id) # ty: ignore[invalid-argument-type] + .options(selectinload(User.roles), noload(User.oauth_accounts)) # ty: ignore[invalid-argument-type] ) user = (await db_session.execute(stmt)).scalar_one_or_none() if user is None or not user.is_active or user.disabled_at is not None: From 3df3bed0a5be2c2f7dda704452673ae77ec0384e Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:31:49 +0200 Subject: [PATCH 06/19] fix(users): accept cookie_samesite case-insensitively now that it is a Literal (#507) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/users/tests/test_settings.py | 12 ++++++++++++ modules/users/users/settings.py | 6 ++++++ 2 files changed, 18 insertions(+) diff --git a/modules/users/tests/test_settings.py b/modules/users/tests/test_settings.py index 311517104..40f1e211f 100644 --- a/modules/users/tests/test_settings.py +++ b/modules/users/tests/test_settings.py @@ -44,6 +44,18 @@ def test_cookie_samesite_default(self): s = UsersSettings() assert s.cookie_samesite == "lax" + def test_cookie_samesite_is_case_insensitive(self): + from users.settings import UsersSettings + + assert UsersSettings(cookie_samesite="Strict").cookie_samesite == "strict" + + def test_cookie_samesite_rejects_unknown_values(self): + from pydantic import ValidationError + from users.settings import UsersSettings + + with pytest.raises(ValidationError): + UsersSettings(cookie_samesite="sideways") + def test_bootstrap_email_default_empty(self): from users.settings import UsersSettings diff --git a/modules/users/users/settings.py b/modules/users/users/settings.py index 8727bff72..9d8bd571f 100644 --- a/modules/users/users/settings.py +++ b/modules/users/users/settings.py @@ -100,6 +100,12 @@ def _non_empty_redirect(cls, value: str) -> str: cookie_secure: bool = True # flipped False in dev by the module at startup cookie_samesite: Literal["lax", "strict", "none"] = "lax" + @field_validator("cookie_samesite", mode="before") + @classmethod + def _lowercase_samesite(cls, value: object) -> object: + """``"Strict"`` worked when this was a plain ``str`` (Starlette lowercases it).""" + return value.lower() if isinstance(value, str) else value + # Mailer mailer: str = Field(default="console", pattern="^(console|smtp)$") base_url: str = "http://localhost:8000" From b8a5105a6736aaba4f2f513966603349a355e00b Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:32:06 +0200 Subject: [PATCH 07/19] chore(ty): drop workarounds made redundant by generic register_module_settings (#507) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../hosting/simple_module_hosting/_settings_registration.py | 6 +----- modules/users/users/module.py | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/framework/hosting/simple_module_hosting/_settings_registration.py b/framework/hosting/simple_module_hosting/_settings_registration.py index 1392c4cae..57a3094b4 100644 --- a/framework/hosting/simple_module_hosting/_settings_registration.py +++ b/framework/hosting/simple_module_hosting/_settings_registration.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import cast - from fastapi import FastAPI from simple_module_core.diagnostics import Diagnostic, DiagnosticLevel @@ -34,9 +32,7 @@ def register_host_settings(app: FastAPI) -> None: "settings.registration" ).register_module_settings - register_module_settings( - app, "host", HostSettings, lambda s: _HostServices(settings=cast(HostSettings, s)) - ) + register_module_settings(app, "host", HostSettings, lambda s: _HostServices(settings=s)) def check_settings_registration(app: FastAPI, modules: list) -> list[Diagnostic]: diff --git a/modules/users/users/module.py b/modules/users/users/module.py index 72044aabc..8669935ae 100644 --- a/modules/users/users/module.py +++ b/modules/users/users/module.py @@ -68,11 +68,7 @@ def register_settings(self, app: FastAPI) -> None: "settings.registration" ).register_module_settings - def _build_state(s: object) -> UsersState: - assert isinstance(s, UsersSettings) - return UsersState(settings=s) - - register_module_settings(app, "users", UsersSettings, _build_state) + register_module_settings(app, "users", UsersSettings, lambda s: UsersState(settings=s)) from users.provider import UsersAuthProvider From ccf935e4ceea399b8f109dcecbf90022e22d2e38 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:32:23 +0200 Subject: [PATCH 08/19] chore: keep migrations.py under the 300-line cap Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- framework/db/simple_module_db/migrations.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/framework/db/simple_module_db/migrations.py b/framework/db/simple_module_db/migrations.py index bc1cc0464..ee790c234 100644 --- a/framework/db/simple_module_db/migrations.py +++ b/framework/db/simple_module_db/migrations.py @@ -45,15 +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", - "check_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] From 3c69f5ddd85547eb6db9533eb0cf9ed0df17525c Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:33:28 +0200 Subject: [PATCH 09/19] chore(ty): tenants/api_tokens/permissions/feature_flags/audit_log/site_lock/keycloak clean under invalid-argument-type/assignment (#507) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/api_tokens/api_tokens/contracts/schemas.py | 2 +- modules/api_tokens/api_tokens/module.py | 2 +- modules/api_tokens/api_tokens/resolver.py | 8 ++++---- modules/api_tokens/api_tokens/service.py | 10 +++++----- modules/audit_log/audit_log/contracts/schemas.py | 2 +- modules/audit_log/audit_log/service.py | 4 ++-- .../feature_flags/feature_flags/contracts/schemas.py | 2 +- modules/feature_flags/feature_flags/service.py | 6 +++--- modules/keycloak/keycloak/module.py | 2 +- modules/keycloak/keycloak/provider.py | 2 +- modules/permissions/permissions/contracts/schemas.py | 4 ++-- modules/permissions/permissions/service.py | 6 +++--- modules/site_lock/site_lock/module.py | 2 +- modules/tenants/tenants/_deletion.py | 2 +- modules/tenants/tenants/_owner_guard.py | 10 +++++----- modules/tenants/tenants/invitations.py | 10 +++++----- modules/tenants/tenants/module.py | 5 ++++- modules/tenants/tenants/service.py | 11 ++++++----- 18 files changed, 47 insertions(+), 43 deletions(-) diff --git a/modules/api_tokens/api_tokens/contracts/schemas.py b/modules/api_tokens/api_tokens/contracts/schemas.py index 2a8f762d2..3f72ce505 100644 --- a/modules/api_tokens/api_tokens/contracts/schemas.py +++ b/modules/api_tokens/api_tokens/contracts/schemas.py @@ -14,7 +14,7 @@ class TokenView(SQLModel): """A token as lists show it. Never carries the secret or its hash.""" - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment] id: uuid.UUID kind: str diff --git a/modules/api_tokens/api_tokens/module.py b/modules/api_tokens/api_tokens/module.py index 8bdb76c88..af7d5612d 100644 --- a/modules/api_tokens/api_tokens/module.py +++ b/modules/api_tokens/api_tokens/module.py @@ -43,7 +43,7 @@ def register_settings(self, app: FastAPI) -> None: app, c.MODULE_PACKAGE, ApiTokensSettings, - lambda s: ApiTokensServices(settings=s), + lambda s: ApiTokensServices(settings=s), # ty: ignore[invalid-argument-type] ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: diff --git a/modules/api_tokens/api_tokens/resolver.py b/modules/api_tokens/api_tokens/resolver.py index 7dec894e4..fbeb69fb5 100644 --- a/modules/api_tokens/api_tokens/resolver.py +++ b/modules/api_tokens/api_tokens/resolver.py @@ -59,8 +59,8 @@ async def _personal(db: Any, token: ApiToken) -> UserContext | None: return None stmt = ( select(User) - .where(User.id == token.owner_user_id) - .options(selectinload(User.roles), noload(User.oauth_accounts)) + .where(User.id == token.owner_user_id) # ty: ignore[invalid-argument-type] + .options(selectinload(User.roles), noload(User.oauth_accounts)) # ty: ignore[invalid-argument-type] ) user = (await db.execute(stmt)).scalar_one_or_none() if user is None or not user.is_active or user.disabled_at is not None: @@ -109,7 +109,7 @@ async def touch_last_used(app: Any, token: ApiToken, now: datetime) -> None: stmt = ( update(ApiToken) .where( - ApiToken.id == token.id, + ApiToken.id == token.id, # ty: ignore[invalid-argument-type] or_(ApiToken.last_used_at.is_(None), ApiToken.last_used_at <= cutoff), ) .values(last_used_at=now, updated_at=ApiToken.updated_at) @@ -134,7 +134,7 @@ async def resolve_api_token(request: Request) -> UserContext | None: async with app.state.sm.db.session_factory() as db: stmt = ( select(ApiToken) - .where(ApiToken.token_hash == hash_token(credential)) + .where(ApiToken.token_hash == hash_token(credential)) # ty: ignore[invalid-argument-type] .execution_options(**{ALL_TENANTS_OPTION: True}) ) token = (await db.execute(stmt)).scalar_one_or_none() diff --git a/modules/api_tokens/api_tokens/service.py b/modules/api_tokens/api_tokens/service.py index 7841fa2ba..45a087a1d 100644 --- a/modules/api_tokens/api_tokens/service.py +++ b/modules/api_tokens/api_tokens/service.py @@ -113,7 +113,7 @@ async def list_for_owner(self, owner_user_id: uuid.UUID) -> list[ApiToken]: """ stmt = ( select(ApiToken) - .where(ApiToken.owner_user_id == owner_user_id, ApiToken.kind == TokenKind.PERSONAL) + .where(ApiToken.owner_user_id == owner_user_id, ApiToken.kind == TokenKind.PERSONAL) # ty: ignore[invalid-argument-type] .order_by(ApiToken.created_at.desc()) .execution_options(**{ALL_TENANTS_OPTION: True}) ) @@ -128,16 +128,16 @@ async def get_owned(self, token_id: uuid.UUID, owner_user_id: uuid.UUID) -> ApiT stmt = ( select(ApiToken) .where( - ApiToken.id == token_id, - ApiToken.owner_user_id == owner_user_id, - ApiToken.kind == TokenKind.PERSONAL, + ApiToken.id == token_id, # ty: ignore[invalid-argument-type] + ApiToken.owner_user_id == owner_user_id, # ty: ignore[invalid-argument-type] + ApiToken.kind == TokenKind.PERSONAL, # ty: ignore[invalid-argument-type] ) .execution_options(**{ALL_TENANTS_OPTION: True}) ) return (await self.db.execute(stmt)).scalar_one_or_none() async def get_in_tenant(self, token_id: uuid.UUID) -> ApiToken | None: - stmt = select(ApiToken).where(ApiToken.id == token_id) + stmt = select(ApiToken).where(ApiToken.id == token_id) # ty: ignore[invalid-argument-type] return (await self.db.execute(stmt)).scalar_one_or_none() async def revoke(self, token: ApiToken, *, cross_tenant: bool = False) -> ApiToken: diff --git a/modules/audit_log/audit_log/contracts/schemas.py b/modules/audit_log/audit_log/contracts/schemas.py index 787f339b7..d22c0cd05 100644 --- a/modules/audit_log/audit_log/contracts/schemas.py +++ b/modules/audit_log/audit_log/contracts/schemas.py @@ -10,7 +10,7 @@ class AuditEntryRead(SQLModel): - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment] id: uuid.UUID entity_type: str diff --git a/modules/audit_log/audit_log/service.py b/modules/audit_log/audit_log/service.py index a4942becb..0dd7153ef 100644 --- a/modules/audit_log/audit_log/service.py +++ b/modules/audit_log/audit_log/service.py @@ -158,8 +158,8 @@ async def iter_entries( # is what stops a batch boundary swallowing the rest of them. stmt = stmt.where( or_( - AuditEntry.created_at < last_created, - and_(AuditEntry.created_at == last_created, AuditEntry.id > last_id), + AuditEntry.created_at < last_created, # ty: ignore[invalid-argument-type] + and_(AuditEntry.created_at == last_created, AuditEntry.id > last_id), # ty: ignore[invalid-argument-type] ) ) stmt = stmt.order_by(AuditEntry.created_at.desc(), AuditEntry.id).limit(batch_size) diff --git a/modules/feature_flags/feature_flags/contracts/schemas.py b/modules/feature_flags/feature_flags/contracts/schemas.py index 830fd4c12..ea79edba3 100644 --- a/modules/feature_flags/feature_flags/contracts/schemas.py +++ b/modules/feature_flags/feature_flags/contracts/schemas.py @@ -11,7 +11,7 @@ class FeatureFlagOverrideOut(SQLModel): """Stored override row as returned by the service/API.""" - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment] id: int scope: str diff --git a/modules/feature_flags/feature_flags/service.py b/modules/feature_flags/feature_flags/service.py index 3be9565d8..8d2be3555 100644 --- a/modules/feature_flags/feature_flags/service.py +++ b/modules/feature_flags/feature_flags/service.py @@ -131,9 +131,9 @@ def build_view( async def _find(self, scope: str, scope_id: str, name: str) -> FeatureFlagOverride | None: result = await self.db.execute( select(FeatureFlagOverride).where( - FeatureFlagOverride.scope == scope, - FeatureFlagOverride.scope_id == scope_id, - FeatureFlagOverride.name == name, + FeatureFlagOverride.scope == scope, # ty: ignore[invalid-argument-type] + FeatureFlagOverride.scope_id == scope_id, # ty: ignore[invalid-argument-type] + FeatureFlagOverride.name == name, # ty: ignore[invalid-argument-type] ) ) return result.scalar_one_or_none() diff --git a/modules/keycloak/keycloak/module.py b/modules/keycloak/keycloak/module.py index 253dd58b5..8282e7e1a 100644 --- a/modules/keycloak/keycloak/module.py +++ b/modules/keycloak/keycloak/module.py @@ -40,7 +40,7 @@ def register_settings(self, app: FastAPI) -> None: app, "keycloak", KeycloakSettings, - lambda s: KeycloakState(settings=s), + lambda s: KeycloakState(settings=s), # ty: ignore[invalid-argument-type] ) app.state.auth.auth_provider = KeycloakAuthProvider( diff --git a/modules/keycloak/keycloak/provider.py b/modules/keycloak/keycloak/provider.py index b3092cd13..7120c5faa 100644 --- a/modules/keycloak/keycloak/provider.py +++ b/modules/keycloak/keycloak/provider.py @@ -167,7 +167,7 @@ async def _upsert_user_cache(self, request: Request, claims: dict) -> str: session_factory = request.app.state.sm.db.session_factory sub = claims["sub"] async with session_factory() as db: - stmt = select(KeycloakUserCache).where(KeycloakUserCache.keycloak_sub == sub) + stmt = select(KeycloakUserCache).where(KeycloakUserCache.keycloak_sub == sub) # ty: ignore[invalid-argument-type] row = (await db.execute(stmt)).scalar_one_or_none() if row is None: import uuid as uuid_mod diff --git a/modules/permissions/permissions/contracts/schemas.py b/modules/permissions/permissions/contracts/schemas.py index e48805527..c83c6fe91 100644 --- a/modules/permissions/permissions/contracts/schemas.py +++ b/modules/permissions/permissions/contracts/schemas.py @@ -20,7 +20,7 @@ class PermissionGroupOut(SQLModel): class RoleOut(SQLModel): """A role as surfaced by the permissions admin UI.""" - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment] id: uuid.UUID name: str @@ -43,7 +43,7 @@ class RolePermissionsUpdate(SQLModel): class UserOut(SQLModel): """A user as surfaced by the permissions admin UI.""" - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment] id: uuid.UUID email: str diff --git a/modules/permissions/permissions/service.py b/modules/permissions/permissions/service.py index 214f63dc7..d370d7a96 100644 --- a/modules/permissions/permissions/service.py +++ b/modules/permissions/permissions/service.py @@ -108,7 +108,7 @@ async def set_role_permissions( if to_remove: await self.db.execute( delete(RolePermission).where( - RolePermission.role_name == role.name, + RolePermission.role_name == role.name, # ty: ignore[invalid-argument-type] RolePermission.permission_key.in_(to_remove), ) ) @@ -129,7 +129,7 @@ async def _get_user(self, user_id: uuid.UUID) -> User | None: from users.models import User result = await self.db.execute( - select(User).where(User.id == user_id).options(selectinload(User.roles)) + select(User).where(User.id == user_id).options(selectinload(User.roles)) # ty: ignore[invalid-argument-type] ) return result.scalar_one_or_none() @@ -199,7 +199,7 @@ async def set_user_permissions( if to_remove: await self.db.execute( delete(UserPermission).where( - UserPermission.user_id == user.id, + UserPermission.user_id == user.id, # ty: ignore[invalid-argument-type] UserPermission.permission_key.in_(to_remove), ) ) diff --git a/modules/site_lock/site_lock/module.py b/modules/site_lock/site_lock/module.py index 60b8d3a72..b0a7e8887 100644 --- a/modules/site_lock/site_lock/module.py +++ b/modules/site_lock/site_lock/module.py @@ -38,7 +38,7 @@ def register_settings(self, app: FastAPI) -> None: app, c.MODULE_PACKAGE, SiteLockSettings, - lambda s: SiteLockState(settings=s), + lambda s: SiteLockState(settings=s), # ty: ignore[invalid-argument-type] ) def register_middleware(self, app: FastAPI) -> None: diff --git a/modules/tenants/tenants/_deletion.py b/modules/tenants/tenants/_deletion.py index ed4718def..bb54e7741 100644 --- a/modules/tenants/tenants/_deletion.py +++ b/modules/tenants/tenants/_deletion.py @@ -28,7 +28,7 @@ async def delete_tenant_rows(db: AsyncSession, tenant: Tenant) -> list[Event]: ) ).all() for model in (Membership, Invitation): - await db.execute(delete(model).where(model.tenant_id == tenant.id)) + await db.execute(delete(model).where(model.tenant_id == tenant.id)) # ty: ignore[invalid-argument-type] events: list[Event] = [MembershipRemoved(tenant.id, user_id) for user_id in user_ids] events.append(TenantDeleted(tenant.id, tenant.slug, tenant.name)) await db.delete(tenant) diff --git a/modules/tenants/tenants/_owner_guard.py b/modules/tenants/tenants/_owner_guard.py index d1faa343e..2923107a9 100644 --- a/modules/tenants/tenants/_owner_guard.py +++ b/modules/tenants/tenants/_owner_guard.py @@ -27,9 +27,9 @@ def _another_owner(membership: Membership): other = aliased(Membership) return exists().where( - other.tenant_id == membership.tenant_id, - other.role == MembershipRole.OWNER, - other.id != membership.id, + other.tenant_id == membership.tenant_id, # ty: ignore[invalid-argument-type] + other.role == MembershipRole.OWNER, # ty: ignore[invalid-argument-type] + other.id != membership.id, # ty: ignore[invalid-argument-type] ) @@ -37,7 +37,7 @@ async def demote_owner(db: AsyncSession, membership: Membership, role: str) -> b """Set ``role`` on an owner's membership unless they are the last owner.""" stmt = ( update(Membership) - .where(Membership.id == membership.id, _another_owner(membership)) + .where(Membership.id == membership.id, _another_owner(membership)) # ty: ignore[invalid-argument-type] .values(role=role) .execution_options(synchronize_session=False) ) @@ -52,7 +52,7 @@ async def remove_owner(db: AsyncSession, membership: Membership) -> bool: """Delete an owner's membership unless they are the last owner.""" stmt = ( delete(Membership) - .where(Membership.id == membership.id, _another_owner(membership)) + .where(Membership.id == membership.id, _another_owner(membership)) # ty: ignore[invalid-argument-type] .execution_options(synchronize_session=False) ) applied = (await db.execute(stmt)).rowcount == 1 diff --git a/modules/tenants/tenants/invitations.py b/modules/tenants/tenants/invitations.py index a57e80e1c..fd639212e 100644 --- a/modules/tenants/tenants/invitations.py +++ b/modules/tenants/tenants/invitations.py @@ -68,10 +68,10 @@ async def create( # still occupy the unique index; drop it so the address can be re-invited. await self.db.execute( delete(Invitation).where( - Invitation.tenant_id == tenant_id, - Invitation.email == data.email, + Invitation.tenant_id == tenant_id, # ty: ignore[invalid-argument-type] + Invitation.email == data.email, # ty: ignore[invalid-argument-type] Invitation.accepted_at.is_(None), - Invitation.expires_at <= _now(), + Invitation.expires_at <= _now(), # ty: ignore[invalid-argument-type] ) ) duplicate = await self.db.scalar( @@ -120,8 +120,8 @@ async def lookup( return None stmt = ( select(Invitation, Tenant) - .join(Tenant, Tenant.id == Invitation.tenant_id) - .where(Invitation.token_hash == hash_token(token)) + .join(Tenant, Tenant.id == Invitation.tenant_id) # ty: ignore[invalid-argument-type] + .where(Invitation.token_hash == hash_token(token)) # ty: ignore[invalid-argument-type] ) if for_update: # two concurrent accepts of one link must not both pass stmt = stmt.with_for_update(of=Invitation) diff --git a/modules/tenants/tenants/module.py b/modules/tenants/tenants/module.py index 0190c3706..c3c9829d6 100644 --- a/modules/tenants/tenants/module.py +++ b/modules/tenants/tenants/module.py @@ -62,7 +62,10 @@ def register_settings(self, app: FastAPI) -> None: "settings.registration" ).register_module_settings register_module_settings( - app, c.MODULE_PACKAGE, TenantsSettings, lambda s: TenantsServices(settings=s) + app, + c.MODULE_PACKAGE, + TenantsSettings, + lambda s: TenantsServices(settings=s), # ty: ignore[invalid-argument-type] ) app.state.tenant_resolver = resolve_tenant app.state.tenant_exists = _tenant_exists(app) diff --git a/modules/tenants/tenants/service.py b/modules/tenants/tenants/service.py index 5b1f9dd10..c7162888b 100644 --- a/modules/tenants/tenants/service.py +++ b/modules/tenants/tenants/service.py @@ -174,7 +174,7 @@ async def lock(self, tenant_id: str) -> Tenant: compiles away; there the database's own writer lock serialises the two transactions (see ``tests/test_owner_race.py``). """ - stmt = select(Tenant).where(Tenant.id == tenant_id).with_for_update() + stmt = select(Tenant).where(Tenant.id == tenant_id).with_for_update() # ty: ignore[invalid-argument-type] tenant = (await self.db.execute(stmt)).scalar_one_or_none() if tenant is None: raise TenantError("not_found", status_code=404) @@ -205,14 +205,15 @@ async def has_member_email(self, tenant_id: str, email: str) -> bool: async def get_membership(self, tenant_id: str, user_id: str) -> Membership | None: stmt = select(Membership).where( - Membership.tenant_id == tenant_id, Membership.user_id == user_id + Membership.tenant_id == tenant_id, + Membership.user_id == user_id, # ty: ignore[invalid-argument-type] ) return (await self.db.execute(stmt)).scalar_one_or_none() async def list_members(self, tenant_id: str) -> list[Membership]: stmt = ( select(Membership) - .where(Membership.tenant_id == tenant_id) + .where(Membership.tenant_id == tenant_id) # ty: ignore[invalid-argument-type] .order_by(Membership.created_at) ) return list((await self.db.execute(stmt)).scalars().all()) @@ -222,7 +223,7 @@ async def seats_used(self, tenant_id: str) -> int: from tenants.invitations import pending_invitation_count members = await self.db.scalar( - select(func.count()).select_from(Membership).where(Membership.tenant_id == tenant_id) + select(func.count()).select_from(Membership).where(Membership.tenant_id == tenant_id) # ty: ignore[invalid-argument-type] ) return int(members or 0) + await pending_invitation_count(self.db, tenant_id) @@ -260,7 +261,7 @@ async def _owner_count(self, tenant_id: str) -> int: stmt = ( select(func.count()) .select_from(Membership) - .where(Membership.tenant_id == tenant_id, Membership.role == MembershipRole.OWNER) + .where(Membership.tenant_id == tenant_id, Membership.role == MembershipRole.OWNER) # ty: ignore[invalid-argument-type] ) return int(await self.db.scalar(stmt) or 0) From b8488b7a220ce15a7eee0a251b07d8986fa875bf Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:35:06 +0200 Subject: [PATCH 10/19] chore(ty): tenants service stays under size cap (#507) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/tenants/tenants/service.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/modules/tenants/tenants/service.py b/modules/tenants/tenants/service.py index c7162888b..c053a4f1a 100644 --- a/modules/tenants/tenants/service.py +++ b/modules/tenants/tenants/service.py @@ -24,12 +24,7 @@ from tenants._deletion import delete_tenant_rows from tenants._owner_guard import demote_owner, remove_owner -from tenants.constants import ( - ENTITLEMENT_SEATS, - MAX_SLUG_LEN, - MembershipRole, - TenantStatus, -) +from tenants.constants import ENTITLEMENT_SEATS, MAX_SLUG_LEN, MembershipRole, TenantStatus from tenants.contracts.entitlements import ( EntitlementProvider, UnlimitedEntitlements, @@ -204,7 +199,7 @@ async def has_member_email(self, tenant_id: str, email: str) -> bool: return await self.db.scalar(stmt) is not None async def get_membership(self, tenant_id: str, user_id: str) -> Membership | None: - stmt = select(Membership).where( + stmt = select(Membership).where( # ty: ignore[invalid-argument-type] Membership.tenant_id == tenant_id, Membership.user_id == user_id, # ty: ignore[invalid-argument-type] ) From dc6a003c477207570ae01f3de4243b9a334f6879 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:35:25 +0200 Subject: [PATCH 11/19] chore(ty): fix misplaced ignore in tenants service (#507) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/tenants/tenants/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/tenants/tenants/service.py b/modules/tenants/tenants/service.py index c053a4f1a..de2079cbe 100644 --- a/modules/tenants/tenants/service.py +++ b/modules/tenants/tenants/service.py @@ -199,7 +199,7 @@ async def has_member_email(self, tenant_id: str, email: str) -> bool: return await self.db.scalar(stmt) is not None async def get_membership(self, tenant_id: str, user_id: str) -> Membership | None: - stmt = select(Membership).where( # ty: ignore[invalid-argument-type] + stmt = select(Membership).where( # ty: ignore[invalid-argument-type] # ty: ignore[invalid-argument-type] Membership.tenant_id == tenant_id, Membership.user_id == user_id, # ty: ignore[invalid-argument-type] ) From 5d8b5e29658491e8fbed41134661bfb5743db84e Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:35:34 +0200 Subject: [PATCH 12/19] chore(ty): tenants service ignore placement (#507) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/tenants/tenants/service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/tenants/tenants/service.py b/modules/tenants/tenants/service.py index de2079cbe..3b2d43a22 100644 --- a/modules/tenants/tenants/service.py +++ b/modules/tenants/tenants/service.py @@ -199,8 +199,8 @@ async def has_member_email(self, tenant_id: str, email: str) -> bool: return await self.db.scalar(stmt) is not None async def get_membership(self, tenant_id: str, user_id: str) -> Membership | None: - stmt = select(Membership).where( # ty: ignore[invalid-argument-type] # ty: ignore[invalid-argument-type] - Membership.tenant_id == tenant_id, + stmt = select(Membership).where( + Membership.tenant_id == tenant_id, # ty: ignore[invalid-argument-type] Membership.user_id == user_id, # ty: ignore[invalid-argument-type] ) return (await self.db.execute(stmt)).scalar_one_or_none() From fb224c901f1391c15b37f3583ad49c525e60bd8f Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:35:56 +0200 Subject: [PATCH 13/19] chore(ty): drop register_module_settings ignores made unused by the generic signature (#507) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/api_tokens/api_tokens/module.py | 2 +- modules/keycloak/keycloak/module.py | 2 +- modules/site_lock/site_lock/module.py | 2 +- modules/tenants/tenants/module.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/modules/api_tokens/api_tokens/module.py b/modules/api_tokens/api_tokens/module.py index af7d5612d..8bdb76c88 100644 --- a/modules/api_tokens/api_tokens/module.py +++ b/modules/api_tokens/api_tokens/module.py @@ -43,7 +43,7 @@ def register_settings(self, app: FastAPI) -> None: app, c.MODULE_PACKAGE, ApiTokensSettings, - lambda s: ApiTokensServices(settings=s), # ty: ignore[invalid-argument-type] + lambda s: ApiTokensServices(settings=s), ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: diff --git a/modules/keycloak/keycloak/module.py b/modules/keycloak/keycloak/module.py index 8282e7e1a..253dd58b5 100644 --- a/modules/keycloak/keycloak/module.py +++ b/modules/keycloak/keycloak/module.py @@ -40,7 +40,7 @@ def register_settings(self, app: FastAPI) -> None: app, "keycloak", KeycloakSettings, - lambda s: KeycloakState(settings=s), # ty: ignore[invalid-argument-type] + lambda s: KeycloakState(settings=s), ) app.state.auth.auth_provider = KeycloakAuthProvider( diff --git a/modules/site_lock/site_lock/module.py b/modules/site_lock/site_lock/module.py index b0a7e8887..60b8d3a72 100644 --- a/modules/site_lock/site_lock/module.py +++ b/modules/site_lock/site_lock/module.py @@ -38,7 +38,7 @@ def register_settings(self, app: FastAPI) -> None: app, c.MODULE_PACKAGE, SiteLockSettings, - lambda s: SiteLockState(settings=s), # ty: ignore[invalid-argument-type] + lambda s: SiteLockState(settings=s), ) def register_middleware(self, app: FastAPI) -> None: diff --git a/modules/tenants/tenants/module.py b/modules/tenants/tenants/module.py index c3c9829d6..c6e6bb70b 100644 --- a/modules/tenants/tenants/module.py +++ b/modules/tenants/tenants/module.py @@ -65,7 +65,7 @@ def register_settings(self, app: FastAPI) -> None: app, c.MODULE_PACKAGE, TenantsSettings, - lambda s: TenantsServices(settings=s), # ty: ignore[invalid-argument-type] + lambda s: TenantsServices(settings=s), ) app.state.tenant_resolver = resolve_tenant app.state.tenant_exists = _tenant_exists(app) From bcbd2c0e0580b8e6edd603260c3fd45b22a796e2 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:36:09 +0200 Subject: [PATCH 14/19] chore(ty): background_tasks + file_storage clean under invalid-argument-type/assignment (#507) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- .../background_tasks/background_tasks/_signal_bus.py | 3 ++- .../background_tasks/_signal_support.py | 2 +- .../background_tasks/contracts/outbox.py | 2 +- .../background_tasks/contracts/schemas.py | 4 ++-- modules/background_tasks/background_tasks/filters.py | 6 +++--- modules/background_tasks/background_tasks/module.py | 2 +- .../background_tasks/background_tasks/outbox_claim.py | 6 +++--- .../background_tasks/background_tasks/outbox_loop.py | 4 ++-- .../background_tasks/outbox_service.py | 10 +++++----- .../background_tasks/retry_service.py | 4 ++-- .../background_tasks/task_heartbeat.py | 4 ++-- modules/background_tasks/background_tasks/tasks.py | 4 ++-- modules/file_storage/file_storage/aggregates.py | 2 +- modules/file_storage/file_storage/backends/s3.py | 2 +- .../file_storage/file_storage/contracts/schemas.py | 4 ++-- modules/file_storage/file_storage/deps.py | 2 ++ modules/file_storage/file_storage/module.py | 2 +- modules/file_storage/file_storage/queries.py | 2 +- modules/file_storage/file_storage/service.py | 2 +- modules/file_storage/file_storage/tasks.py | 8 ++++---- modules/file_storage/file_storage/upload_guards.py | 8 ++++---- modules/file_storage/file_storage/uploads.py | 7 ++++--- modules/file_storage/file_storage/visibility.py | 2 +- .../tests/test_file_storage_deps_backend.py | 11 +++++++++++ 24 files changed, 59 insertions(+), 44 deletions(-) create mode 100644 modules/file_storage/tests/test_file_storage_deps_backend.py diff --git a/modules/background_tasks/background_tasks/_signal_bus.py b/modules/background_tasks/background_tasks/_signal_bus.py index fb8bd3e7c..a9e32a931 100644 --- a/modules/background_tasks/background_tasks/_signal_bus.py +++ b/modules/background_tasks/background_tasks/_signal_bus.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio +import concurrent.futures import logging from typing import Any @@ -53,7 +54,7 @@ def publish_from_signal(event: Any) -> None: future.add_done_callback(_log_publish_failure) -def _log_publish_failure(future: asyncio.Future[Any]) -> None: +def _log_publish_failure(future: concurrent.futures.Future[Any]) -> None: if future.cancelled(): return exc = future.exception() diff --git a/modules/background_tasks/background_tasks/_signal_support.py b/modules/background_tasks/background_tasks/_signal_support.py index f5c813fab..d4605ee36 100644 --- a/modules/background_tasks/background_tasks/_signal_support.py +++ b/modules/background_tasks/background_tasks/_signal_support.py @@ -47,7 +47,7 @@ def upsert_by_celery_id( """ row: TaskExecution | None = None if celery_task_id: - stmt = select(TaskExecution).where(TaskExecution.celery_task_id == celery_task_id) + stmt = select(TaskExecution).where(TaskExecution.celery_task_id == celery_task_id) # ty: ignore[invalid-argument-type] row = session.execute(stmt).scalar_one_or_none() if row is None: row = TaskExecution(celery_task_id=celery_task_id, **defaults) diff --git a/modules/background_tasks/background_tasks/contracts/outbox.py b/modules/background_tasks/background_tasks/contracts/outbox.py index 17dd24a6b..de699bce0 100644 --- a/modules/background_tasks/background_tasks/contracts/outbox.py +++ b/modules/background_tasks/background_tasks/contracts/outbox.py @@ -15,7 +15,7 @@ class OutboxItem(SQLModel): """One (event, durable handler) delivery as the admin screen shows it.""" - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment] id: uuid.UUID event_key: str diff --git a/modules/background_tasks/background_tasks/contracts/schemas.py b/modules/background_tasks/background_tasks/contracts/schemas.py index abbb81698..35dcb2027 100644 --- a/modules/background_tasks/background_tasks/contracts/schemas.py +++ b/modules/background_tasks/background_tasks/contracts/schemas.py @@ -21,7 +21,7 @@ class TaskExecutionListItem(SQLModel): the operator needs to read it *before* confirming, not on a second page. """ - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment] id: uuid.UUID celery_task_id: str | None = None @@ -43,7 +43,7 @@ class TaskExecutionListItem(SQLModel): class TaskExecutionDetail(SQLModel): """Full row for the detail page (adds args/kwargs/result/traceback).""" - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment] id: uuid.UUID celery_task_id: str | None = None diff --git a/modules/background_tasks/background_tasks/filters.py b/modules/background_tasks/background_tasks/filters.py index 68435ebd3..12b3c7c0e 100644 --- a/modules/background_tasks/background_tasks/filters.py +++ b/modules/background_tasks/background_tasks/filters.py @@ -34,15 +34,15 @@ def execution_filters( if tenant_id == PLATFORM_TENANT_FILTER: conditions.append(TaskExecution.tenant_id.is_(None)) elif tenant_id: - conditions.append(TaskExecution.tenant_id == tenant_id) + conditions.append(TaskExecution.tenant_id == tenant_id) # ty: ignore[invalid-argument-type] if status is not None: - conditions.append(TaskExecution.status == status) + conditions.append(TaskExecution.status == status) # ty: ignore[invalid-argument-type] if task_name: conditions.append( TaskExecution.task_name.ilike(like_contains_pattern(task_name), escape=LIKE_ESCAPE_CHAR) ) if queue: - conditions.append(TaskExecution.queue == queue) + conditions.append(TaskExecution.queue == queue) # ty: ignore[invalid-argument-type] return conditions diff --git a/modules/background_tasks/background_tasks/module.py b/modules/background_tasks/background_tasks/module.py index 087621f11..27357a5ad 100644 --- a/modules/background_tasks/background_tasks/module.py +++ b/modules/background_tasks/background_tasks/module.py @@ -95,7 +95,7 @@ def register_settings(self, app: FastAPI) -> None: app, "background_tasks", BackgroundTasksSettings, - lambda s: BackgroundTasksServices(settings=s), + lambda s: BackgroundTasksServices(settings=s), # ty: ignore[invalid-argument-type] ) def register_permissions(self, registry: PermissionRegistry) -> None: diff --git a/modules/background_tasks/background_tasks/outbox_claim.py b/modules/background_tasks/background_tasks/outbox_claim.py index a5fd5704c..f468d17b3 100644 --- a/modules/background_tasks/background_tasks/outbox_claim.py +++ b/modules/background_tasks/background_tasks/outbox_claim.py @@ -108,7 +108,7 @@ async def reclaim_expired( ) dead = await session.execute( update(OutboxMessage) - .where(*expired, OutboxMessage.attempts >= max_attempts) + .where(*expired, OutboxMessage.attempts >= max_attempts) # ty: ignore[invalid-argument-type] .values( status=OutboxStatus.DEAD, locked_until=None, @@ -118,7 +118,7 @@ async def reclaim_expired( ) back = await session.execute( update(OutboxMessage) - .where(*expired) + .where(*expired) # ty: ignore[invalid-argument-type] .values(status=OutboxStatus.PENDING, locked_until=None, next_attempt_at=now) .execution_options(synchronize_session=False) ) @@ -174,7 +174,7 @@ async def claim( update(OutboxMessage) .where( OutboxMessage.id.in_(select(claimable.c.id)), - OutboxMessage.status == OutboxStatus.PENDING, + OutboxMessage.status == OutboxStatus.PENDING, # ty: ignore[invalid-argument-type] ) .values( status=OutboxStatus.RUNNING, diff --git a/modules/background_tasks/background_tasks/outbox_loop.py b/modules/background_tasks/background_tasks/outbox_loop.py index 76e77ff71..ae55445e6 100644 --- a/modules/background_tasks/background_tasks/outbox_loop.py +++ b/modules/background_tasks/background_tasks/outbox_loop.py @@ -29,7 +29,7 @@ import logging import os import threading -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Coroutine from typing import Any logger = logging.getLogger(__name__) @@ -105,7 +105,7 @@ async def _main() -> T: def stop_worker_loop( - finalize: Callable[[dict[str, Any]], Awaitable[None]] | None = None, + finalize: Callable[[dict[str, Any]], Coroutine[Any, Any, None]] | None = None, ) -> None: """Stop every outbox loop this process started (tests, shutdown). diff --git a/modules/background_tasks/background_tasks/outbox_service.py b/modules/background_tasks/background_tasks/outbox_service.py index 9812359f7..c9b5367ec 100644 --- a/modules/background_tasks/background_tasks/outbox_service.py +++ b/modules/background_tasks/background_tasks/outbox_service.py @@ -68,7 +68,7 @@ async def list( await self.db.execute( select(OutboxMessage) .where(*where) - .order_by(OutboxMessage.created_at.desc(), OutboxMessage.id) + .order_by(OutboxMessage.created_at.desc(), OutboxMessage.id) # ty: ignore[invalid-argument-type] .offset((page - 1) * per_page) .limit(per_page) ) @@ -97,7 +97,7 @@ async def _get(self, message_id: uuid.UUID) -> OutboxMessage: row = ( await self.db.execute( select(OutboxMessage) - .where(OutboxMessage.id == message_id, *self.scope.conditions()) + .where(OutboxMessage.id == message_id, *self.scope.conditions()) # ty: ignore[invalid-argument-type] .execution_options(populate_existing=True) ) ).scalar_one_or_none() @@ -118,8 +118,8 @@ async def retry(self, message_id: uuid.UUID) -> OutboxItem: result = await self.db.execute( update(OutboxMessage) .where( - OutboxMessage.id == message_id, - OutboxMessage.status == OutboxStatus.DEAD, + OutboxMessage.id == message_id, # ty: ignore[invalid-argument-type] + OutboxMessage.status == OutboxStatus.DEAD, # ty: ignore[invalid-argument-type] *self.scope.conditions(), ) .values( @@ -145,7 +145,7 @@ async def discard(self, message_id: uuid.UUID) -> None: result = await self.db.execute( delete(OutboxMessage) .where( - OutboxMessage.id == message_id, + OutboxMessage.id == message_id, # ty: ignore[invalid-argument-type] OutboxMessage.status.in_(DISCARDABLE), *self.scope.conditions(), ) diff --git a/modules/background_tasks/background_tasks/retry_service.py b/modules/background_tasks/background_tasks/retry_service.py index bcc358834..9e312d652 100644 --- a/modules/background_tasks/background_tasks/retry_service.py +++ b/modules/background_tasks/background_tasks/retry_service.py @@ -265,8 +265,8 @@ def _fail_unsent(task_id: str, exc: Exception) -> None: session.execute( update(TaskExecution) .where( - TaskExecution.celery_task_id == task_id, - TaskExecution.status == TaskStatus.PENDING, + TaskExecution.celery_task_id == task_id, # ty: ignore[invalid-argument-type] + TaskExecution.status == TaskStatus.PENDING, # ty: ignore[invalid-argument-type] ) .values( status=TaskStatus.FAILED, diff --git a/modules/background_tasks/background_tasks/task_heartbeat.py b/modules/background_tasks/background_tasks/task_heartbeat.py index 158d087c5..97497cfc2 100644 --- a/modules/background_tasks/background_tasks/task_heartbeat.py +++ b/modules/background_tasks/background_tasks/task_heartbeat.py @@ -74,11 +74,11 @@ def heartbeat(*, force: bool = False) -> bool: session.execute( update(TaskExecution) .where( - TaskExecution.celery_task_id == task_id, + TaskExecution.celery_task_id == task_id, # ty: ignore[invalid-argument-type] # Only a row still ``running``: a heartbeat must not revive # a row the sweep already declared stuck (it may have been # retried since) or rewrite a terminal one. - TaskExecution.status == TaskStatus.RUNNING, + TaskExecution.status == TaskStatus.RUNNING, # ty: ignore[invalid-argument-type] ) .values(heartbeat_at=now_utc()) ) diff --git a/modules/background_tasks/background_tasks/tasks.py b/modules/background_tasks/background_tasks/tasks.py index cca1955c7..4a3c7268a 100644 --- a/modules/background_tasks/background_tasks/tasks.py +++ b/modules/background_tasks/background_tasks/tasks.py @@ -124,7 +124,7 @@ def sweep_stuck_tasks(self: Task) -> int: stmt = ( update(TaskExecution) .where( - TaskExecution.status == TaskStatus.RUNNING, + TaskExecution.status == TaskStatus.RUNNING, # ty: ignore[invalid-argument-type] _stale(now, stuck_after, overrides), ) .values(status=TaskStatus.STUCK, finished_at=now) @@ -151,7 +151,7 @@ def purge_old_executions() -> int: count = result.rowcount or 0 delivered = session.execute( delete(OutboxMessage) - .where(OutboxMessage.status == OutboxStatus.DELIVERED) + .where(OutboxMessage.status == OutboxStatus.DELIVERED) # ty: ignore[invalid-argument-type] .where(OutboxMessage.delivered_at < cutoff) ) count += delivered.rowcount or 0 diff --git a/modules/file_storage/file_storage/aggregates.py b/modules/file_storage/file_storage/aggregates.py index df99006cc..c084446de 100644 --- a/modules/file_storage/file_storage/aggregates.py +++ b/modules/file_storage/file_storage/aggregates.py @@ -108,7 +108,7 @@ async def compute(db: AsyncSession) -> StorageAggregates: select( StoredFile.content_type, StoredFile.created_by, - func.count(StoredFile.id), + func.count(StoredFile.id), # ty: ignore[invalid-argument-type] func.coalesce(func.sum(StoredFile.size_bytes), 0), ).group_by(StoredFile.content_type, StoredFile.created_by) ) diff --git a/modules/file_storage/file_storage/backends/s3.py b/modules/file_storage/file_storage/backends/s3.py index cbc1ae832..f5263a5bb 100644 --- a/modules/file_storage/file_storage/backends/s3.py +++ b/modules/file_storage/file_storage/backends/s3.py @@ -179,7 +179,7 @@ async def _open(stream: AsyncGenerator[bytes]) -> AsyncIterator[bytes]: return _chain(first, iterator) -async def _chain(first: bytes | None, rest: AsyncGenerator[bytes]) -> AsyncIterator[bytes]: +async def _chain(first: bytes | None, rest: AsyncIterator[bytes]) -> AsyncIterator[bytes]: try: if first is not None: yield first diff --git a/modules/file_storage/file_storage/contracts/schemas.py b/modules/file_storage/file_storage/contracts/schemas.py index 6eca2ed9c..f49e46244 100644 --- a/modules/file_storage/file_storage/contracts/schemas.py +++ b/modules/file_storage/file_storage/contracts/schemas.py @@ -12,7 +12,7 @@ class StoredFileOut(SQLModel): """Metadata returned for a stored file.""" - model_config = ConfigDict(from_attributes=True) + model_config = ConfigDict(from_attributes=True) # ty: ignore[invalid-assignment] id: uuid.UUID key: str @@ -129,7 +129,7 @@ class CompletePart(SQLModel): no: int = Field(alias="n", ge=1) etag: str - model_config = ConfigDict(populate_by_name=True) + model_config = ConfigDict(populate_by_name=True) # ty: ignore[invalid-assignment] class UploadCompleteRequest(SQLModel): diff --git a/modules/file_storage/file_storage/deps.py b/modules/file_storage/file_storage/deps.py index fc0cf4816..05c542a51 100644 --- a/modules/file_storage/file_storage/deps.py +++ b/modules/file_storage/file_storage/deps.py @@ -26,6 +26,8 @@ async def get_file_storage_service( db: AsyncSession = Depends(get_db), services: FileStorageServices = Depends(get_file_storage_services), ) -> FileStorageService: + if services.backend is None: + raise RuntimeError("file_storage backend is not initialised (on_startup has not run)") return FileStorageService(db, services.backend, services.settings, services.aggregates) diff --git a/modules/file_storage/file_storage/module.py b/modules/file_storage/file_storage/module.py index 91b491ca2..0598f7c23 100644 --- a/modules/file_storage/file_storage/module.py +++ b/modules/file_storage/file_storage/module.py @@ -87,7 +87,7 @@ def register_settings(self, app: FastAPI) -> None: app, "file_storage", FileStorageSettings, - lambda s: FileStorageServices(settings=s), + lambda s: FileStorageServices(settings=s), # ty: ignore[invalid-argument-type] ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: diff --git a/modules/file_storage/file_storage/queries.py b/modules/file_storage/file_storage/queries.py index 68566ac99..4d035025c 100644 --- a/modules/file_storage/file_storage/queries.py +++ b/modules/file_storage/file_storage/queries.py @@ -96,7 +96,7 @@ async def count_files( # column, so the filter never applied and the total went on counting # deleted rows. That is the pager offering pages that render empty — the # exact failure ``filter_clauses`` exists to prevent. - query = select(func.count(StoredFile.id)).select_from(StoredFile) + query = select(func.count(StoredFile.id)).select_from(StoredFile) # ty: ignore[invalid-argument-type] for clause in filter_clauses(created_by=created_by, search=search, content_type=content_type): query = query.where(clause) return int((await db.execute(query)).scalar() or 0) diff --git a/modules/file_storage/file_storage/service.py b/modules/file_storage/file_storage/service.py index 9003b6d98..2e9bb9133 100644 --- a/modules/file_storage/file_storage/service.py +++ b/modules/file_storage/file_storage/service.py @@ -165,7 +165,7 @@ async def get( ``owner`` (``deps.get_owner_scope``) narrows it to one uploader's rows the same way: someone else's file misses like an unknown id (#544). """ - stmt = queries.owned_by(select(StoredFile).where(StoredFile.id == file_id), owner) + stmt = queries.owned_by(select(StoredFile).where(StoredFile.id == file_id), owner) # ty: ignore[invalid-argument-type] if platform: stmt = stmt.where(StoredFile.tenant_id == PLATFORM_TENANT_ID) async with platform_scope(self.db, platform): diff --git a/modules/file_storage/file_storage/tasks.py b/modules/file_storage/file_storage/tasks.py index 45da6d54a..d7cc49f9c 100644 --- a/modules/file_storage/file_storage/tasks.py +++ b/modules/file_storage/file_storage/tasks.py @@ -64,8 +64,8 @@ def reap_stale_uploads() -> dict[str, int]: # they only linger for support questions, so drop them after a window. purged = db.execute( delete(UploadSession).where( - UploadSession.status != constants.UploadStatus.ACTIVE, - UploadSession.expires_at + UploadSession.status != constants.UploadStatus.ACTIVE, # ty: ignore[invalid-argument-type] + UploadSession.expires_at # ty: ignore[invalid-argument-type] < now - timedelta(days=settings.upload_session_retention_days), ) ).rowcount @@ -73,8 +73,8 @@ def reap_stale_uploads() -> dict[str, int]: db.execute( select(UploadSession) .where( - UploadSession.status == constants.UploadStatus.ACTIVE, - UploadSession.expires_at <= now, + UploadSession.status == constants.UploadStatus.ACTIVE, # ty: ignore[invalid-argument-type] + UploadSession.expires_at <= now, # ty: ignore[invalid-argument-type] ) .limit(_REAP_BATCH) ).scalars() diff --git a/modules/file_storage/file_storage/upload_guards.py b/modules/file_storage/file_storage/upload_guards.py index 909eb4409..655f90a18 100644 --- a/modules/file_storage/file_storage/upload_guards.py +++ b/modules/file_storage/file_storage/upload_guards.py @@ -68,8 +68,8 @@ async def purge_finished(self, *, owner: str | None) -> int: result = await self.db.execute( delete(UploadSession).where( UploadSession.created_by == owner, - UploadSession.status != constants.UploadStatus.ACTIVE, - UploadSession.expires_at < cutoff, + UploadSession.status != constants.UploadStatus.ACTIVE, # ty: ignore[invalid-argument-type] + UploadSession.expires_at < cutoff, # ty: ignore[invalid-argument-type] ) ) return result.rowcount or 0 @@ -109,8 +109,8 @@ async def _check_quota(self, size: int, *, reserve: bool) -> None: if reserve: reserved = await self.db.scalar( select(func.coalesce(func.sum(UploadSession.total_size), 0)).where( - UploadSession.status == constants.UploadStatus.ACTIVE, - UploadSession.expires_at > datetime.now(UTC), + UploadSession.status == constants.UploadStatus.ACTIVE, # ty: ignore[invalid-argument-type] + UploadSession.expires_at > datetime.now(UTC), # ty: ignore[invalid-argument-type] ) ) if (stored or 0) + (reserved or 0) + size > quota: diff --git a/modules/file_storage/file_storage/uploads.py b/modules/file_storage/file_storage/uploads.py index c9cf613bf..341250b1d 100644 --- a/modules/file_storage/file_storage/uploads.py +++ b/modules/file_storage/file_storage/uploads.py @@ -109,7 +109,8 @@ async def get(self, upload_id: uuid.UUID) -> UploadSession: """The caller's own session. Others' ids miss exactly like unknown ones.""" owner = current_user_id.get() stmt = select(UploadSession).where( - UploadSession.id == upload_id, UploadSession.created_by == owner + UploadSession.id == upload_id, # ty: ignore[invalid-argument-type] + UploadSession.created_by == owner, ) session = (await self.db.execute(stmt)).scalar_one_or_none() if owner else None if session is None: @@ -159,8 +160,8 @@ async def abort_expired(self, *, owner: str | None) -> int: return 0 stmt = select(UploadSession).where( UploadSession.created_by == owner, - UploadSession.status == constants.UploadStatus.ACTIVE, - UploadSession.expires_at <= datetime.now(UTC), + UploadSession.status == constants.UploadStatus.ACTIVE, # ty: ignore[invalid-argument-type] + UploadSession.expires_at <= datetime.now(UTC), # ty: ignore[invalid-argument-type] ) expired = list((await self.db.execute(stmt)).scalars()) for session in expired: diff --git a/modules/file_storage/file_storage/visibility.py b/modules/file_storage/file_storage/visibility.py index d1dbc2141..3388edeef 100644 --- a/modules/file_storage/file_storage/visibility.py +++ b/modules/file_storage/file_storage/visibility.py @@ -46,7 +46,7 @@ async def get_public(self, file_id: uuid.UUID) -> StoredFile: """ stmt = ( select(StoredFile) - .where(StoredFile.id == file_id, StoredFile.public.is_(True)) + .where(StoredFile.id == file_id, StoredFile.public.is_(True)) # ty: ignore[invalid-argument-type] .execution_options(all_tenants=True) ) row = (await self.db.execute(stmt)).scalar_one_or_none() diff --git a/modules/file_storage/tests/test_file_storage_deps_backend.py b/modules/file_storage/tests/test_file_storage_deps_backend.py new file mode 100644 index 000000000..d5bdba4d7 --- /dev/null +++ b/modules/file_storage/tests/test_file_storage_deps_backend.py @@ -0,0 +1,11 @@ +"""``get_file_storage_service`` refuses to run before the backend is built.""" + +import pytest +from file_storage.deps import get_file_storage_service +from file_storage.services import FileStorageServices + + +async def test_service_dep_requires_backend(): + services = FileStorageServices(settings=None) + with pytest.raises(RuntimeError, match="not initialised"): + await get_file_storage_service(db=None, services=services) From 86886721253074d6586afd3f1f17381f58bdf232 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:36:34 +0200 Subject: [PATCH 15/19] chore(ty): drop now-unused register_module_settings ignores (#507) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/background_tasks/background_tasks/module.py | 2 +- modules/file_storage/file_storage/module.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/background_tasks/background_tasks/module.py b/modules/background_tasks/background_tasks/module.py index 27357a5ad..087621f11 100644 --- a/modules/background_tasks/background_tasks/module.py +++ b/modules/background_tasks/background_tasks/module.py @@ -95,7 +95,7 @@ def register_settings(self, app: FastAPI) -> None: app, "background_tasks", BackgroundTasksSettings, - lambda s: BackgroundTasksServices(settings=s), # ty: ignore[invalid-argument-type] + lambda s: BackgroundTasksServices(settings=s), ) def register_permissions(self, registry: PermissionRegistry) -> None: diff --git a/modules/file_storage/file_storage/module.py b/modules/file_storage/file_storage/module.py index 0598f7c23..91b491ca2 100644 --- a/modules/file_storage/file_storage/module.py +++ b/modules/file_storage/file_storage/module.py @@ -87,7 +87,7 @@ def register_settings(self, app: FastAPI) -> None: app, "file_storage", FileStorageSettings, - lambda s: FileStorageServices(settings=s), # ty: ignore[invalid-argument-type] + lambda s: FileStorageServices(settings=s), ) def register_routes(self, api_router: APIRouter, view_router: APIRouter) -> None: From fb2baf224b8c1f133961164849f1b1b8a07eb487 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:41:01 +0200 Subject: [PATCH 16/19] test(settings): lenient-log secrecy test ignores records from before hydration Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/settings/tests/test_hydrate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/settings/tests/test_hydrate.py b/modules/settings/tests/test_hydrate.py index 0b6d15cc3..50f9850dc 100644 --- a/modules/settings/tests/test_hydrate.py +++ b/modules/settings/tests/test_hydrate.py @@ -141,6 +141,9 @@ async def test_lenient_model_level_error_still_raises(db_session) -> None: async def test_lenient_log_never_echoes_the_stored_value(db_session, caplog) -> None: store = SettingsStore(SettingService(db_session)) await store.set_override("g", "token", "bad-s3cr3t", "string") + # Only hydration's records: the insert above may already be in caplog as an + # aiosqlite DEBUG line when an earlier test left logging at DEBUG. + caplog.clear() with caplog.at_level("ERROR"): cfg = await hydrate_settings(_Guarded, store, package="g", lenient=True) assert cfg.token == "ok" From 05e9f6a40e6ad9d46af99be82ef57f9d50a4fbf3 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 21:41:11 +0200 Subject: [PATCH 17/19] docs(changelog): #507 Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 981f36aea..b254318bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,22 @@ 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[]`. + `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. +- `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` From 50b4acabb63aa5a57f376e03dddfef1f5da2d558 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 22:04:58 +0200 Subject: [PATCH 18/19] fix: address code review findings (round 1, pass 1) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- CHANGELOG.md | 2 ++ modules/settings/settings/_field_meta.py | 7 ++++++- modules/settings/settings/hydrate.py | 6 ++++-- .../settings/tests/test_settings_group_metadata.py | 14 ++++++++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b254318bb..518668c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,8 @@ All notable changes to this project are documented in this file. The format is b - **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). diff --git a/modules/settings/settings/_field_meta.py b/modules/settings/settings/_field_meta.py index 21970e6fe..f238260c0 100644 --- a/modules/settings/settings/_field_meta.py +++ b/modules/settings/settings/_field_meta.py @@ -8,7 +8,7 @@ from __future__ import annotations import re -from typing import Any +from typing import Any, Literal, get_args, get_origin from pydantic_settings import BaseSettings @@ -71,6 +71,11 @@ def choices_for(info) -> list[str] | None: if isinstance(declared, (list, tuple)) and declared: return [str(choice) for choice in declared] + if get_origin(info.annotation) is Literal: + options = get_args(info.annotation) + if options and all(isinstance(o, str) for o in options): + return list(options) + for constraint in info.metadata: pattern = getattr(constraint, "pattern", None) if not isinstance(pattern, str): diff --git a/modules/settings/settings/hydrate.py b/modules/settings/settings/hydrate.py index 937de96be..91a149819 100644 --- a/modules/settings/settings/hydrate.py +++ b/modules/settings/settings/hydrate.py @@ -11,7 +11,7 @@ import logging import types -from typing import Any, Union, get_args, get_origin +from typing import Any, Literal, Union, get_args, get_origin from pydantic import BaseModel from pydantic_settings import ( @@ -52,12 +52,14 @@ def value_type_for_field(cls: type[BaseSettings], field_name: str) -> str: - ``bool`` → ``"bool"`` - ``int`` → ``"int"`` - ``float`` → ``"float"`` - - ``str`` and enums → ``"string"`` + - ``str``, enums and ``Literal`` of strings → ``"string"`` - ``list``, ``dict``, and other container types → ``"json"`` """ info = cls.model_fields[field_name] ann = info.annotation origin = get_origin(ann) + if origin is Literal and all(isinstance(a, str) for a in get_args(ann)): + return "string" if origin is not None: return "json" return _SCALAR_TYPES.get(ann, "string") diff --git a/modules/settings/tests/test_settings_group_metadata.py b/modules/settings/tests/test_settings_group_metadata.py index faa826ee1..b56e611a9 100644 --- a/modules/settings/tests/test_settings_group_metadata.py +++ b/modules/settings/tests/test_settings_group_metadata.py @@ -14,3 +14,17 @@ class Config(BaseSettings): view = _field_view("host", Config(), "SM_DEMO_") assert view.group == (group if isinstance(group, str) else None) + + +def test_string_literal_field_is_a_string_select(): + from typing import Literal + + from settings._field_meta import choices_for + from settings.hydrate import input_type_for_field, value_type_for_field + + class Config(BaseSettings): + mode: Literal["lax", "strict"] = "lax" + + assert value_type_for_field(Config, "mode") == "string" + assert input_type_for_field(Config, "mode") == ("string", False) + assert choices_for(Config.model_fields["mode"]) == ["lax", "strict"] From 65654b70c08869bd57abe0d8a70e71393387dfd2 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Mon, 19 Oct 2026 22:07:53 +0200 Subject: [PATCH 19/19] fix: address code review findings (round 1, pass 1b) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 --- modules/settings/settings/_module_settings.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/settings/settings/_module_settings.py b/modules/settings/settings/_module_settings.py index 3f7125f56..b45ec746c 100644 --- a/modules/settings/settings/_module_settings.py +++ b/modules/settings/settings/_module_settings.py @@ -168,6 +168,7 @@ def hidden(value: Any) -> bool: or named_secret or any(embeds_credential(v) for v in (raw_value, default, raw_env)) ) + group = extra.get("group") return ModuleSettingField( name=name, env_var=env_var, @@ -177,7 +178,7 @@ def hidden(value: Any) -> bool: is_secret=secret, type=value_type, requires_restart=bool(extra.get("requires_restart", False)), - group=group if isinstance(group := extra.get("group"), str) else None, + group=group if isinstance(group, str) else None, env_set=env_set, db_override=name in overridden, env_readable=live_env_var is not None,