@suss/adapter-python 0.23.0 → 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 +25 -447
- package/dist/index.cjs +4356 -4089
- package/dist/index.d.cts +45 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +4056 -3789
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -1,469 +1,47 @@
|
|
|
1
1
|
# @suss/adapter-python
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Read a Python codebase with [suss](https://github.com/nimbuscloud-ai/suss).
|
|
4
4
|
|
|
5
|
-
|
|
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
|
-
|
|
7
|
+
## Install
|
|
8
8
|
|
|
9
|
-
|
|
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
|
|
9
|
+
```bash
|
|
10
|
+
npm install --save-dev @suss/cli
|
|
45
11
|
```
|
|
46
12
|
|
|
47
|
-
|
|
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 |
|
|
13
|
+
The adapter ships inside the CLI, so there is nothing else to install.
|
|
108
14
|
|
|
109
|
-
|
|
15
|
+
## Read a FastAPI service
|
|
110
16
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
17
|
+
```bash
|
|
18
|
+
npx suss extract --lang python -f fastapi -f sqlalchemy -f requests -o summaries/python.json
|
|
19
|
+
npx suss check --dir summaries/
|
|
184
20
|
```
|
|
185
21
|
|
|
186
|
-
|
|
187
|
-
`not found` and the 200 gated on its negation, rather than as one claim with a
|
|
188
|
-
guessed status.
|
|
22
|
+
`suss init` reads your requirements file or pyproject and writes those two commands out for your own project.
|
|
189
23
|
|
|
190
|
-
|
|
24
|
+
## What it reads
|
|
191
25
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
call
|
|
195
|
-
`responseStatusCalls`, and where each of them takes its status:
|
|
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.
|
|
196
29
|
|
|
197
|
-
|
|
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:
|
|
212
|
-
|
|
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}
|
|
219
|
-
```
|
|
220
|
-
|
|
221
|
-
comes out as a 404 under `item_id > 10` and a 200 under its negation.
|
|
222
|
-
|
|
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.
|
|
228
|
-
|
|
229
|
-
### Keying anything on a node
|
|
30
|
+
How each of those is decided, and where it stops: [how the Python adapter reads a project](./DESIGN.md).
|
|
230
31
|
|
|
231
|
-
|
|
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.
|
|
32
|
+
## Where it fits
|
|
235
33
|
|
|
236
|
-
|
|
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.
|
|
237
35
|
|
|
238
|
-
|
|
239
|
-
when the method behind it says it returns one:
|
|
240
|
-
|
|
241
|
-
```ts
|
|
242
|
-
storage: [
|
|
243
|
-
{
|
|
244
|
-
module: "sqlalchemy.orm",
|
|
245
|
-
queryTypes: ["Query"],
|
|
246
|
-
writes: ["update", "delete", "add"],
|
|
247
|
-
storageSystem: "postgresql",
|
|
248
|
-
},
|
|
249
|
-
]
|
|
250
|
-
```
|
|
251
|
-
|
|
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:
|
|
256
|
-
|
|
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
|
-
```
|
|
36
|
+
`grammar/tree-sitter-python.wasm` is a checked-in binary, not a build output. [Where it comes from](./grammar/README.md).
|
|
268
37
|
|
|
269
|
-
|
|
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.
|
|
38
|
+
## More
|
|
273
39
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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, and an attribute set on `self` in `__init__` and called elsewhere. A function written in a parameter default such as `Depends(get_db)` is not a call the route makes; it runs around the route, and the next section says how it is read.
|
|
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 runs around a route
|
|
422
|
-
|
|
423
|
-
A route's wire behavior is not only what its own body does. A FastAPI dependency that raises 401, a middleware that returns 429 and an exception handler that turns a `ValueError` into a 500 all respond for the route without appearing in it. Each of those gets a summary of its own, the route lists them under `metadata.wrappers.applied` in the order they run, and the extractor composes their transitions into the route's, with `wrappers.from` on each transition a wrapper contributed. How that composition works is in the extractor's README; this section says which registrations the adapter reads.
|
|
424
|
-
|
|
425
|
-
A pack declares each way its library attaches something around a route, in `wrappers` on a discovery pattern. There are two forms:
|
|
426
|
-
|
|
427
|
-
| Form | Reads | Example |
|
|
428
|
-
| --- | --- | --- |
|
|
429
|
-
| `dependency` | a function passed to one of `callees` inside a `keyword` list, on the app or router construction, on the route decorator, or in a parameter default | `Depends(require_caller)` |
|
|
430
|
-
| `decoratedWrapper` | a function decorated with `attribute` on the app or router object | `@app.middleware("http")`, `@app.exception_handler(ValueError)`, `@app.before_request` |
|
|
431
|
-
|
|
432
|
-
Each form says which constructions register it, and how far the registration reaches. A registrar with `covers: "everyRoute"` (the app) reaches every route the pack discovers; one with `covers: "ownRoutes"` (a router or namespace) reaches the routes decorated on that same object, told apart by where it was constructed. A registration is read only where the object is recognized the way a route decorator's object is, so `app = FastAPI(...)` in one module and `@app.middleware` in another are joined through the import the same way a route on an imported router is.
|
|
433
|
-
|
|
434
|
-
How a wrapper's body is read depends on the form:
|
|
435
|
-
|
|
436
|
-
- A `dependency` runs to completion before the handler, so each `raise` of a response status is a response of its own and every `return` hands on to the route.
|
|
437
|
-
- A `decoratedWrapper` with `continuationParam` (FastAPI middleware) hands on at the statement that calls that parameter, and anything after the call is not read. A `return` that does not call it responds on its own.
|
|
438
|
-
- A `decoratedWrapper` with `returnedValueResponds` (Flask's `before_request`) responds with whatever it returns and hands on only where it returns nothing.
|
|
439
|
-
- A `decoratedWrapper` with `throwParam` (an exception handler) runs only for a request that raised, so its responses replace each of the route's paths that end in a `raise` the pack does not read as a response. A raised `HTTPException` or `abort` already has a status, so no handler applies to it.
|
|
440
|
-
|
|
441
|
-
Wrappers run in the order the library runs them: middleware first, then dependencies from the app, the router and the route in that order, then exception handlers over what raised. Two registrations of the same function are one wrapper.
|
|
442
|
-
|
|
443
|
-
Not read yet:
|
|
444
|
-
|
|
445
|
-
- `include_router(router, dependencies=[...])` and `add_middleware(SomeClass)`. A dependency at the mount and a middleware given as a class are missed.
|
|
446
|
-
- Which exception type a handler is for. Every handler applies to every unread raise, so a route with two handlers reports both statuses on the paths that raise.
|
|
447
|
-
- A dependency's own dependencies. `Depends(f)` where `f` takes `g: str = Depends(g)` reads `f` only.
|
|
448
|
-
- A parameter default that refers to something other than a function at module scope, such as a method or a variable bound inside a function.
|
|
449
|
-
- Flask's `before_request` on a blueprint. A flask-restx route is decorated on a namespace, and the blueprint is where it is mounted, so only the app's hook is read.
|
|
450
|
-
|
|
451
|
-
## What a file imports from the project
|
|
452
|
-
|
|
453
|
-
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`.
|
|
454
|
-
|
|
455
|
-
## Extraction cache
|
|
456
|
-
|
|
457
|
-
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.
|
|
458
|
-
|
|
459
|
-
## Grammar asset
|
|
460
|
-
|
|
461
|
-
`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.
|
|
462
|
-
|
|
463
|
-
## 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)
|
|
464
44
|
|
|
465
45
|

|
|
466
46
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
Licensed under Apache 2.0. See [LICENSE](../../../LICENSE).
|
|
47
|
+
Apache 2.0. See [LICENSE](../../../LICENSE).
|