The official @groma/scanner-python plugin reads Python source through the
standard library's ast parser, running in bundled Pyodide 314.0.7. The
package includes CPython and its standard library as WebAssembly assets.
Git is required; a separate Python installation or virtual environment is not. It does not
import project modules, execute source, install dependencies, or evaluate
setup.py. Python parser documentation.
For a published release:
groma scanner add @groma/scanner-python
groma scanner check
groma scanFor local development, build a runnable package from the groma.md repository, copy that package into the consumer repository, and add its relative path:
bun plugins/scanners/python/build.ts /tmp/python-scanner
# In the consumer repository, after copying the folder to tools/python-scanner:
groma scanner add ./tools/python-scanner
groma scanner check
groma scanEach scan uses its own worker and an in-memory copy of the selected files. The worker runs only the scanner's parser code, then releases its interpreter. Runtime assets are loaded from the installed scanner package; scanning does not download Python packages or use project environments.
The package's default include and exclude lists
decide which files the scanner reads. The include list, **/*.py,
**/pyproject.toml, **/setup.cfg and **/requirements.txt, names .py
sources and the project declarations below, and no .pyi stubs or notebooks.
The exclude list names virtual environments and bytecode
caches (.venv/, venv/, __pycache__/), tests (test/, tests/,
test_*.py, *_test.py, conftest.py), and build output (build/, dist/).
Excluded files are never read, so an excluded file cannot fail the scan.
pyproject.toml, setup.py, setup.cfg, and requirements.txt identify source
projects. A file belongs to its nearest containing project; nested project roots
retain their parent links. Source outside those declarations belongs to one
repository source group. A pyproject.toml project name supplies the root name
when present; otherwise the directory name is used. These are source groupings,
not evaluated Python distributions. Discovery reads these declarations without
loading the plugin. Source-only repositories can add the plugin explicitly.
Each file has one record with named classes and functions, including methods,
async functions and nested declarations. Functions also provide operations and
exact UTF-16 source positions. Calls in their bodies identify the owning
operation, call position, line and member name when present. Every call target
is unresolved: Python runtime binding is outside this parser's evidence.
Decorators, defaults, annotations, module/class initialization, lambdas and
lazy generator expressions do not provide invocation evidence. Concrete
callbacks are not analyzed and call evidence produces no derived call
relationships; imports are followed for execution-entry source closure and
the HTTP facts below. Every observation includes a
PYTHON_SYNTAX_ONLY diagnostic.
Source is parsed and compiled for syntax and scope validation, including valid
module-level await, but the code object is never executed. Invalid source or project TOML fails the whole Python
observation. Other healthy scanners follow groma.md's existing failure isolation.
Correct reported syntax errors or use a scanner release supporting the source
language version. Readiness checks source availability and the packaged worker;
full syntax validation happens during the scan.
Module guards, __main__.py, and [project.scripts] identify execution entries.
A declared script resolves its target within its own project. Its local source
closure follows imports that can run at module level, including repeated star
imports and imports inside a branch; imports inside functions do not add files.
Components list the declarations of their Python files under the
shared outline contract. The worker
parses each requested file with the same ast parser and lists only
statements directly in the module body:
defandasync def, and lambdas assigned directly to a module-level name, are functions.- Classes are types. Their members are the
defandasync defstatements directly in the class body, including static methods, class methods and__init__.
Properties are not members: methods decorated with @property,
@cached_property, @functools.cached_property, or a property's .getter,
.setter, or .deleter. Nested classes and functions, class attributes
holding lambdas, type aliases, and other assignments such as partial(...) are
not listed either, nor are declarations inside module-level if or try
blocks. A declaration's line is its def, class, or assigned name's line.
Visibility comes from names alone. Dunder names and names without a leading
underscore are public. Members named _name are protected and members named
__name are private. Other top-level names starting with _ are private.
__all__ does not change visibility.
A declaration is an entry when a Code link names it: a top-level declaration by
its name, such as place_order, and a member by its class and name, such as
OrderService.fetch, the form the scan uses for methods.
groma lint and scan findings compare Python operations under the
shared rule. The scanner
attaches a source range, from the def line to the body's last line, and body
tokens to every def and async def: module functions, methods including
__init__, and functions nested in functions or classes.
Parameters and the names a function binds, including comprehension variables
and nested function names, become slots in order of appearance; a def, class,
import, except or match name appears where it is bound. A function does not
bind names it declares global or nonlocal. An import inside a function
keeps the imported module and member, such as math.floor, and only its local
name becomes a slot. Docstrings, decorators, parameter defaults and annotations
are not body tokens.
The syntax tree has no parentheses, so an operand of an operator that is itself
a binary, boolean or comparison expression is wrapped in ( and ):
(a + b) * c and a + b * c differ, and a chain such as a < b <= c stays one
run. A subscript writes index, a slice keeps where each bound stands, a
dictionary unpacking writes ** where its key would be, and an else block
starts with else.
Lambdas, including those in a dictionary passed to a call or decorator, are anonymous callbacks and are never compared. A function's tokens include the functions, lambdas, classes and generator expressions nested in it. Module and class-body code are initializers and are not compared.
The scanner reports the HTTP endpoints and requests it recognizes in Python source. Supported declarations:
| Construct | Reported as |
|---|---|
@app.route, @app.api_route, @app.get and the other method decorators on a Flask, FastAPI or Starlette application |
One endpoint per method; a route or api_route without methods serves GET |
add_api_route(path, endpoint, methods=...) and add_route(...) on a FastAPI or Starlette application or router |
Read like route |
add_url_rule(rule, view_func=handler, methods=...) on a Flask application |
One endpoint per method; GET when no method is listed |
mount(path, app) on a FastAPI or Starlette application or router |
The mounted application's routes under the mount path; a blocker for everything under that path when the scanner cannot resolve the mounted application |
host(...) on a FastAPI or Starlette application or router |
A blocker for the whole application |
Blueprint(url_prefix=...) and APIRouter(prefix=...) |
The router's own prefix |
register_blueprint(..., url_prefix=...) and include_router(..., prefix=...), in any scanned module |
The registering prefix, including nested registrations; Flask's replaces the blueprint's own prefix, FastAPI's goes before the router's |
urlpatterns with path(), re_path() and url() |
One endpoint for method *, since Django hands every method to the view |
include("dotted.module") |
The prefix of the included module's patterns |
requests, httpx and aiohttp method calls, and request("METHOD", url) |
One request |
httpx.Client, httpx.AsyncClient, requests.Session and aiohttp.ClientSession, including base_url= |
One request per call on the session |
urllib.request.urlopen |
GET, or POST with data; Request(method=...) states its own method |
<name>, <str:name>, {name} and {name:str} are parameters; <path:rest> and
{rest:path} are catch-alls. A regular-expression route is read segment by
segment, with (?P<rest>.*) or .+ last as a catch-all; a dot in its text is
literal only when escaped.
The scanner recognizes an application assigned once in a function body, such
as Flask's create_app, and routes declared on that local name. It reports no
endpoint for a router created inside a function or a Starlette or FastAPI route table, and no request for a client call
outside a function. A Flask route whose path or methods are computed, or whose
blueprint the scanner cannot resolve or finds registered on an application it
cannot resolve, such as one passed to a function, reports nothing; Django,
FastAPI and Starlette report such entries as blockers (decision 8). A router
nobody registers in the scanned source keeps only its own prefix.
Django endpoints need the complete include() graph, so urlpatterns must be a
literal list or tuple bound once. When any module builds its table by addition,
binds it more than once, includes a computed module, or includes a dotted path
that matches several scanned files, no Django endpoint is reported at all. An
include() of a module outside the scan is safe.
Only a name the module binds exactly once resolves to its value, in a module and
in an operation alike, so a constant reassigned anywhere, PREFIX += "/v2" and
a for PREFIX in ... loop included, and a client session rebound in the same
function, resolve to nothing.
A client call counts only when the module imports that library. A name that a
function binds, such as a parameter named requests or BASE, never resolves
to the module's import or constant of the same name.
Names resolve across modules by import: the scanner maps each scanned file to
its dotted module path and follows import, from ... import and relative
imports to one scanned module. A dotted path that matches several files, or
none, resolves to nothing.
The eight producer decisions for Python:
- Which prefixes belong in the path. A blueprint's or router's own prefix,
every registering prefix, and every Django
include()prefix above it. Django endpoints are reported only from URL tables nobody includes, so each path carries its prefixes.APIRouter(prefix="/speakers")included withprefix="/api"reports/api/speakers/{speaker_id}. Flask'sregister_blueprint(talks, url_prefix="/api")replaces the blueprint's ownurl_prefix, so its/<int:talk_id>route reports/api/<talk_id>; a nested blueprint's prefix follows its parent's. - Whether the construct is an endpoint. Only a view or decorated handler.
Flask
before_requesthooks, WSGI or ASGI middleware, Django middleware and permission classes, andstatic()helpers are not endpoints. - Dynamic or unknown.
f"/talks/{talk_id}"fills one whole segment, so it is dynamic; Python cannot prove the value holds no slash.f"/talks/{talk_id}.json"and a URL read from an unresolved value are unknown. - The local helper. Only the call that names a recognized client reports a
request. A helper that takes the URL as a parameter and calls
requests.get(url)reports one request whose path is unknown, and its callers report nothing. - The base.
requests.get("/talks")has no base. A value read from configuration setsconfigured:os.environ["API"],os.environ.get(...),os.getenv(...), a Djangosettings.API, and abase_urlattribute such asself.base_url, directly or through a module-level name bound once to one. So dorequests.post(f"{settings.API}/talks")and a session withbase_url=settings.API.self.base_urlis instead the value of its one assignment when the class, its subclasses and every class they inherit from in the scanned source assignbase_urlexactly once, by a plain assignment in a class body or throughself.base_url = ..., and no code setsbase_urlon another object, such asclient.base_url = url. Sobase_url = "https://api.github.com"leads with anunknownsegment andbase_url = f"{settings.API}/v1"setsconfiguredbeforev1. A base class outside the scanned source is not counted.requests.get("https://example.com/talks"), and a base the scanner can neither resolve nor trace to configuration, such asrequests.get(url)with a parameter,build_url() + "/talks"or a name bound more than once, report a leadingunknownsegment. A module-level name bound once resolves to its text, soAPI = "/api"withAPI + "/talks"is/api/talks. Literal text right after a configured base must start with/, sincesettings.API + "talks"continues the base's last segment: that segment isunknown. - Which operation a file-location route names. Python has no file-location routing, so every endpoint names the handler function a decorator or URL pattern designates.
- Which segments are constrained. A converter other than
strorstring, such as<int:pk>,<slug:s>or{id:int}, and a segment that mixes text with a placeholder, such asphoto-{id}, are constrained parameters. In a regular-expression route, a named group limited to one segment, such as(?P<pk>[0-9]+), is a constrained parameter and(?P<slug>[^/]+)a plain one. A group that may match a slash, such as(?P<path>[a-z/]+), or text the format cannot state, becomes a constrained optional catch-all that replaces the rest of the route, and so does a catch-all followed by more route text. A regular expression without$also matches any continuation, so^feed/ends with an optional catch-all. - Registration order. Django, FastAPI and Starlette take the first route
that matches; Flask prefers the most specific one and reports no
order. A Django endpoint'sordernames the URL table nobody includes, and its position follows that table with eachinclude()expanded where it stands. FastAPI and Starlette add routes as modules import, an order the scanner does not prove, so every endpoint of one application shares position0. The application is the file that creates it; when the scanner cannot find that file, such as for a router nobody registers or one registered on an application it cannot resolve, it is the file where the route is written. A route entry these routers register but the scanner cannot report keeps its position as a blocker: its literal prefix followed by a constrained optional catch-all, so core derives no row to it and none for a request it could take first. Django blockers are a class-based view throughas_view(), a view it cannot resolve such asadmin.site.urls, aninclude()of a module outside the scan, an unreadable route, and a list element that is notpath(),re_path()orurl(); each names its URL module's code as the(module)operation. Aninclude()whose route does not end in/, such aspath("v", include(...)), joins it to the included routes with no separator, so each included route is reported in blocker form after the text before that last segment. FastAPI and Starlette blockers are a route with a computed path or methods, anadd_api_route()oradd_route()whose handler the scanner cannot resolve, a route on a router registered on an application the scanner cannot resolve (a bare catch-all),include_router()ormount()of a router or application the scanner cannot resolve, and everyhost().
Python roots, symbols and operations are temporary scanner evidence. They add
no OKF record type or C4 containment level. groma.md core owns initial placement
and preserves curated file ownership. Ordinary Markdown and OKF readers see
the existing architecture records and Code links with scanner ID python.
Framework dependencies and imports alone do not establish C4 collaborations.
The same rules apply across Python projects, without assuming a web framework
or directory layout.
See fresh-checkout validation for exercised projects and limitations.