@suss/adapter-python 0.3.2-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.
@@ -0,0 +1,459 @@
1
+ import { TypeShape, BehavioralSummary } from '@suss/behavioral-ir';
2
+ import { Tree, Node } from 'web-tree-sitter';
3
+ export { Node as PyNode, Tree as PyTree } from 'web-tree-sitter';
4
+ import { ExtractorOptions, RawCodeStructure } from '@suss/extractor';
5
+ import { Database } from '@suss/datalog';
6
+
7
+ /**
8
+ * A new `Parser` for every call, so that parses running at the same time do
9
+ * not fight over one parser's mutable `language` field. The compiled `Language`
10
+ * is loaded once per process and shared.
11
+ */
12
+ declare function parsePython(source: string): Promise<Tree>;
13
+
14
+ /**
15
+ * The lexical binder. It builds module, class, and function scopes over one
16
+ * file's parse tree and records how each name in each scope came to be bound,
17
+ * whether that was an import, an assignment, a def, a parameter, or a `global`
18
+ * or `nonlocal` redirect to another scope.
19
+ *
20
+ * It never has to be complete, because "I could not resolve this name" is a
21
+ * legal answer everywhere a name is read. What it has to avoid is being wrong.
22
+ * So it only binds names written directly in a body's own statement list. A
23
+ * definition or import nested inside an `if`, `try`, or `with` block is not
24
+ * found, and a read of it comes back unresolved rather than as a guess.
25
+ */
26
+
27
+ type ScopeKind = "module" | "class" | "function";
28
+ interface Scope {
29
+ kind: ScopeKind;
30
+ /** The module node, or the class_definition / function_definition this scope belongs to. */
31
+ node: Node;
32
+ parent: Scope | null;
33
+ bindings: Map<string, Binding>;
34
+ }
35
+ /**
36
+ * `relativeLevel` is 0 for an absolute import and the dot count for a
37
+ * relative one. `module` is the dotted path after the dots, empty for
38
+ * a bare `from . import c`.
39
+ */
40
+ type Binding = {
41
+ kind: "import";
42
+ module: string;
43
+ relativeLevel: number;
44
+ localName: string;
45
+ } | {
46
+ kind: "importFrom";
47
+ module: string;
48
+ relativeLevel: number;
49
+ importedName: string;
50
+ } | {
51
+ kind: "classDef";
52
+ node: Node;
53
+ } | {
54
+ kind: "functionDef";
55
+ node: Node;
56
+ } | {
57
+ kind: "parameter";
58
+ }
59
+ /** The right-hand side, when there is one, so we can trace a decorator's base object one hop back to whatever constructed it. */
60
+ | {
61
+ kind: "assignment";
62
+ value: Node | null;
63
+ }
64
+ /** `global x` inside a function: reads of `x` in this scope resolve in the module scope instead. */
65
+ | {
66
+ kind: "global";
67
+ }
68
+ /** `nonlocal x` inside a function: reads of `x` resolve in the nearest enclosing function scope. */
69
+ | {
70
+ kind: "nonlocal";
71
+ };
72
+ interface ModuleBinding {
73
+ moduleScope: Scope;
74
+ /** The scope a class_definition, a function_definition, or the module node opens, keyed by that node's id. */
75
+ scopeFor: Map<number, Scope>;
76
+ /** The modules a `from X import *` pulls in. We cannot list what a wildcard brings in, so nothing expands them here. */
77
+ openImports: string[];
78
+ }
79
+ declare function bindModule(root: Node): ModuleBinding;
80
+ /**
81
+ * Python does not put a class body's namespace on the lookup chain of a function
82
+ * nested inside it, so a class scope's own bindings only count when the search
83
+ * starts there. A `global` or `nonlocal` marker sends the lookup to the scope it
84
+ * points at.
85
+ */
86
+ declare function resolveName(scope: Scope, name: string): Binding | null;
87
+
88
+ /**
89
+ * Turns a Python annotation into an IR type shape.
90
+ *
91
+ * Nothing here infers anything. A value nobody annotated has no shape at all,
92
+ * and an annotation this module does not recognize comes back as a `ref` by
93
+ * name, which says only what the source called it.
94
+ */
95
+
96
+ /**
97
+ * `definitions` stores each converted class shape once, however many annotations
98
+ * mention it. `scopeFor` is here so a name written inside a referenced class's
99
+ * body resolves too, not only one written at the annotation's use site.
100
+ */
101
+ interface AnnotationContext {
102
+ scopeFor: Map<number, Scope>;
103
+ definitions: Map<string, TypeShape | null>;
104
+ }
105
+ /** `scope` is where the annotation is written, which is how we tell a project-local class from an external name. */
106
+ declare function annotationToShape(typeNode: Node, scope: Scope, ctx: AnnotationContext): TypeShape;
107
+ /** Exported so a decorator keyword that gives a class name reads the same way as that name written in annotation position. */
108
+ declare function shapeFromName(name: string, scope: Scope, ctx: AnnotationContext): TypeShape;
109
+
110
+ /**
111
+ * Small helpers for reading a tree-sitter-python parse tree.
112
+ *
113
+ * The node-type strings here come from tree-sitter-python's grammar, at the
114
+ * version the grammar README gives. Read a node through these helpers rather
115
+ * than calling `childForFieldName` or `.type` inline, so that a grammar upgrade
116
+ * that renames a field only has to be fixed in one place.
117
+ */
118
+
119
+ interface Range {
120
+ start: number;
121
+ end: number;
122
+ }
123
+
124
+ type DecoratorArg = {
125
+ kind: "string";
126
+ value: string;
127
+ } | {
128
+ kind: "number";
129
+ value: number;
130
+ } | {
131
+ kind: "boolean";
132
+ value: boolean;
133
+ } | {
134
+ kind: "none";
135
+ }
136
+ /** A bare name. The caller resolves it, using the scope it already has. */
137
+ | {
138
+ kind: "identifier";
139
+ name: string;
140
+ } | {
141
+ kind: "list";
142
+ items: DecoratorArg[];
143
+ } | {
144
+ kind: "other";
145
+ };
146
+ interface DecoratorClassification {
147
+ /** The name as its source module exports it, not the local alias or attribute path it was written under. */
148
+ importedName: string | null;
149
+ /** The dotted module the name was imported from. It is null whenever `importedName` is null. */
150
+ module: string | null;
151
+ /** The local variable an attribute decorator hangs on, `app` in `@app.get(...)`. */
152
+ objectName: string | null;
153
+ relativeLevel: number;
154
+ args: DecoratorArg[];
155
+ keywordArgs: Record<string, DecoratorArg>;
156
+ /** Where the decorator is written. Anything we read out of its arguments uses this as its provenance. */
157
+ range: Range;
158
+ }
159
+ declare function classifyDecorator(decoratorNode: Node, scope: Scope): DecoratorClassification;
160
+
161
+ /**
162
+ * The Python adapter's own pattern-pack contract.
163
+ *
164
+ * This is deliberately not the TypeScript adapter's `PatternPack`. That type
165
+ * dispatches its discovery variants through ts-morph-specific handlers, and
166
+ * Python's two route shapes have no exact match in its union anyway. Match
167
+ * shapes stay per-language until a second implementation shows what is actually
168
+ * shared, and this is the per-language one for Python.
169
+ *
170
+ * A pack is still plain data, following the same rule the TypeScript packs
171
+ * follow: it describes what a library defines, never anything a project chose.
172
+ */
173
+ interface PythonPack {
174
+ name: string;
175
+ /** Wire protocol for the produced boundary bindings, e.g. "http". */
176
+ protocol: string;
177
+ discovery: PythonDiscoveryPattern[];
178
+ }
179
+ type PythonDiscoveryPattern = DecoratedClassRoute | DecoratedFunctionRoute;
180
+ /** Conventions both kinds of route share. Each one describes what the library does, never a project's choice. */
181
+ interface RouteConventions {
182
+ /** How the library spells a path parameter. The README lists the syntaxes we know how to read. */
183
+ pathParamSyntax?: string;
184
+ /** Set it only when the library itself binds an annotated local class to the request body. */
185
+ annotatedClassIsRequestBody?: boolean;
186
+ /**
187
+ * Callables the library uses to inject a parameter rather than read it
188
+ * off the request. FastAPI's `Depends` and `Security` are these: the
189
+ * server supplies the value and the client sends nothing, so a
190
+ * parameter defaulted to one of them is no part of the request however
191
+ * its annotation reads.
192
+ */
193
+ injectedParameterCallees?: string[];
194
+ /**
195
+ * What the library serves when a composed path ends up with repeated
196
+ * slashes in it. Werkzeug serves the merged path and redirects
197
+ * the written one, so "merged" is what Flask needs; "kept" is the
198
+ * default and is what Starlette does.
199
+ */
200
+ pathRepeatedSlashes?: PathRepeatedSlashes;
201
+ /** The status the library returns for a declared response when the route does not give one. Library-defined. */
202
+ defaultStatusCode?: number;
203
+ /**
204
+ * Set it only when the library reads a status out of the tuple a handler
205
+ * returns, which is what Flask does with `return body, 201`. Without it
206
+ * the library default applies whatever the body returns, and a route
207
+ * that sets its own status would be reported at the default.
208
+ */
209
+ statusFromReturnedTuple?: boolean;
210
+ /** Unset means the library has no router mounting, and a route's decorator path stands as written. */
211
+ routerComposition?: RouterComposition;
212
+ }
213
+ /**
214
+ * A class has a decorator whose first string-literal argument is the route path,
215
+ * and each method in its body named after an HTTP verb becomes its own unit,
216
+ * with the verb taken from the method name.
217
+ */
218
+ interface DecoratedClassRoute extends RouteConventions {
219
+ type: "decoratedClassRoute";
220
+ /** The library's own module, plus any wrapper module the person configuring the pack lists alongside it. */
221
+ importModule: string[];
222
+ /** The decorator's name as the library exports it, "route" for flask-restx's `Namespace.route`. */
223
+ decoratorName: string;
224
+ /** Method name written in the class body, mapped to the HTTP verb it dispatches. */
225
+ verbMethodNames: Record<string, string>;
226
+ }
227
+ /**
228
+ * A function has a decorator whose attribute name is the HTTP verb and whose
229
+ * first string-literal argument is the route path, the `@app.get(path)`
230
+ * convention. The object it hangs on may be imported, or built one hop away by
231
+ * a call to something imported.
232
+ */
233
+ interface DecoratedFunctionRoute extends RouteConventions {
234
+ type: "decoratedFunctionRoute";
235
+ importModule: string[];
236
+ /** Decorator attribute name, mapped to the HTTP verb it means. */
237
+ verbAttributeNames: Record<string, string>;
238
+ /** Unset leaves the return annotation as the only source for the response shape. */
239
+ responseModelKeyword?: string;
240
+ statusCodeKeyword?: string;
241
+ }
242
+ /**
243
+ * What a library calls the pieces of router mounting, so a route's served path
244
+ * can be built from the literal prefixes written along the way: the router
245
+ * constructor's own, the one at the call that mounts it, and, where the pack
246
+ * says so, the prefix on the object the mount is called on. What each spelling
247
+ * of a prefix means, and what makes a composition abstain, is the grid in the
248
+ * adapter's README.
249
+ */
250
+ interface RouterComposition {
251
+ /** Constructor whose call builds a mountable router, FastAPI's `APIRouter`. */
252
+ routerConstructorName: string;
253
+ /** Method that mounts a router onto the app, FastAPI's `include_router`. */
254
+ includeMethodName: string;
255
+ /** One keyword serves the constructor and the mount alike. A library that spells them apart needs two fields here. */
256
+ prefixKeyword: string;
257
+ /** Default "prefixes". */
258
+ mountPrefixEffect?: MountPrefixEffect;
259
+ /** Set it when the library works out a path for a router that gives no prefix. We cannot work that path out, so such routes abstain. */
260
+ constructorPrefixRequired?: boolean;
261
+ /** Default "unreadable". The same setting covers the constructor and the mount. */
262
+ noValuePrefix?: NoValuePrefix;
263
+ /** Default "kept". */
264
+ constructorPrefixTrailingSlash?: PrefixTrailingSlash;
265
+ /**
266
+ * Where the object the mount is called on states a prefix of its
267
+ * own, in front of everything the constructor and the mount state.
268
+ * Unset means it states none, which is FastAPI's behavior: an app
269
+ * serves a mounted router exactly where the two prefixes put it.
270
+ */
271
+ mountObjectPrefix?: MountObjectPrefix;
272
+ }
273
+ /**
274
+ * The prefix the object a mount is called on states, and where it is
275
+ * written. flask-restx needs both halves: `Api(prefix=...)` states one
276
+ * on the object itself, and the Flask blueprint the `Api` was built
277
+ * from states another with `Blueprint(name, __name__,
278
+ * url_prefix=...)`. The library serves a route under the blueprint's
279
+ * prefix, then the `Api`'s, then whatever the namespace and the route
280
+ * say.
281
+ */
282
+ interface MountObjectPrefix {
283
+ /** Keyword stating a prefix on the mount object's own construction (flask-restx's `Api(prefix=...)`). */
284
+ prefixKeyword?: string;
285
+ /** The object handed to that construction which states a prefix of its own. */
286
+ carrier?: MountObjectCarrier;
287
+ }
288
+ /**
289
+ * An object handed to the mount object's constructor, one hop further
290
+ * out, with a prefix of its own (the Flask blueprint behind an
291
+ * `Api`). Naming it here is what lets the adapter tell that object
292
+ * apart from the plain app that appears in the same argument position and
293
+ * has no prefix at all.
294
+ */
295
+ interface MountObjectCarrier {
296
+ /** Modules the carrier's constructor is imported from (Flask's `flask`). */
297
+ importModule: string[];
298
+ /** Constructor building the carrier, as its library exports it (Flask's `Blueprint`). */
299
+ constructorName: string;
300
+ /** Position of the carrier among the mount object's constructor arguments. */
301
+ argumentIndex: number;
302
+ /** Keyword stating the carrier's prefix, at its construction and at its registration alike (Flask's `url_prefix`). */
303
+ prefixKeyword: string;
304
+ /**
305
+ * Method handing the carrier to an already-built mount object
306
+ * (flask-restx's `init_app`, the application-factory spelling of
307
+ * `Api(blueprint)`). Unset means the constructor argument is the
308
+ * only way in.
309
+ */
310
+ handoffMethodName?: string;
311
+ /**
312
+ * Method registering the carrier somewhere else (Flask's
313
+ * `register_blueprint`). The adapter reads it only to abstain: a
314
+ * registration restating the prefix, putting the carrier inside
315
+ * another carrier, or happening twice moves the served path
316
+ * somewhere the carrier's own construction no longer says.
317
+ */
318
+ registerMethodName: string;
319
+ }
320
+ type MountPrefixEffect = "prefixes" | "replaces";
321
+ type PrefixTrailingSlash = "kept" | "trimmed";
322
+ type NoValuePrefix = "unstated" | "unreadable";
323
+ /**
324
+ * Werkzeug serves the merged path and redirects the written one, so Flask
325
+ * needs "merged". Starlette leaves the path as composed, so FastAPI keeps
326
+ * the default.
327
+ */
328
+ type PathRepeatedSlashes = "kept" | "merged";
329
+
330
+ /**
331
+ * Maps a dotted import onto the file in the repo it refers to.
332
+ *
333
+ * The search walks a small set of configured roots. Finding out what `sys.path`
334
+ * actually is would mean running Python, so when more than one root offers a
335
+ * candidate, the resolver abstains rather than picking one.
336
+ */
337
+ interface RelativeModuleSpec {
338
+ /** The dotted path after the leading dots. It is empty for a bare `from . import x`. */
339
+ module: string;
340
+ /** Dot count: 0 means absolute, 1 is `.`, 2 is `..`, and so on. */
341
+ relativeLevel: number;
342
+ }
343
+ type ModuleResolution = {
344
+ status: "resolved";
345
+ file: string;
346
+ } | {
347
+ status: "unresolved";
348
+ /**
349
+ * "external": no configured root has a file for it. "ambiguous": more
350
+ * than one does. "outsideRoots": a relative import had enough dots to walk
351
+ * up past every configured root.
352
+ */
353
+ reason: "external" | "ambiguous" | "outsideRoots";
354
+ };
355
+ interface ModuleResolverOptions {
356
+ /** The directories an absolute dotted import is resolved against. We cannot see `sys.path` order, since that only exists at runtime. */
357
+ roots: string[];
358
+ }
359
+ declare function resolveAbsoluteModule(dotted: string, options: ModuleResolverOptions): ModuleResolution;
360
+ /**
361
+ * The search starts in the importing file's own directory, because Python
362
+ * resolves a relative import against `__package__` and not against `sys.path`.
363
+ * `options.roots` only limits how far the dots can walk up, so a deeply relative
364
+ * import cannot land in an unrelated checkout.
365
+ */
366
+ declare function resolveRelativeModule(importingFile: string, spec: RelativeModuleSpec, options: ModuleResolverOptions): ModuleResolution;
367
+ declare function resolveModule(importingFile: string, spec: RelativeModuleSpec, options: ModuleResolverOptions): ModuleResolution;
368
+
369
+ /** One file, already parsed and bound. `buildRouterIndex` takes a project as a list of these. */
370
+ interface BoundPythonFile {
371
+ /** The absolute path, which is what module resolution joins on. */
372
+ file: string;
373
+ /** The path a gap refers to this file by, which a reader has to be able to open. */
374
+ displayPath: string;
375
+ root: Node;
376
+ module: ModuleBinding;
377
+ }
378
+ /**
379
+ * What the object a decorator hangs on turns out to be. `notRouter` covers the
380
+ * app itself and anything the index never saw constructed, so the decorator's
381
+ * own path stands as written. `composed` gives the prefix to put in front of
382
+ * that path. An `abstain` reason is written to follow "the router this route is
383
+ * declared on ...".
384
+ */
385
+ type RoutePrefixResolution = {
386
+ kind: "notRouter";
387
+ } | {
388
+ kind: "composed";
389
+ value: string;
390
+ } | {
391
+ kind: "abstain";
392
+ reason: string;
393
+ };
394
+ interface RouterIndex {
395
+ resolve(pattern: PythonDiscoveryPattern, module: ModuleBinding, objectName: string): RoutePrefixResolution;
396
+ }
397
+ declare function buildRouterIndex(files: BoundPythonFile[], packs: PythonPack[], resolverOptions: ModuleResolverOptions): RouterIndex;
398
+
399
+ /**
400
+ * Finds decorated routes and turns each one into a `RawCodeStructure`.
401
+ *
402
+ * Only decorated module-level functions and classes that are declared
403
+ * unconditionally get discovered, the same boundary the binder in scope.ts
404
+ * draws. Nothing here reads into a unit's body, so a route's summary comes out
405
+ * with no branches at all, or with exactly one branch describing what an
406
+ * annotation or a decorator keyword already states, such as a FastAPI
407
+ * `response_model` or `status_code`, or a return annotation.
408
+ *
409
+ * That is enough to pair a route against a caller by method and path, and it
410
+ * claims nothing about behavior nobody read.
411
+ */
412
+
413
+ interface DiscoveryOptions {
414
+ packs: PythonPack[];
415
+ /** Repo-relative or absolute path recorded on each summary's `location.file`. */
416
+ filePath: string;
417
+ /** Without it, every route object looks like the app itself and paths stand as written. */
418
+ routerIndex?: RouterIndex;
419
+ /** Under "strict" a route whose unit cannot be built stops the run instead of abstaining. */
420
+ gapHandling?: ExtractorOptions["gapHandling"];
421
+ }
422
+ declare function discoverUnits(root: Node, module: ModuleBinding, options: DiscoveryOptions): RawCodeStructure[];
423
+
424
+ /**
425
+ * The name is part of the key because the range is measured in lines, two
426
+ * units can start on the same line, and `entry` is a set, so keying on the range
427
+ * alone would drop one of them.
428
+ */
429
+ declare function unitKey(filePath: string, range: {
430
+ start: number;
431
+ end: number;
432
+ }, name: string): string;
433
+ declare function emitEntryFact(db: Database, filePath: string, range: {
434
+ start: number;
435
+ end: number;
436
+ }, name: string): void;
437
+ /** Module scope only. A function- or class-scoped import is not recorded. */
438
+ declare function emitModuleImportFacts(db: Database, filePath: string, module: ModuleBinding, resolverOptions: ModuleResolverOptions): void;
439
+
440
+ interface ExtractPythonOptions {
441
+ /** Absolute paths of the files to parse and extract. */
442
+ files: string[];
443
+ packs: PythonPack[];
444
+ /** Directories an absolute import is resolved against. */
445
+ roots: string[];
446
+ /** When set, `location.file` on each summary is relativized against this. */
447
+ workspaceRoot?: string;
448
+ /** As well as deciding how much of what nobody could read reaches a summary, "strict" lets a route that cannot be built stop the run. */
449
+ gapHandling?: ExtractorOptions["gapHandling"];
450
+ }
451
+ interface ExtractPythonResult {
452
+ summaries: BehavioralSummary[];
453
+ facts: Database;
454
+ }
455
+ declare function extractPythonProject(options: ExtractPythonOptions): Promise<ExtractPythonResult>;
456
+ /** Every `.py` file under `root`, depth-first, skipping the usual non-source directories. */
457
+ declare function findPythonFiles(root: string): string[];
458
+
459
+ export { type AnnotationContext, type Binding, type BoundPythonFile, type DecoratedClassRoute, type DecoratedFunctionRoute, type DecoratorArg, type DecoratorClassification, type DiscoveryOptions, type ExtractPythonOptions, type ExtractPythonResult, type ModuleBinding, type ModuleResolution, type ModuleResolverOptions, type MountObjectCarrier, type MountObjectPrefix, type MountPrefixEffect, type NoValuePrefix, type PathRepeatedSlashes, type PrefixTrailingSlash, type PythonDiscoveryPattern, type PythonPack, type RelativeModuleSpec, type RouteConventions, type RoutePrefixResolution, type RouterComposition, type RouterIndex, type Scope, type ScopeKind, annotationToShape, bindModule, buildRouterIndex, classifyDecorator, discoverUnits, emitEntryFact, emitModuleImportFacts, extractPythonProject, findPythonFiles, parsePython, resolveAbsoluteModule, resolveModule, resolveName, resolveRelativeModule, shapeFromName, unitKey };