Skip to content

Commit 112a71e

Browse files
authored
SEP-1907: Neutralize the dependency-typing artifacts in ty's warning output (#1434)
`ty check` reported 4,072 diagnostics with nothing in the output separating a genuine typing defect in this repository from an artifact of how a third-party dependency is typed. This splits the two and neutralizes only the artifact half — 446 diagnostics — leaving a 3,626 first-party remainder that SEP-1908 can be sized against. **No rule severity changes**: no severity in `[tool.ty.rules]` moved, and `[tool.ty.src].include` and the `Makefile` `typecheck` target are byte-identical to `main`. The only edit inside the `[tool.ty.rules]` span is the comment block introducing the override entries that follow it. **`scripts/classify_ty_diagnostics.py` (new).** Holds the classification as executable predicates rather than prose: eleven `Group` entries, each pairing a rule set with a message regex and recording the discriminant it keys on. The discriminant is load-bearing because most groups share a rule with genuine defects — under `unknown-argument`, the pydantic-settings `_secrets_dir` kwarg and a first-party `PMM` kwarg differ only in the symbol the message names, and `tests/app/sep/test_config.py` carries both. One group is additionally confined to a set of paths: `Cannot resolve imported module` reads identically for a golden-app module scaffolded at test time and for a first-party import someone mistyped, so `absent-modules` matches only under `tests/app/sep/apps/framework/golden/` and at `app/sep/sync/syncers/system_facts/payload.py`. Three modes: `report` prints the group table and the per-rule artifact/residual split, `baseline` emits a fingerprint manifest, `check` reconciles a run against one. `check` is the gate, and it compares fingerprints rather than counts. A count comparison cannot establish the ticket's central invariant: once a suppression hides a first-party warning that row is simply absent, so a change removing one artifact *and* one first-party diagnostic while an unrelated new one appears reconciles to the expected total. `check` instead takes the multiset difference over `(path, rule, message)` and fails unless every diagnostic that stopped reporting is one the classification marks as an artifact, naming any that is not. The fingerprint is exactly what `classify()` reads, so two diagnostics sharing one always share a verdict; folding the line and column away costs the gate no soundness and lets it survive the reformatting the suppression comments provoke, which would otherwise report every diagnostic below an edited line as newly suppressed. **Two neutralization mechanisms, chosen per `(file, rule)` pair.** Eleven `[[tool.ty.overrides]]` entries in `pyproject.toml` cover the 59 pairs whose every hit of that rule in that file is an artifact — 264 hits. Each entry names explicit file paths rather than a directory wildcard, softens exactly one rule, and carries the reason. The 27 pairs that mix take 182 per-site `# ty: ignore[rule]` comments instead, because an override cannot discriminate within a file and would suppress the genuine defects alongside. No pair gets both: an override makes a same-rule comment unused, and `unused-ignore-comment` is unlisted in `[tool.ty.rules]` and so inherits `all = "error"` — which is also what makes the comments self-cleaning as the tree drifts. **`docs/development/ty-policy.md`** gains a *Neutralized dependency-typing artifacts* section recording the group table with each discriminant and mechanism, the per-rule residual next to the commit it was measured at, and the commands that reproduce the split. The existing per-rule tables and sampling record are untouched; the recorded-baseline section gains a pointer, since its 3,926 figure now describes a configuration that has moved. **Reproducing the claim** (the manifest is a build artifact, not a committed file): ```bash # Capture the base run first: the classifier does not exist at the merge base. git switch --detach $(git merge-base HEAD origin/main) ty check --output-format concise > /tmp/ty-base.txt git switch - python3 scripts/classify_ty_diagnostics.py baseline --from /tmp/ty-base.txt \ --out /tmp/ty-baseline.json python3 scripts/classify_ty_diagnostics.py check --baseline /tmp/ty-baseline.json ``` Measured at merge-base 8ab1800 with ty 0.0.49: 4,072 → 3,626, all 446 removed rows are warnings, all 366 `error`-severity diagnostics survive untouched, and `check` exits 0 with an empty `RETAINED` list. **What is *not* neutralized.** The sqlmodel-vs-sqlalchemy `AsyncSession` mismatch — 201 `invalid-argument-type` hits expecting `sqlmodel.ext.asyncio.session.AsyncSession` and finding the `sqlalchemy` one — is first-party and stays reportable. The two classes are not parallel declarations by two libraries: the sqlmodel class subclasses the sqlalchemy one, adding `exec`, and `app/core/db/crud.py:40` imports the subclass so `BaseManager` can call `session.exec(...)` at `app/core/db/crud.py:227`. A value typed as the supertype cannot satisfy a parameter requiring the subtype, so ty is right; every hit arises where a test file or helper annotates its own `session` parameter with the sqlalchemy import.
1 parent bd0c69e commit 112a71e

33 files changed

Lines changed: 1849 additions & 231 deletions

app/celery.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
celery = Celery("sep", **settings.CELERY.model_dump())
4343

4444
celery.loop = asyncio.new_event_loop()
45-
asyncio.set_event_loop(celery.loop)
45+
asyncio.set_event_loop(celery.loop) # ty: ignore[unresolved-attribute]
4646

4747

4848
@setup_logging.connect
@@ -56,7 +56,7 @@ def init_child_event_loop(**kwargs: Any) -> None:
5656
"""Initialize a new event loop for each worker process."""
5757
logger.debug("Initializing new event loop for worker process")
5858
celery.loop = asyncio.new_event_loop()
59-
asyncio.set_event_loop(celery.loop)
59+
asyncio.set_event_loop(celery.loop) # ty: ignore[unresolved-attribute]
6060

6161

6262
CORRELATION_ID_HEADER_KEY = "correlation_id"

app/core/alerts/config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,11 @@ class AlertSettings(BaseYamlSettings):
5050
"""
5151

5252
SETTINGS_PREFIXES: ClassVar[list[str]] = ["ALERTING"]
53-
PROVIDERS: set[BaseAlertProvider] = hot_field(
53+
PROVIDERS: set[BaseAlertProvider] = hot_field( # ty: ignore[invalid-assignment]
5454
set(), materializer=materialize_via_owning_model
5555
)
56-
SOURCE_PREFIX: str = hot_field("")
57-
SOURCE_SUFFIX: str = hot_field("")
56+
SOURCE_PREFIX: str = hot_field("") # ty: ignore[invalid-assignment]
57+
SOURCE_SUFFIX: str = hot_field("") # ty: ignore[invalid-assignment]
5858

5959
@field_validator("PROVIDERS", mode="before")
6060
@classmethod

app/core/config.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -355,12 +355,22 @@ class PMMSettings(BaseLowercaseModel):
355355
"""
356356

357357
endpoint: StrCredentialHttpUrl | None = None
358-
frontend: StrHttpUrl | None = hot_field(None, advanced=True)
358+
frontend: StrHttpUrl | None = hot_field( # ty: ignore[invalid-assignment]
359+
None, advanced=True
360+
)
359361
api_key: SecretStr | None = None
360-
verify_ssl: bool = hot_field(default=True, advanced=True)
361-
execution_target: str | None = hot_field(None, advanced=True)
362-
annotations_enabled: bool = hot_field(default=False, advanced=True)
363-
annotations_timeout: PositiveInt = hot_field(5, advanced=True)
362+
verify_ssl: bool = hot_field( # ty: ignore[invalid-assignment]
363+
default=True, advanced=True
364+
)
365+
execution_target: str | None = hot_field( # ty: ignore[invalid-assignment]
366+
None, advanced=True
367+
)
368+
annotations_enabled: bool = hot_field( # ty: ignore[invalid-assignment]
369+
default=False, advanced=True
370+
)
371+
annotations_timeout: PositiveInt = hot_field( # ty: ignore[invalid-assignment]
372+
5, advanced=True
373+
)
364374

365375
@model_validator(mode="after")
366376
def _default_frontend_to_endpoint(self) -> Self:
@@ -423,7 +433,9 @@ class SettingsOverrideOptions(BaseCaseInsensitiveModel):
423433
seconds=30
424434
)
425435
REFRESHER_ENABLED: bool = True
426-
ALLOWED_KEYS: set[SettingsOverrideKey] | None = not_overridable_field(None)
436+
ALLOWED_KEYS: set[SettingsOverrideKey] | None = ( # ty: ignore[invalid-assignment]
437+
not_overridable_field(None)
438+
)
427439

428440

429441
_REMOVED_SETTINGS_OVERRIDE_KEYS = {
@@ -604,14 +616,14 @@ class Settings(BaseYamlSettings):
604616
ALLOW_CONCURRENT_SESSIONS: bool = False
605617
SECRET_KEY: SecretStr = SecretStr(secrets.token_urlsafe(32))
606618
SEP_INTERNAL_TOKEN: SecretStr | None = None
607-
LOGGING: LogLevel = hot_field(LogLevel.WARNING)
619+
LOGGING: LogLevel = hot_field(LogLevel.WARNING) # ty: ignore[invalid-assignment]
608620
LOGGING_CONFIG: dict[str, Any] = {}
609621
SSL_CAFILE: RelativeFilePathField | None = None
610622
BASE_URL: URL | None = None
611623
BACKEND_CORS_ORIGINS: list[StrHttpUrl] | None = None
612624
ALLOWED_HOSTS: list[str] = []
613625
SECURITY_HEADERS: SecurityHeadersOptions | None = SecurityHeadersOptions()
614-
PMM: PMMSettings = hot_field(PMMSettings())
626+
PMM: PMMSettings = hot_field(PMMSettings()) # ty: ignore[invalid-assignment]
615627
SETTINGS_OVERRIDE: SettingsOverrideOptions = SettingsOverrideOptions()
616628
_CLIENT_REGISTRY: ClientRegistry = ClientRegistry()
617629

app/sep/app_drain.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,9 @@ def record_task_start(task_id: str, task: Any, **_: Any) -> None:
194194
app_key = getattr(task, _OWNER_APP_KEY_ATTR, None)
195195
if app_key is None:
196196
return
197-
celery.loop.run_until_complete(_record_start(app_key, task_id))
197+
celery.loop.run_until_complete( # ty: ignore[unresolved-attribute]
198+
_record_start(app_key, task_id)
199+
)
198200

199201

200202
@task_postrun.connect
@@ -203,13 +205,17 @@ def record_task_end(task_id: str, task: Any, **_: Any) -> None:
203205
app_key = getattr(task, _OWNER_APP_KEY_ATTR, None)
204206
if app_key is None:
205207
return
206-
celery.loop.run_until_complete(_record_end(app_key, task_id))
208+
celery.loop.run_until_complete( # ty: ignore[unresolved-attribute]
209+
_record_end(app_key, task_id)
210+
)
207211

208212

209213
@celery.task
210214
def reconcile_disabling_apps() -> None:
211215
"""Prune orphaned running-task rows and finalize drained apps (safety net)."""
212-
celery.loop.run_until_complete(_reconcile_disabling_apps())
216+
celery.loop.run_until_complete( # ty: ignore[unresolved-attribute]
217+
_reconcile_disabling_apps()
218+
)
213219

214220

215221
async def _reconcile_disabling_apps() -> None:

app/sep/apps/alerts/config.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,11 @@ class AlertsSettings(BaseYamlSettings):
4141
"""
4242

4343
SETTINGS_PREFIXES: ClassVar[list[str]] = ["SEP", "ALERTS"]
44-
BACKUP_INTERVAL: IntervalSchedule = hot_field(
44+
BACKUP_INTERVAL: IntervalSchedule = hot_field( # ty: ignore[invalid-assignment]
4545
IntervalSchedule(every=24, period=Period.HOURS)
4646
)
47-
BACKUP_RETENTION: PositiveInt = hot_field(10)
48-
ALERT_FOLDER_NAME: str = hot_field("SEP Alerts")
47+
BACKUP_RETENTION: PositiveInt = hot_field(10) # ty: ignore[invalid-assignment]
48+
ALERT_FOLDER_NAME: str = hot_field("SEP Alerts") # ty: ignore[invalid-assignment]
4949

5050

5151
alerts_settings: AlertsSettings = OverridableSettingsProxy(

app/sep/apps/inventory/config.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,20 @@ class InventoryAppSettings(BaseYamlSettings):
5959
"""
6060

6161
SETTINGS_PREFIXES: ClassVar[list[str]] = ["SEP", "INVENTORY"]
62-
COLLECTION_INTERVAL: ManageableInterval | None = hot_field(None)
63-
COLLECTION_RETENTION: Annotated[timedelta, Gt(timedelta(0))] = hot_field(
64-
timedelta(days=30)
62+
COLLECTION_INTERVAL: ManageableInterval | None = ( # ty: ignore[invalid-assignment]
63+
hot_field(None)
64+
)
65+
COLLECTION_RETENTION: Annotated[
66+
timedelta, Gt(timedelta(0))
67+
] = ( # ty: ignore[invalid-assignment]
68+
hot_field(timedelta(days=30))
69+
)
70+
COLLECTION_BATCH_SIZE: PositiveInt = hot_field( # ty: ignore[invalid-assignment]
71+
500, advanced=True
72+
)
73+
COLLECTION_MAX_BATCHES: PositiveInt = hot_field( # ty: ignore[invalid-assignment]
74+
20, advanced=True
6575
)
66-
COLLECTION_BATCH_SIZE: PositiveInt = hot_field(500, advanced=True)
67-
COLLECTION_MAX_BATCHES: PositiveInt = hot_field(20, advanced=True)
6876

6977

7078
inventory_app_settings: InventoryAppSettings = OverridableSettingsProxy(

app/sep/config.py

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -471,8 +471,12 @@ class DeliveryPlanInputs(BaseModel):
471471
:param secrets: Values for the secret names the baked plan declares.
472472
"""
473473

474-
endpoint: CredentialHttpUrl | None = not_overridable_field(None)
475-
secrets: dict[str, SecretStr] = not_overridable_field({})
474+
endpoint: CredentialHttpUrl | None = ( # ty: ignore[invalid-assignment]
475+
not_overridable_field(None)
476+
)
477+
secrets: dict[str, SecretStr] = ( # ty: ignore[invalid-assignment]
478+
not_overridable_field({})
479+
)
476480

477481
@field_validator("secrets")
478482
@classmethod
@@ -633,16 +637,22 @@ class SEPSettings(BaseYamlAppSettings):
633637
SETTINGS_PREFIXES: ClassVar[list[str]] = ["SEP"]
634638
UVICORN_PORT: int = 8000
635639
ROOT_PATH: URIPathPrefix = ""
636-
SESSION_REFRESH: CookieOptions = nested_overridable_field(
637-
CookieOptions(
638-
COOKIE_NAME="refreshToken",
639-
PATH="/api/oauth",
640-
),
641-
advanced=True,
640+
SESSION_REFRESH: CookieOptions = ( # ty: ignore[invalid-assignment]
641+
nested_overridable_field(
642+
CookieOptions(
643+
COOKIE_NAME="refreshToken",
644+
PATH="/api/oauth",
645+
),
646+
advanced=True,
647+
)
642648
)
643649
ALERT_DEFINITIONS_DIR: RelativeDirectoryPathField | None = None
644-
INVENTORY_ENDPOINT: CredentialHttpUrl = hot_field(..., advanced=True)
645-
TASKS_ENDPOINT: CredentialHttpUrl = hot_field(..., advanced=True)
650+
INVENTORY_ENDPOINT: CredentialHttpUrl = hot_field( # ty: ignore[invalid-assignment]
651+
..., advanced=True
652+
)
653+
TASKS_ENDPOINT: CredentialHttpUrl = hot_field( # ty: ignore[invalid-assignment]
654+
..., advanced=True
655+
)
646656
APPS: UniqueList[App] = Field(
647657
default_factory=UniqueList,
648658
validation_alias=AliasChoices("APPS", "PLUGINS"),
@@ -651,21 +661,29 @@ class SEPSettings(BaseYamlAppSettings):
651661
DATABASE: DatabaseOptions = DatabaseOptions(NAME="sep.db")
652662
SYNCERS: UniqueList[SyncOptions] = UniqueList()
653663
SYNCER_EXTRA_KWARGS: SyncerExtraKwargs = SyncerExtraKwargs()
654-
SYNC_REFRESH_TIME: int = hot_field(5)
655-
DIAGNOSTICS_DELIVERY: DeliveryPlan | None = not_overridable_field(
656-
None, advanced=True
664+
SYNC_REFRESH_TIME: int = hot_field(5) # ty: ignore[invalid-assignment]
665+
DIAGNOSTICS_DELIVERY: DeliveryPlan | None = ( # ty: ignore[invalid-assignment]
666+
not_overridable_field(None, advanced=True)
667+
)
668+
DIAGNOSTICS_DELIVERY_INPUTS: (
669+
DeliveryPlanInputs | None
670+
) = ( # ty: ignore[invalid-assignment]
671+
hot_field(None, materializer=materialize_delivery_plan_inputs, advanced=True)
657672
)
658-
DIAGNOSTICS_DELIVERY_INPUTS: DeliveryPlanInputs | None = hot_field(
659-
None, materializer=materialize_delivery_plan_inputs, advanced=True
673+
APP_DRAIN: AppDrainSettings = ( # ty: ignore[invalid-assignment]
674+
nested_overridable_field(AppDrainSettings())
675+
)
676+
ARTIFACT_DOWNLOAD_TTL: PositiveInt = hot_field( # ty: ignore[invalid-assignment]
677+
600, advanced=True
660678
)
661-
APP_DRAIN: AppDrainSettings = nested_overridable_field(AppDrainSettings())
662-
ARTIFACT_DOWNLOAD_TTL: PositiveInt = hot_field(600, advanced=True)
663679
# Plain fields rather than ``hot_field``: the readiness gate runs in a
664680
# pre-fork beat child, before the DB override refresher exists to serve one.
665681
API_READINESS_TIMEOUT: PositiveFloat = DEFAULT_API_READINESS_TIMEOUT
666682
API_READINESS_POLL_INTERVAL: PositiveFloat = DEFAULT_API_READINESS_POLL_INTERVAL
667-
CONNECTIVITY_CHECK_DEFAULT: bool = hot_field(default=False)
668-
AMBIENT_SESSION_SSO_ENABLED: bool = hot_field(
683+
CONNECTIVITY_CHECK_DEFAULT: bool = hot_field( # ty: ignore[invalid-assignment]
684+
default=False
685+
)
686+
AMBIENT_SESSION_SSO_ENABLED: bool = hot_field( # ty: ignore[invalid-assignment]
669687
default=False,
670688
description=(
671689
"Enable ambient Grafana-session SSO: sign an unauthenticated caller "
@@ -675,7 +693,7 @@ class SEPSettings(BaseYamlAppSettings):
675693
"the browser sends the session cookie to SEP."
676694
),
677695
)
678-
FOOTER_TEMPLATE: Template = hot_field(
696+
FOOTER_TEMPLATE: Template = hot_field( # ty: ignore[invalid-assignment]
679697
Template("$summary $version"),
680698
materializer=materialize_template,
681699
advanced=True,

app/sep/snippets/celery.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@
5555
@celery.task
5656
def sync_snippets() -> None:
5757
"""Define Celery task to sync snippets from `sep_setting.SNIPPETS.SNIPPETS_DIR`."""
58-
celery.loop.run_until_complete(update_snippets())
58+
celery.loop.run_until_complete( # ty: ignore[unresolved-attribute]
59+
update_snippets()
60+
)
5961

6062

6163
async def update_snippets() -> None:

app/sep/snippets/config.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -398,23 +398,29 @@ class SnippetsSettings(BaseYamlSettings):
398398
# stores it as ``{"_url": ...}`` rather than a plain string, which then
399399
# coerces back into a broken ``URL``. Persist the raw string and
400400
# re-materialize through the owning model on load instead.
401-
SNIPPETS_BASE_URL: URL | None = hot_field(
401+
SNIPPETS_BASE_URL: URL | None = hot_field( # ty: ignore[invalid-assignment]
402402
None, materializer=materialize_via_owning_model
403403
)
404404
META: SnippetsMetaOptions = SnippetsMetaOptions()
405-
SYNC_FILTER: set[SnippetFilter] | None = hot_field(None)
405+
SYNC_FILTER: set[SnippetFilter] | None = ( # ty: ignore[invalid-assignment]
406+
hot_field(None)
407+
)
406408
INTERPRETERS: OrderedDict[SnippetFilter, SnippetInterpreterConfig] = (
407409
DEFAULT_INTERPRETERS
408410
)
409411
USE_MAGIC: bool = False
410-
SYNC_INTERVAL: IntervalSchedule = hot_field(
412+
SYNC_INTERVAL: IntervalSchedule = hot_field( # ty: ignore[invalid-assignment]
411413
IntervalSchedule(every=1, period=Period.HOURS)
412414
)
413-
ENABLE_MANUAL_SYNC: bool = hot_field(default=False)
414-
AUTO_APPROVE_BUILTIN_SNIPPETS: bool = hot_field(default=True)
415+
ENABLE_MANUAL_SYNC: bool = hot_field( # ty: ignore[invalid-assignment]
416+
default=False
417+
)
418+
AUTO_APPROVE_BUILTIN_SNIPPETS: bool = hot_field( # ty: ignore[invalid-assignment]
419+
default=True
420+
)
415421
SYNC_ON_STARTUP: bool = True
416-
PREVIEW_MAX_CHARS: PositiveInt = hot_field(10000)
417-
PREVIEW_MAX_LINES: PositiveInt = hot_field(500)
422+
PREVIEW_MAX_CHARS: PositiveInt = hot_field(10000) # ty: ignore[invalid-assignment]
423+
PREVIEW_MAX_LINES: PositiveInt = hot_field(500) # ty: ignore[invalid-assignment]
418424

419425
@model_validator(mode="before")
420426
@classmethod

app/sep/snippets/models/snippet.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -883,7 +883,7 @@ class Snippet(BaseSnippet, BaseSQLModel, table=True):
883883

884884
__table_args__ = (Index("ix_snippet_filename", "filename", unique=True),)
885885
approved_at: UTCDatetime | None = SQLField(
886-
sa_type=DateTimeWithTimezone,
886+
sa_type=DateTimeWithTimezone, # ty: ignore[invalid-argument-type]
887887
default=None,
888888
index=True,
889889
)

0 commit comments

Comments
 (0)