@suss/adapter-python 0.22.4 → 0.24.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/README.md CHANGED
@@ -1,439 +1,47 @@
1
1
  # @suss/adapter-python
2
2
 
3
- The Python language adapter for suss. It parses source with tree-sitter (WASM), resolves names with its own lexical binder, and emits behavioral structure through the same shared assembly layer the TypeScript adapter uses.
3
+ Read a Python codebase with [suss](https://github.com/nimbuscloud-ai/suss).
4
4
 
5
- ## What this package is
5
+ suss reads both sides of every call in a repository and says where the two disagree: a client asking for a field the route stopped returning, a status nobody handles, a queue with no consumer. It reads the code itself, with no model and no network. This package is the part that reads Python: it parses with tree-sitter, binds names the way Python does, and writes down what each route declares and what each handler reaches.
6
6
 
7
- `@suss/adapter-python` is the Python language adapter. It meets the Layer 1 contract in [`docs/internal/facts-and-rules.md`](../../../docs/internal/facts-and-rules.md): discover units, emit summaries in the shared IR, emit facts. It parses a file with `web-tree-sitter` and a vendored Python grammar (`grammar/tree-sitter-python.wasm`, so there is no native build step), builds module, class and function scopes over the tree (imports, assignments, `global`/`nonlocal`), works out which file in the repo an import points at, and finds routes on decorated functions and class methods whose decorator resolves to a module the pack was configured with. When a pack says the library mounts routers (FastAPI's `APIRouter` plus `include_router`), the adapter builds a route's path out of the two literal prefixes, one mount hop deep. When it cannot build the path, the route keeps its name, gets no path, and gets a gap explaining why. Each unit it finds becomes a `RawCodeStructure` object, which it hands to `assembleSummary` in `@suss/extractor`, the same assembly code the TypeScript adapter uses.
7
+ ## Install
8
8
 
9
- v0 (this slice) does nothing with the path engine. A route either has no transitions at all, or one transition that describes the response the code declares (a FastAPI `response_model` / `status_code`, or a return annotation). It never breaks a route into branches. See [`design/proposals/language-adapters.md`](../../../design/proposals/language-adapters.md) for what a later slice adds.
10
-
11
- ## Where it fits in suss
12
-
13
- This package depends on `@suss/extractor` (for `RawCodeStructure` and `assembleSummary`), `@suss/behavioral-ir`, `@suss/datalog` (for the fact database), and `web-tree-sitter`. Framework packs under `packages/framework/*` (`@suss/framework-flask-restx`, `@suss/framework-fastapi`) use its `PythonPack` contract. Nothing in this package knows what any particular library calls its decorators.
14
-
15
- ## Path templates
16
-
17
- A pack declares which syntax its library uses for path parameters, under `pathParamSyntax`. The adapter understands two. `"braces"` covers `{name}` and `{name:converter}`, which is what FastAPI uses via Starlette. `"flaskConverters"` covers `<name>`, `<converter:name>`, and `<converter(arguments):name>`, which is what flask-restx uses via Werkzeug. In both cases the adapter rewrites the path into the IR's plain-brace form and treats the parameters in the template as path parameters.
18
-
19
- If a pack declares nothing, it gets paths exactly as written and no parameter is treated as a path parameter. If a pack declares a syntax the adapter has no reader for, its routes are still discovered, but they come out with no path and a recorded gap. Packs written against 0.3 assumed brace parsing applied to every path, so those packs now have to declare `"braces"` explicitly.
20
-
21
- ## Which expression is the app
22
-
23
- A route is a decorator on something, and whether it counts depends on what that something is. `@app.get("/x")` is a route when `app` was built by calling what FastAPI exports, and nothing at all when `app` is some other object with a `get` method. So discovery has to answer one question per decorator: what built the object this hangs on?
24
-
25
- The lexical binder goes first, and it settles most cases: it follows the name one hop back to the call that built it and reads where that call's constructor was imported from. It is fast, it needs no facts, and it covers the module-level app and the app factory.
26
-
27
- The binder only records a name written directly in a body's own statement list, and it has no idea what an attribute refers to. So these two get nothing out of it:
28
-
29
- ```python
30
- class Holder:
31
- def __init__(self):
32
- self.app = FastAPI()
33
-
34
- def wire(self):
35
- @self.app.get("/health") # the binder has no binding for self.app
36
- def health(): ...
37
-
38
- try:
39
- app = FastAPI()
40
-
41
- @app.get("/health") # the binder skips a name written inside a try
42
- def health(): ...
43
- except RuntimeError:
44
- pass
45
- ```
46
-
47
- Whatever the binder declines goes to the rules in `@suss/resolution`, as `wantedSubject`. They follow the value however it was moved, through a binding, a property read, an import, or an argument, and come back with the call it was written as and where that call's callee came from. The TypeScript adapter asks the same question of the same rules, so a spelling either language learns to follow, both get.
48
-
49
- The rules settle on one construction or on nothing. A name that could be two different constructions comes back with neither, because keying a route on the wrong app is worse than keying it on none, and then the decorator stays unclassified and the route is not discovered.
50
-
51
- Three Python facts feed the question. A method's receiver binds to the class it is declared in, which is what makes `self.app` the value the class puts under `app`. An assignment written inside a method body binds its name, which the class walk used to skip. And an import of a package the repo cannot read still records which module a name came from, which is how `FastAPI()` is told apart from a same-named constructor the project wrote itself.
52
-
53
- ## How a prefix is read
54
-
55
- A mounted route's path comes from prefixes written at up to four places: the object the mount is called on, whatever that object was built from, the router's constructor, and the call that mounts it. All four go through one reader, and a given spelling means the same thing at every one of them. We got this wrong three times by fixing one place or one spelling at a time, so the whole grid is written out here.
56
-
57
- Every cell describes what the library itself does. We read that off the library's source and then confirmed it against a running app, using `url_map` for flask-restx and the route table for FastAPI.
58
-
59
- **At the constructor** (`Namespace(path=...)`, `APIRouter(prefix=...)`):
60
-
61
- | Written | flask-restx serves | FastAPI serves | What suss records |
62
- | -------------------- | -------------------------- | -------------------------- | ---------------------------------------------------------------------------- |
63
- | nothing | `/` + the namespace's name | the route path, no prefix | unstated |
64
- | `"/orders"` | `/orders` + the route path | `/orders` + the route path | stated |
65
- | `"/orders/"` | `/orders` + the route path | the app does not start | stated, trailing slashes trimmed where the pack says so |
66
- | `"/"` | the route path, no prefix | the app does not start | stated, and trimming leaves nothing |
67
- | `""` | `/` + the namespace's name | the route path, no prefix | unstated where the pack says a no-value prefix is unstated, otherwise stated |
68
- | `None`, `False`, `0` | `/` + the namespace's name | the app does not start | unstated where the pack says so, otherwise unreadable |
69
- | a name, a call, or an f-string the evaluator settles | whatever it evaluates to | whatever it evaluates to | stated, with the string it settles on |
70
- | anything the evaluator cannot settle | whatever it evaluates to | whatever it evaluates to | unreadable |
71
-
72
- **At the mount** (`add_namespace(ns, path=...)`, `include_router(router, prefix=...)`):
73
-
74
- | Written | flask-restx serves | FastAPI serves | What suss records |
75
- | -------------------- | ---------------------------------------------------- | -------------------------------------------------- | ----------------------------------------------------- |
76
- | nothing | where the constructor put it | the constructor's prefix + the route path | unstated |
77
- | `"/api"` | `/api` + the route path, replacing the constructor's | `/api` + the constructor's prefix + the route path | stated |
78
- | `"/api/"` | `/api/` + the route path, kept as written | the app does not start | stated |
79
- | `""` | where the constructor put it | the constructor's prefix + the route path | unstated where the pack says so, otherwise stated |
80
- | `None`, `False`, `0` | where the constructor put it | the app does not start | unstated where the pack says so, otherwise unreadable |
81
- | a name, a call, or an f-string the evaluator settles | whatever it evaluates to | whatever it evaluates to | stated, with the string it settles on |
82
- | anything the evaluator cannot settle | whatever it evaluates to | whatever it evaluates to | unreadable |
83
-
84
- The two libraries differ on one property, and the pack says which way each one goes. flask-restx checks whether the path is truthy, so all four no-value spellings mean the same thing as writing nothing, at both places. FastAPI wants a string, so an empty string is an ordinary prefix that happens to add nothing, and the other three stop the app from starting.
85
-
86
- Here is what the reader does with each answer. A stated prefix goes into the path. An unstated one adds nothing, unless the pack says the library makes up a path of its own when the prefix is unstated. In that case the route abstains, because the path it is actually served at is somewhere suss never looked. An unreadable prefix abstains too. A route that abstains keeps its name and gives no path, and the summary says why.
87
-
88
- The trailing-slash rows are not symmetric. flask-restx trims at the constructor and not at the mount, because only the constructor's path goes through the property that strips it. The pack declares trimming per library, and the mount side does not need it, since a mount that states a prefix on that library abstains anyway.
89
-
90
- ### The object the mount is called on
91
-
92
- flask-restx serves a route under the prefix of the object `add_namespace` was called on, ahead of everything the namespace and the route say. That object has two prefixes of its own: one written on it, `Api(prefix=...)`, and one written on the Flask blueprint it was built from, `Blueprint(name, __name__, url_prefix=...)`. The library joins the blueprint's prefix, the `Api`'s, the namespace's path and the route's path by concatenating them and dropping the falsy ones, so nothing is stripped at either of these two sites.
93
-
94
- FastAPI has no column here: its app has no prefix of its own, and the pack declares none, so the object contributes nothing.
95
-
96
- **At the blueprint** (`Blueprint("api", __name__, url_prefix=...)`):
97
-
98
- | Written | flask-restx serves | The reader says |
99
- | --- | --- | --- |
100
- | nothing | the rest of the path, no prefix | unstated |
101
- | `"/api/v1"` | `/api/v1` + the rest | stated |
102
- | `"/api/v1/"` | `/api/v1/` + the rest, and Werkzeug answers the merged `/api/v1/...` | stated, and the composed path is merged |
103
- | `"/"` | the rest of the path, once the merge collapses the doubled slash | stated, and merging leaves nothing |
104
- | `""`, `None` | the rest of the path, no prefix | unstated |
105
- | `False`, `0` | the app does not start: Flask hands the value straight to `rstrip` | unstated |
106
- | a name, a call, or an f-string the evaluator settles | whatever it evaluates to | stated, with the string it settles on |
107
- | anything the evaluator cannot settle | whatever it evaluates to | unreadable |
108
-
109
- **At the `Api`** (`Api(bp, prefix=...)`):
110
-
111
- | Written | flask-restx serves | The reader says |
112
- | --- | --- | --- |
113
- | nothing | the blueprint's prefix + the rest | unstated |
114
- | `"/extra"` | the blueprint's prefix + `/extra` + the rest | stated |
115
- | `"/extra/"` | the same, concatenated as written, and Werkzeug answers the merged path | stated, and the composed path is merged |
116
- | `""`, `None`, `False`, `0` | the blueprint's prefix + the rest | unstated |
117
- | a name, a call, or an f-string the evaluator settles | whatever it evaluates to | stated, with the string it settles on |
118
- | anything the evaluator cannot settle | whatever it evaluates to | unreadable |
119
-
120
- ### A call that spreads a dictionary
121
-
122
- `Api(**build_authorizations())` and `include_router(router, **options)`
123
- spread a value the call does not write out. The reader skips the spread
124
- and reads the keywords that are written, so the routes underneath keep
125
- their paths.
126
-
127
- That leaves one thing it cannot see. If the dictionary sets the prefix
128
- keyword itself, the composed path is wrong, and nothing in the output
129
- says so. Reading the prefix as unknown instead would be correct and would
130
- cost every route under every call that spreads a config dictionary, which
131
- is most of them. Propagating the uncertainty rather than choosing between
132
- the two is the work that would settle this.
133
-
134
- **At the registration** (`app.register_blueprint(bp, ...)`), which the reader consults only to abstain:
135
-
136
- | Written | flask-restx serves | The reader says |
137
- | --- | --- | --- |
138
- | nothing | where the blueprint's own `url_prefix` put it | the reading stands |
139
- | `url_prefix="/over"` | `/over` + the rest, replacing the blueprint's | abstain |
140
- | `url_prefix=""` | the rest, replacing the blueprint's with nothing | abstain |
141
- | `url_prefix=None` | where the blueprint's own `url_prefix` put it | abstain |
142
- | registered on another blueprint | the outer blueprint's prefix in front of everything | abstain |
143
- | registered twice | flask-restx refuses to start | abstain |
144
- | never registered | nothing at all | the reading stands |
145
-
146
- Any spelling of `url_prefix` at the registration abstains, `None` included. The three that are written say different things (`"/over"` replaces, `""` replaces with nothing, `None` falls back to the blueprint's own), so a written keyword there says nothing on its own about where the routes land.
147
-
148
- A registration nobody wrote leaves the reading standing rather than abstaining. A blueprint the run never sees registered is one whose registration might sit in a file outside the run, and reading the prefix it was built with is the same claim the constructor already makes.
149
-
150
- ### Repeated slashes
151
-
152
- A prefix written with a trailing slash leaves the composed path with two. Werkzeug answers such a rule at the merged path and redirects the written one, so `/api/v1//orders` is reached at `/api/v1/orders`; the pack says so, and the reader merges repeated slashes in every path it composes. Starlette does not do this, so FastAPI's pack says nothing and its paths stand as composed.
153
-
154
- ## What a body lowers to
155
-
156
- A route ends where it returns and where it raises, and each of those places is
157
- what makes it produce more than one transition, so the adapter lowers a function
158
- body into the statement form the shared path engine in `@suss/extractor` walks.
159
- That engine is generic over the language's own condition handle and never looks
160
- inside one, so the enumeration, the negation of an earlier arm, and the budget
161
- are all shared with TypeScript.
162
-
163
- | Python | Lowers to |
164
- | --- | --- |
165
- | `if` / `elif` / `else` | one `if` per test, with the elif chain nested into the else arm the way Python reads it |
166
- | `while`, `for` | `loop` |
167
- | `try` / `except` / `finally` | `try`, with every except arm as the catch body |
168
- | `match` / `case` | `switch`, one group per case, `case _` as the default group |
169
- | `return`, `raise` | `exit`, which is what gives each of them its own transition |
170
- | `break`, `continue` | `exit`, which the engine uses for reachability rather than as an outcome |
171
- | anything else | `opaque` |
172
-
173
- A statement's exit kind comes from scanning its own subtree for a return or a
174
- raise, stopping at a nested `def` or `lambda`, because those belong to the
175
- function they declare. A raise anywhere beats a return anywhere.
176
-
177
- So a handler written this way:
178
-
179
- ```python
180
- def get(self, order_id) -> dict:
181
- if not found:
182
- return {"error": "nope"}, 404
183
- return {"a": 1}, 200
9
+ ```bash
10
+ npm install --save-dev @suss/cli
184
11
  ```
185
12
 
186
- comes out as two transitions, the 404 gated on the opaque condition
187
- `not found` and the 200 gated on its negation, rather than as one claim with a
188
- guessed status.
189
-
190
- ### A branch that ends by raising
191
-
192
- Most frameworks let a route end the request by raising rather than by
193
- returning, and the status the client receives is then whatever the raised
194
- call was given. A pack says which of its library's callables do that, under
195
- `responseStatusCalls`, and where each of them takes its status:
13
+ The adapter ships inside the CLI, so there is nothing else to install.
196
14
 
197
- ```ts
198
- responseStatusCalls: [
199
- {
200
- callee: "fastapi.HTTPException",
201
- statusKeyword: "status_code",
202
- statusArgument: 0,
203
- },
204
- ],
205
- ```
206
-
207
- `callee` is the name as the file imports it, module and name together, which
208
- is what tells FastAPI's `HTTPException` apart from a project class of the same
209
- name. A `raise` of one of these becomes a transition responding with that
210
- status, and so does a bare call to one written as a statement of its own,
211
- which is how Flask's `abort(404)` ends a request. So this handler:
15
+ ## Read a FastAPI service
212
16
 
213
- ```python
214
- @app.get("/items/{item_id}")
215
- def show(item_id: int):
216
- if item_id > 10:
217
- raise HTTPException(status_code=404, detail="missing")
218
- return {"id": item_id}
17
+ ```bash
18
+ npx suss extract --lang python -f fastapi -f sqlalchemy -f requests -o summaries/python.json
19
+ npx suss check --dir summaries/
219
20
  ```
220
21
 
221
- comes out as a 404 under `item_id > 10` and a 200 under its negation.
22
+ `suss init` reads your requirements file or pyproject and writes those two commands out for your own project.
222
23
 
223
- A status the reading cannot resolve to a number is reported as the text it was
224
- written as, so the transition says a status was set without claiming which. A
225
- `raise` of anything the pack does not list is still a transition, with the
226
- exception type on it and no status, since the framework is the only thing that
227
- knows what it turns that exception into.
24
+ ## What it reads
228
25
 
229
- ### Keying anything on a node
26
+ - FastAPI and flask-restx routes, including a decorator a project re-exports through a wrapper module, with `response_model` and `status_code` taken as the declared contract.
27
+ - The path a route is served under, composed through `include_router` prefixes, however many mounts deep, including a mount written inside a function or spread out of a dictionary.
28
+ - SQLAlchemy calls, `requests` / `httpx` / `aiohttp` call sites, what a file reads from the environment, and what a handler calls out to, as facts the checker's rules run over.
230
29
 
231
- tree-sitter hands back a fresh wrapper object every time a child is read, so
232
- two reads of one node are never `===` and a plain `Set` or `Map` keyed on a
233
- node matches nothing. Use `NodeSet` and `NodeMap`, which key on the node id.
234
- `npm run check:style` fails a build that keys either on a node.
30
+ How each of those is decided, and where it stops: [how the Python adapter reads a project](./DESIGN.md).
235
31
 
236
- ## What a body does with the database
32
+ ## Where it fits
237
33
 
238
- A pack says which query types its library defines, and a call chain matches
239
- when the method behind it says it returns one:
34
+ It depends on `@suss/extractor`, `@suss/behavioral-ir`, `@suss/datalog` and `web-tree-sitter`. The `fastapi`, `flask-restx` and `sqlalchemy` packs consume its `PythonPack` contract, and nothing here knows what any particular library's decorators are called.
240
35
 
241
- ```ts
242
- storage: [
243
- {
244
- module: "sqlalchemy.orm",
245
- queryTypes: ["Query"],
246
- writes: ["update", "delete", "add"],
247
- storageSystem: "postgresql",
248
- },
249
- ]
250
- ```
36
+ `grammar/tree-sitter-python.wasm` is a checked-in binary, not a build output. [Where it comes from](./grammar/README.md).
251
37
 
252
- Matching on the return rather than on the import is what reads a project's own
253
- wrapper. A measured Flask service imports `sqlalchemy` in 50 files, and every
254
- one of its 157 queries still goes through a base class the call sites never
255
- import:
38
+ ## More
256
39
 
257
- ```python
258
- # in the project, not in the library
259
- class Base:
260
- @classmethod
261
- def query(cls) -> Query: ...
262
-
263
- class Orders(Base): ...
264
-
265
- # in a handler, importing neither the base nor SQLAlchemy
266
- found = Orders.query().filter_by(id=1).first()
267
- ```
268
-
269
- A recognizer keyed on the import finds none of that. Following the call to
270
- `Base.query` and reading what it says it returns finds all of it, and the
271
- inheritance hop comes from `contains`, which reads `holdsProperty` and adds
272
- what a base class declares.
273
-
274
- A chain is one thing the code does, so the three calls above are one read. The
275
- call that starts the chain is the one resolved, and the method the chain ends
276
- with tells a read from a write. A method a base and a subclass both declare
277
- gives two, and nothing is claimed.
278
-
279
- A chain also matches when the name it starts on is declared, where the chain
280
- is written, as one of the query types: a parameter annotated `db: Session`,
281
- a local bound by `session = Session()` or `with Session() as session:`, or a
282
- project function whose return annotation says `Session`. The type has to be
283
- imported from the pattern's module, so a project class that happens to share
284
- the name does not match. `recordsNothing` lists the methods on such a type
285
- that touch no rows of their own: `execute` runs a statement whose own chain is
286
- the read or write, and `close` manages the session.
287
-
288
- A query built from a function the library exports has no project method in
289
- between for its return to be read off, so a pack lists those by name and a call
290
- site importing one matches on that:
291
-
292
- ```ts
293
- queryFunctions: ["select", "insert", "update", "delete"]
294
- ```
295
-
296
- For one of these the function itself is the operation, so
297
- `update(User).where(...).values(...)` is an update however it ends.
298
-
299
- `fields` comes from the columns a query spells out, `User.id` in
300
- `select(User.id, User.email)`, and from the keywords of a call the pack lists
301
- under `valueMethods`, `name` in `.values(name="x")`. `selector` comes from
302
- the keywords every other call in the chain picks rows by, `id` in
303
- `filter_by(id=user_id)`. Raw SQL handed to `text` is read as its own effect,
304
- with the kind and table taken from the statement.
305
-
306
- ## Where a mount is written
307
-
308
- Almost no service mounts anything at the top level of a module. It builds its routers or namespaces there, each with a literal prefix, and registers them inside the function that builds the app, often by looping over a list that another function put together:
309
-
310
- ```python
311
- def create_app():
312
- app = Flask(__name__)
313
- api = Api(app)
314
- for namespace in loader.load_namespaces():
315
- api.add_namespace(namespace)
316
- return app
317
- ```
318
-
319
- So suss looks for mounts inside function bodies as well as at the top level. It still reads constructor calls at the top level only, which is where people write them. It does not see a mount inside an `if`, a `try`, a `with`, a `while`, or a class method, because the binder records no name written inside one of those, and a mount there would resolve to nothing anyway.
320
-
321
- A mount inside a `for` goes one of two ways, depending on whether the source says which routers the loop covers.
322
-
323
- | The loop reads | What suss does | Why |
324
- | --- | --- | --- |
325
- | a list or tuple of bare names, `for ns in [orders, users]` | mounts each of those names, exactly as a mount call that named it would | every element is a name the existing one-hop rule already follows |
326
- | anything else, `for ns in loader.load_namespaces()` | mounts nothing, and every router it never saw mounted by name explains that the loop is the reason | no file says which routers the call returns |
327
-
328
- Claiming a path off the second row would mean guessing twice at once: that this router is in the collection at all, and that the mount states no prefix that would replace or extend the router's own. A wrong path is worse than no path here, because a claimed path pairs the route with a contract on the other side, and every check downstream treats it as fact. Nothing tells those checks apart from a path suss followed all the way.
329
-
330
- What the second row does give you is a better explanation. A router nobody mounts by name used to say "is never mounted through a single variable binding in the files read", which sends a reader hunting for a registration that is sitting right there in the app factory. When suss saw a loop it could not enumerate, the reason says that instead, and the fix it points at (list the routers out in the source, or give suss the list) is the one that works.
331
-
332
- Both rows were checked against a running app. A loop over a literal list serves each namespace under its constructor path, the same paths the direct mounts serve. A `path=` written on the mount inside the loop replaces every namespace's own path, which is one more reason not to treat a loop's mount as an ordinary registration.
333
-
334
- ### A mount inside a function runs only if the app calls that function
335
-
336
- A mount at a module's top level runs on import, so it is the mount. A mount inside a function is only a candidate, and suss has no idea which functions the app actually calls. A repo with a test factory next to the app factory is where that goes wrong:
337
-
338
- ```python
339
- def create_test_app():
340
- app = FastAPI()
341
- app.include_router(router, prefix="/test") # only tests call this
342
- return app
343
-
344
-
345
- def create_app():
346
- app = FastAPI()
347
- for r in loader.load_routers(): # what production runs
348
- app.include_router(r)
349
- return app
350
- ```
351
-
352
- Taking the only mount it could follow would put the route at `/test`, where no request ever arrives. So suss drops a mount written inside a function when it also saw a loop it could not enumerate somewhere other than that same function. The route abstains, and says both the function and the loop.
353
-
354
- Both of those qualifiers matter. Only counting loops keeps the ordinary case working: two factories that each register their own routers by name are not competing, because neither one could have registered the other's. Only counting a loop in a *different* place keeps a factory working when it mixes an explicit registration with a loop, since both of those run together whenever that function runs.
355
-
356
- A module-level mount is never dropped this way. It runs whichever factory the app calls.
357
-
358
- ## What a file reads from the environment
359
-
360
- `os.environ` is part of the language's standard library, so the adapter recognizes reads of it itself, without a pack. Each read becomes the same `config-read` interaction the TypeScript adapter emits for `process.env.X`, on the binding `runtime-config`, spelled `os.environ["X"]` whichever way the source wrote it. The runtime-config checker pairs those against what a template declares for the function the file runs in.
361
-
362
- | Python | Recognized as | Defaulted |
363
- | --- | --- | --- |
364
- | `os.environ["X"]` | a read of `X` | no |
365
- | `os.environ.get("X")` | a read of `X` | no |
366
- | `os.environ.get("X", "d")`, `os.environ.get("X", default="d")` | a read of `X` | yes |
367
- | `os.getenv("X")`, `os.getenv("X", "d")` | a read of `X` | as above |
368
- | any of these followed by `or` (`os.environ.get("X") or "d"`) | a read of `X` | yes |
369
- | `import os as _os`, `from os import environ, getenv` | the same reads, through the alias | |
370
- | `os.environ[name]`, `os.environ.get(f"{prefix}_X")` | nothing: the name is not a literal | |
371
- | `os.environ["X"] = "1"`, `del os.environ["X"]`, `"X" in os.environ` | nothing: a write or a membership test | |
372
- | `os.environ.get("X") or os.environ.get("Y")` | `X` defaulted, `Y` not, since `Y` is the chain's last resort | |
373
-
374
- A read inside a route body goes on that route's summary. A read at module level, or in a class body, runs when the module is imported, so it goes on a `module-init` summary named after the file, one per file that has such a read. A read inside a function a route reaches through its calls goes on that function's own summary (see below). A read inside a function nothing discovered and nothing reaches is reported nowhere, because nothing says when that function runs.
375
-
376
- ## What a route reaches
377
-
378
- A route's body calls project functions, and those call others. Each function a route reaches this way gets a summary of its own, of kind `library`, bound as `function-call` with `transport: "in-process"` and `recognition: "reachable"`, with the calls, environment reads and database work its own body does. Each invocation effect on a route or a reached function says which summary the call lands on, in `summary`, so a reader answering "what does this route reach" follows `summary` from one unit to the next and never has to match a name. This is the same walk the TypeScript adapter runs, and it produces the same shape.
379
-
380
- The walk starts at every discovered route, and adds a `calls` fact for each call in a body it could follow, until the set stops growing. A function two routes both reach gets one summary. A call the walk could not follow is recorded once per callee on the summary of the body it is in, as an `unfollowedCall` gap saying why, unless the reason is one nothing could have done better with (a call to a parameter that some caller passes a function by name into, or a call into a package that is not in the run).
381
-
382
- A callee is followed only through a binding that says where it came from. The binder's scopes settle a name written in the file; the import resolver settles a name brought in from another file in the run.
383
-
384
- | Written as | Followed to |
385
- | --- | --- |
386
- | `helper()` with `def helper` in the file | that definition |
387
- | `helper()` with `from pkg.mod import helper` | `helper` in `pkg/mod.py` |
388
- | `mod.helper()` with `import pkg.mod` or `from pkg import mod` | `helper` in `pkg/mod.py`, whether or not `pkg` has an `__init__.py` |
389
- | `run = helper` then `run()`, written straight in a body | `helper`, one alias hop |
390
- | `Handlers.on_get()` with `on_get = helper` in the class body | `helper` |
391
- | `self.method()` or `cls.method()` inside a method | the method on the enclosing class |
392
- | `Service().run()`, or `svc = Service()` then `svc.run()` | `run` on `Service` |
393
- | `Service()` | `Service.__init__` |
394
- | `helper()` with `from pkg.mod import *` and one module defining it | that definition |
395
- | a call written as an argument, or as the receiver of a chain | followed like any other, in the order it finishes |
396
- | a call inside a lambda | the enclosing function's call |
397
- | `inner()` with `def inner` nested in the body | `inner`, which gets a summary of its own with the calls its body makes |
398
- | `register(build_index)`, where `register(handler)` calls `handler()` | `build_index`, followed from wherever a caller in the run named it, through the parameter `register`'s own body calls |
399
-
400
- A function passed by name into a call is followed one hop further than the call itself. `register(build_index)` records where `build_index` is declared, keyed to its position among `register`'s arguments; `register`'s own body calling `handler()` records which of its parameters that call goes through. A reader asking what calls `build_index` finds `register`, and what `register` reaches includes `build_index`, even though the join crosses two separate calls. Only a bare name counts, so a lambda written inline and a variable bound to a function are both out of scope, the same as a callee written that way.
401
-
402
- Where it stops, and what the gap says:
403
-
404
- | Written as | Reason |
405
- | --- | --- |
406
- | a call on a parameter, `callback()`, that some caller in the run passes a function by name into | followed through the join above (no gap) |
407
- | a call on a parameter, `callback()`, that no caller in the run passes a function by name into | the caller supplies it, and nothing named what it passed |
408
- | a name a `for`, `with`, `except ... as`, walrus, or lambda parameter rebinds | the value could not be settled |
409
- | a name assigned inside an `if`, `try`, or `with` block, or by unpacking | the value could not be settled |
410
- | `from a import *` and `from b import *` when both define the name | more than one possible source |
411
- | an import that resolves under two roots | more than one possible source |
412
- | a call into a package whose source is not in the run | outside the run (no gap) |
413
- | a value's attribute, `order.save()`, or a call on a call's result | no declaration to follow (no gap) |
414
-
415
- One callee spelling that lands on two definitions in one body, `load()` under a class body that imports its own `load` say, is placed on neither.
416
-
417
- Not followed yet: a method inherited from a base class, a callable stored in a dict or a list, a decorator's own body, a call written in a parameter default such as `Depends(get_db)`, and an attribute set on `self` in `__init__` and called elsewhere.
418
-
419
- A summary's `identity.id` is the file relative to the project root plus the export path (`app/store.py::read_orders`, `app/models.py::Orders.total`), and `summary` on an effect is that id. Two summaries that would share an id are told apart by their boundary, then by their line.
420
-
421
- ## What a file imports from the project
422
-
423
- Every summary has `metadata.moduleImports`, the project files its own file's imports resolved to, relative to the workspace root and sorted. A file whose imports all resolve outside the project gets an empty list rather than no field, so a Lambda handler that imports only the standard library still tells the checker that its closure is the handler module alone. A checker rebuilds the import graph from that field to work out which modules a template's handler entry loads; the entry `app.handler` under `CodeUri: src/` matches `src/app.py`, and a dotted module such as `shop.app.handler` matches `src/shop/app.py`.
424
-
425
- ## Extraction cache
426
-
427
- This adapter uses the extraction cache shared across every language adapter, from `@suss/extractor`. It keeps the cache beside the project root, at `.suss/cache/`, and supplies the walked `.py` file list as the key's file set; it has no config file of its own to guard the entry the way the TypeScript adapter's tsconfig does. Reuse today is whole-run only: any file changing re-extracts the whole project instead of that one file. See the extractor package's README for the design.
428
-
429
- ## Grammar asset
430
-
431
- `grammar/tree-sitter-python.wasm` is a checked-in binary asset, not a build output. See [`grammar/README.md`](./grammar/README.md) for its provenance and how to bump it.
432
-
433
- ## Coverage
40
+ - [Documentation](https://nimbuscloud-ai.github.io/suss/)
41
+ - [Python and Ruby](https://nimbuscloud-ai.github.io/suss/guides/python-and-ruby)
42
+ - [Every pack suss ships](https://nimbuscloud-ai.github.io/suss/reference/packages)
43
+ - [Source and issues](https://github.com/nimbuscloud-ai/suss)
434
44
 
435
45
  ![coverage](../../../.github/badges/coverage-python.svg)
436
46
 
437
- ## License
438
-
439
- Licensed under Apache 2.0. See [LICENSE](../../../LICENSE).
47
+ Apache 2.0. See [LICENSE](../../../LICENSE).