@descryy/adapter-python 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,9 +1,18 @@
1
1
  {
2
2
  "name": "@descryy/adapter-python",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Python adapter. Parses with Python's own ast in one subprocess (DEC-062) \u2014 no grammar to keep in sync, and annotations arrive as source text.",
6
6
  "license": "UNLICENSED",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/descryhq-wq/descry-adapters.git",
10
+ "directory": "packages/adapter-python"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/descryhq-wq/descry-adapters/issues"
14
+ },
15
+ "homepage": "https://github.com/descryhq-wq/descry-adapters#readme",
7
16
  "engines": {
8
17
  "node": ">=22.5"
9
18
  },
@@ -26,9 +35,9 @@
26
35
  "build:assets": "node -e \"require('fs').copyFileSync('src/extract.py','dist/extract.py')\""
27
36
  },
28
37
  "dependencies": {
29
- "@descryy/adapter-common": "0.1.0",
30
- "@descryy/ir": "0.3.0",
31
- "@descryy/adapter-openapi": "0.1.0",
32
- "@descryy/adapter-sql": "0.1.0"
38
+ "@descryy/adapter-common": "0.2.0",
39
+ "@descryy/ir": "0.5.0",
40
+ "@descryy/adapter-openapi": "0.3.0",
41
+ "@descryy/adapter-sql": "0.3.0"
33
42
  }
34
43
  }
package/src/extract.py CHANGED
@@ -290,6 +290,17 @@ class Collector(ast.NodeVisitor):
290
290
  # Module-level `name = ClassName()`, which is the only inference this
291
291
  # file performs and the only one it can perform without a checker.
292
292
  self.instantiations: list[dict] = []
293
+ # A local variable's *own* type annotation — `org: Organization | None
294
+ # = await get_org_for_user(...)` — as opposed to `instantiations`
295
+ # above, which only sees a bare constructor call on the right-hand
296
+ # side. The ground truth this exists for is exactly this shape: a
297
+ # local carrying its own annotation, assigned from a call whose
298
+ # return type nothing here resolves. Kept as the raw annotation
299
+ # string (not a single type name) so the Node side can run its own
300
+ # `typeNamesIn` + "exactly one candidate" discipline against
301
+ # `Optional[X]` / `X | None` / `Union[X, Y]`, the same as it already
302
+ # does for a parameter's own annotation.
303
+ self.local_annotations: list[dict] = []
293
304
  # Module-level assignments that *might* be type aliases. Classified in
294
305
  # `collect()` rather than here, because rule 4 asks where a constructor
295
306
  # was imported from and the import table is only complete once the whole
@@ -682,6 +693,20 @@ class Collector(ast.NodeVisitor):
682
693
  "line": node.lineno,
683
694
  }
684
695
  )
696
+ # A local variable's own annotation, inside a function (or
697
+ # nested closure) body — `org: Organization | None = await
698
+ # get_org_for_user(...)`. Module-level annotated assignments are
699
+ # already covered by `alias_candidates`/`_alembic_revision`; this
700
+ # is specifically the local-scope case those do not read.
701
+ if self._scope:
702
+ self.local_annotations.append(
703
+ {
704
+ "local": node.target.id,
705
+ "annotation": _annotation(node.annotation),
706
+ "scope": list(self._scope),
707
+ "line": node.lineno,
708
+ }
709
+ )
685
710
  if not self._scope and node.value is not None:
686
711
  self.alias_candidates.append(
687
712
  {
@@ -1182,6 +1207,80 @@ def _classify_alias(candidate: dict, typing_names: set[str], bound: set[str]) ->
1182
1207
  return None
1183
1208
 
1184
1209
 
1210
+ def _alembic_revision(tree: ast.Module) -> dict | None:
1211
+ """`attrs.migrationRevision` — the revision/down_revision half `_migration_ops`
1212
+ does not read.
1213
+
1214
+ Alembic's generator writes exactly this pair at module scope in every
1215
+ script it produces: `revision = "..."` and `down_revision = "..." | None`.
1216
+ Gated on **both names being assigned at module level**, not just one —
1217
+ a bare `revision = ...` is not rare on its own (a git SHA, an API version)
1218
+ and is not enough by itself to claim this file is a migration; the pair is.
1219
+
1220
+ `down_revision` carries three shapes Alembic itself writes: a single
1221
+ string (one parent), the literal `None` (a root migration — a real,
1222
+ meaningful empty parent, not an unread one), or a tuple/list of strings
1223
+ (a merge revision, several parents). Anything else written there — a
1224
+ computed expression, a name — is reported as the string `"unknown"`
1225
+ rather than guessed at: the same "a name-matched guess is a false claim"
1226
+ rule `descry-core`'s own regex-based migration reader applies to this
1227
+ exact line from the outside (`sources/migrations/dialects.ts`).
1228
+ """
1229
+
1230
+ def string_of(node: ast.AST) -> str | None:
1231
+ return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
1232
+
1233
+ revision: str | None = None
1234
+ has_down_revision = False
1235
+ down_revision: str | list[str] | None = None
1236
+
1237
+ for node in tree.body:
1238
+ target: ast.AST | None
1239
+ value: ast.AST | None
1240
+ if isinstance(node, ast.Assign) and len(node.targets) == 1:
1241
+ target = node.targets[0]
1242
+ value = node.value
1243
+ elif isinstance(node, ast.AnnAssign):
1244
+ # `revision: str = "..."` — a type-annotated assignment. Alembic's
1245
+ # newer templates and mypy-strict repos write the pair this way;
1246
+ # it is semantically the same assignment, just with an annotation
1247
+ # attached. `node.value` is `None` for a bare annotation with no
1248
+ # value (`down_revision: str` alone) — that assigns nothing, so it
1249
+ # must not be treated as `down_revision = None` (a root migration
1250
+ # is a real, distinct fact). Skip the node entirely in that case.
1251
+ if node.value is None:
1252
+ continue
1253
+ target = node.target
1254
+ value = node.value
1255
+ else:
1256
+ continue
1257
+ if not isinstance(target, ast.Name):
1258
+ continue
1259
+
1260
+ if target.id == "revision":
1261
+ revision = string_of(value)
1262
+ continue
1263
+
1264
+ if target.id == "down_revision":
1265
+ has_down_revision = True
1266
+ if isinstance(value, ast.Constant) and value.value is None:
1267
+ down_revision = None
1268
+ else:
1269
+ single = string_of(value)
1270
+ if single is not None:
1271
+ down_revision = single
1272
+ elif isinstance(value, (ast.Tuple, ast.List)):
1273
+ ids = [string_of(elt) for elt in value.elts]
1274
+ down_revision = ids if len(ids) > 0 and all(i is not None for i in ids) else "unknown"
1275
+ else:
1276
+ down_revision = "unknown"
1277
+
1278
+ if revision is None or not has_down_revision:
1279
+ return None
1280
+
1281
+ return {"revision": revision, "downRevision": down_revision}
1282
+
1283
+
1185
1284
  def collect(path: str, source: str, deep_call_modules: frozenset[str] = frozenset()) -> dict:
1186
1285
  tree = ast.parse(source, filename=path)
1187
1286
  collector = Collector(path)
@@ -1235,12 +1334,14 @@ def collect(path: str, source: str, deep_call_modules: frozenset[str] = frozense
1235
1334
 
1236
1335
  env_reads, env_dynamic_reads = _env_reads(tree, collector.imports)
1237
1336
  migration_ops = _migration_ops(tree, collector.imports)
1337
+ migration_revision = _alembic_revision(tree)
1238
1338
 
1239
1339
  return {
1240
1340
  "declarations": collector.declarations,
1241
1341
  "imports": collector.imports,
1242
1342
  "references": collector.references,
1243
1343
  "instantiations": collector.instantiations,
1344
+ "localAnnotations": collector.local_annotations,
1244
1345
  "valueRefs": collector.value_refs,
1245
1346
  "aliases": aliases,
1246
1347
  "calls": collector.calls,
@@ -1250,6 +1351,7 @@ def collect(path: str, source: str, deep_call_modules: frozenset[str] = frozense
1250
1351
  "envReads": env_reads,
1251
1352
  "envDynamicReads": env_dynamic_reads,
1252
1353
  "migrationOps": migration_ops,
1354
+ "migrationRevision": migration_revision,
1253
1355
  }
1254
1356
 
1255
1357