Skip to content

Read Python or Ruby ​

Point suss extract at the directory instead of a tsconfig, and pass the packs for the frameworks the project uses.

bash
npx @suss/cli extract --dir services/shop -f fastapi -o summaries/shop.json
npx @suss/cli inspect summaries/shop.json

The summaries are the same format a TypeScript run writes, so check compares a Python handler against a TypeScript client in one pass. Neither adapter needs an installed interpreter, a virtualenv or a bundle. Both parse with tree-sitter compiled to WASM, which ships inside the CLI.

Let init pick the packs ​

bash
npx @suss/cli init services/shop --plain

For a Python project it reads requirements.txt and the other requirements files beside it, pyproject.toml (the standard table and Poetry's two), Pipfile, setup.cfg and a literal install_requires list in setup.py. For a Ruby project it reads Gemfile.lock. Over this repo's fixtures/python-webapp:

✓ Found 2 things to read

  Your code
    fastapi          fastapi in requirements.txt
    flask-restx      flask-restx in requirements.txt

1. Install suss

   npm install --save-dev @suss/cli

2. Read each side into one folder

   suss extract --lang python -f fastapi -f flask-restx -o summaries/code.json

When init looked somewhere and could not read it, it prints that on its own line. A setup.py that computes its dependency list, a conda environment file, or a Gemfile with no lock file beside it each get one of those lines, because suss cannot suggest a pack for a library that only appears there.

Which packs read which language ​

The pack catalog lists every pack and what it reads. In short:

  • In Python, suss reads routes with fastapi or flask-restx, database calls with sqlalchemy or sqlmodel, and outbound HTTP with requests, httpx or aiohttp.
  • In Ruby, suss reads GraphQL fields with graphql-ruby, Rails controller actions with rails, database calls with activerecord, and outbound HTTP with faraday or net-http.

A pack for one language cannot run alongside a pack for another in the same command. Within one language you can combine them freely: -f rails -f activerecord=suss.activerecord.json gives you the controller actions and the tables each one touches.

Python ​

bash
npx @suss/cli extract --dir fixtures/python-fastapi -f fastapi -o summaries/shop.json
npx @suss/cli inspect summaries/shop.json
shop/main.py
├─ GET /health  (fastapi handler | line 24)
│      -> 200 HealthStatus
│
├─ POST /orders  (fastapi handler | line 29)
│      -> 201
│
└─ GET ?  (fastapi handler | line 34)

       !! The path in this route's decorator is not a string literal, so the binding names no path and nothing pairs with it

shop/routers/admin.py
└─ GET ?  (fastapi handler | line 13)

       !! The router this route is declared on is mounted with a prefix that is not a string literal, so the binding names no path and nothing pairs with it

shop/routers/items.py
├─ current_user  (line 24)
│      -> delegate -> unknown
│
├─ GET /api/items/{item_id}  (fastapi handler | line 32)
│      -> 200 ItemResponse
│
├─ POST /api/items  (fastapi handler | line 37 | wrapped by current_user (shop/routers/items.py))
│      -> 201 ItemResponse
│
└─ GET /api/items/{item_id}/stock  (fastapi handler | line 42)
       if  item_id > 10
         -> 404
           + HTTPException
       else
         -> 200 { count }

Three things in that output need a word of explanation.

suss prints /api/items/{item_id} as one path, even though no single file writes it out. The router declares prefix="/items", and app.include_router(items_router, prefix="/api") mounts it under a second prefix. The pack joins the two when both are string literals, and it follows one mount hop.

read_stock shows what the adapter does with a body. Each return becomes a branch with its status and the conditions that lead to it, and raise HTTPException(status_code=404, ...) becomes the 404. FastAPI resolves Depends(current_user) on create_item itself, and the caller never sends it, so suss leaves it out of the request.

The two GET ? lines are routes whose path the source does not state. One reads its path from settings, and the other is mounted under a prefix that comes from the environment. suss keeps their names, gives them no path, and prints why. A route with no path pairs with nothing, and a guessed path would pair it with somebody else's handler, so every finding about it would be about the wrong route.

What extract reads, and from where:

  • Every .py file under the directory, skipping __pycache__, .venv, venv, node_modules and .git. Name a subset with --files.
  • The directory you pointed at, plus each checked-out submodule, as the roots an absolute import resolves against. That is the closest thing a Python project has to a tsconfig's paths.
  • The source directories pyproject.toml declares, as more roots: setuptools package-dir and packages.find, hatch packages, and poetry packages. A project that declares none and keeps a package under src/ gets src/. So from orders.routes import router resolves when the package is at src/orders, and running from the repository root reads the same routes as running with --dir src. When pyproject.toml cannot be parsed, the run says so and still tries src/. suss marks a module found under two roots as ambiguous and does not resolve it, because only the sys.path of a running interpreter decides which one wins. suss reports a submodule that was never checked out, and with -o it also writes a note next to the summaries, so a CI job reading the summaries can tell the run was incomplete.
  • The packs. Decorator names come only from the packs. No other part of the adapter contains one.

suss works out that a directory is Python from a pyproject.toml, a requirements file, setup.py or Pipfile, and failing those from the .py files themselves. The packs you ask for also settle it, and --lang python sets it directly.

flask-restx ​

Each HTTP-verb-named method on a decorated Resource class becomes its own route, the verb from the method name and the path from the decorator's first string argument. Werkzeug converters are canonicalized, so /orders/<int:order_id> comes out as /orders/{order_id} with order_id as a path parameter. A method with a return annotation gets one transition for that type under a 200. @ns.marshal_with and @ns.expect are not read yet.

A route declared on a namespace is served under the namespace's own path, and the pack composes the two:

python
from flask_restx import Namespace

ns = Namespace("orders", path="/orders")

@ns.route("/<int:order_id>")
class OrderDetail:
    def get(self, order_id): ...

With api.add_namespace(ns) somewhere in the files the run reads, that comes out as GET /orders/{order_id}. For the composition to happen, the namespace has to be constructed with a literal path and mounted once, through a variable, by an add_namespace that gives no path of its own. Written any other way, the route is still discovered under its name, with no path and a recorded reason. The README of @suss/adapter-python has a table of what every spelling means at each site.

FastAPI ​

  • The verb comes from the decorator's own attribute name, so @app.post("/orders") is a POST. The app or router is recognized by construction, app = FastAPI() or router = APIRouter(), one assignment back from an import of fastapi.
  • response_model= and status_code= are taken as what the route declares. With neither written, the return annotation supplies the body. With a body read and no status written, the status is 200.
  • A route on a router composes its path from the router's own prefix and the prefix at the include_router(...) call that mounts it, one hop deep, when both are string literals.

Dependencies, middleware and mounted sub-apps are not read yet.

Ruby ​

The rails pack needs nothing from you: root defaults to app and routesFile to config/routes.rb.

bash
npx @suss/cli extract --dir fixtures/ruby-rails -f rails -f activerecord=suss.activerecord.json -o summaries/rails.json
npx @suss/cli inspect summaries/rails.json

suss.activerecord.json sets which database is behind the connection. ActiveRecord works with several, and only database.yml records which one a project uses:

json
{ "storageSystem": "postgresql" }
app/controllers/profiles_controller.rb
├─ GET /profile  (rails handler | line 4 | wrapped by require_login (app/controllers/application_controller.rb), not_found (app/controllers/application_controller.rb) on a throw)
│      if  session[:user_id] == null
│        -> 401  (from require_login)
│      else
│        -> 200
│          + app/services/order_service.find_order →
│
│    Reaches:
│      reads postgresql:Order  through OrderService.new.find_order
│
└─ PATCH /profile  (rails handler | line 8 | wrapped by require_login (app/controllers/application_controller.rb), not_found (app/controllers/application_controller.rb) on a throw)
       if  session[:user_id] == null
         -> 401  (from require_login)
       else
         -> 200
           + app/services/order_service.cancel_order →

     Reaches:
       writes postgresql:Order  through OrderService.new.cancel_order

config/routes.rb
└─ routes  (line 1 | confidence: low)
       -> void

       !! config/routes.rb also declares mount, which this pack does not read; whatever those declarations route is missing from what suss reports

Every instance method that a controller extending ApplicationController defines directly is one of its actions, and suss takes the method and path from config/routes.rb. before_action filters come through as wrappers, so the summary records that the 401 on both actions comes from require_login. Under Reaches:, suss follows the call into the service object and prints the table at the other end, with the chain of calls that leads there.

The pack reads most of the routes grammar:

  • resources and resource, with only:, except:, controller:, path: and module:
  • member and collection blocks, and the on: :member keyword form
  • nested resources to any depth
  • namespace, and scope with a positional path, path: or module:
  • the bare get/post/patch/put/delete calls, with to:, with controller: and action:, or in the "path" => "controller#action" spelling
  • root, both spellings
  • match with via:, bound to the first verb listed, and via: :all as the wildcard method
  • constraints, walked as if the block were not there, since it only narrows matching
  • concern and concerns, in both spellings
  • draw(:name), read from config/routes/name.rb under the scope the draw was written in
  • mount, for an engine the project keeps in its own tree, which the engineRoots option points at

When the pack cannot read a declaration, it records the gap instead of guessing. The mount line above is one of these: the mount Sidekiq::Web in the routes file points at a gem's engine outside engineRoots, and the gap records which call went unread. direct, param: on a resource and a gem routing call like devise_for go the same way.

An action the routes file does not reach is still discovered, with its calls followed, only with no boundary. When the routes file is missing altogether, every action named for one of Rails' seven conventional actions is bound at the path Rails' own naming convention gives it, and the run records that it did so.

graphql-ruby ​

bash
npx @suss/cli extract --dir . -f graphql-ruby=suss.graphql-ruby.json -o summaries/schema.json
json
{ "root": "app/graphql" }
OptionDefaultWhat it does
rootrequiredThe directory suss looks under for the class a mutation: or resolver: reference points at, when no file the run reads declares it. It follows Rails' constant-to-path convention, so Mutations::CampaignUpdate is read from <root>/mutations/campaign_update.rb. Your layout is your project's, so there is no default and the pack reads nothing without one. A relative path is read against the config file it was written in.
camelizetruegraphql-ruby's schema-wide default for exposing a snake_case symbol camelCased. Set it to false when your schema does. A field or argument call's own camelize: still wins for that one name, as it does at runtime.

A class extending one of graphql-ruby's generated base classes has each field call in its body turned into a resolver, named Campaign.id from the class's short name with a trailing Type stripped. The binding is graphql-resolver(typeName, fieldName), so it pairs against a client operation exactly as a NestJS or Apollo resolver summary does. The adapter follows a class's whole ancestry, so you do not have to declare an intermediate base class of your own.

field :campaign_update, mutation: Mutations::CampaignUpdate is followed to the class it points at. suss finds that class the way it finds a call's receiver, in whichever file of the run declares it, so a mutation kept in an engine or a pack directory is read too. The class's own field and argument calls become the payload and the arguments, and the field's summary describes its resolve method. An argument counts as required unless it declares otherwise. A ready? or authorized? hook on the class gets a summary of its own, and a path through it that raises or returns false shows up on the field as an outcome where resolve never runs. field :total, Integer, resolver_method: :count_all is read from the type's count_all method.

When suss cannot read a field in Ruby, it leaves that one field without a contract, the same way it leaves one route without a path in Python. field :status, status_label_for(:organizer) is discovered as Organizer.status with no declared contract at all, instead of a contract that claims the type is unknown. A mutation: reference to a class that no file in the run declares, and that is not where the convention puts it under root, gives you the field and no payload.

A Gemfile, a Gemfile.lock or a Rails config/application.rb is enough for suss to read a directory as Ruby, and --lang ruby states it directly. The walk reads every .rb file, skipping vendor, node_modules, tmp and .git. Ruby constants resolve through class and module nesting, and suss does not follow require, so Ruby has no equivalent of Python's import roots.

Configuring a pack ​

Most packs need nothing from you, because everything they match on is something their library defines. When a pack does need something from you about your project, write it to a JSON file and give the file name on the flag:

bash
npx @suss/cli extract --lang ruby --dir . -f graphql-ruby=suss.graphql-ruby.json

A pack that cannot work without a value stops and says so, instead of quietly reading half a project. A relative path in an option is read against the config file itself, so the same file works whichever directory you run from. In a config file kept outside the project, write paths as absolute ones. A relative one points at a folder that does not exist, and the run reports it as no-path in the pack health block and as packPathMissing in check --json.

A pack config states something about your own project. A fact about a package you depend on, such as a module of yours that re-exports a framework, goes in a dependency stub instead.

Your own wrapper around a route decorator ​

Most services wrap flask-restx's route decorator in a module of their own instead of importing it in every route file:

python
# myapp/wrappers/restx.py
from flask_restx import Namespace

api = Namespace("todos")

def route(path):
    return api.route(path)
python
# myapp/routes/todos.py
from myapp.wrappers.restx import route

@route("/todos")
class TodoList:
    def get(self): ...
    def post(self): ...

A dependency stub points at that module:

yaml
# suss/stubs/restx-wrapper.yaml
package: myapp.wrappers.restx
statements:
  - kind: re-exports
    of: flask_restx

The pack then accepts a route decorator imported from your wrapper alongside one imported from flask_restx itself. An aliased import (from myapp.wrappers.restx import route as api_route) resolves the same way. The pack hardcodes only what flask-restx defines. What you called your wrapper is your own choice, so it comes in through the stub.

suss infer stub myapp reads the project's own imports and drafts it, one file per wrapper module it finds. The fastapi pack reads the same re-exports statement, for a module of yours that re-exports FastAPI's own constructors.

A base class that comes from a gem is the Ruby equivalent:

yaml
# suss/stubs/acme-graphql.yaml
package: acme-graphql
statements:
  - kind: extends-base
    class: Acme::GraphQL::AuthenticatedObject
    extends: Acme::GraphQL::BaseObject

The value of extends: picks which pack gets the class. A graphql-ruby root class sends it to graphql-ruby, a Rails root class sends it to rails, and a class in neither list goes to both.

What a file reads from the environment ​

Both adapters record the config a project reads from the environment, and both follow the read through a helper the project wrote. A Python project with a setting() helper:

python
# shop/config.py
import os

def setting(name, default=None):
    return os.environ.get(name, default)
python
# shop/main.py
@app.get("/health")
def health():
    return {"region": setting("SHOP_REGION"), "tier": setting("SHOP_TIER")}
shop/main.py
└─ GET /health  (fastapi handler | line 9)
       -> 200 { region, tier }
         + shop/config.setting →
         + shop/config.setting →
         + reads runtime-config SHOP_REGION
         + reads runtime-config SHOP_TIER

suss reports the variable names on the route that reads them, and not on the helper. suss reads Ruby the same way, through ENV["X"], ENV.fetch("X") and a helper in front of either:

app/controllers/health_controller.rb
├─ GET /health  (rails handler | line 2)
│      -> 200
│        + setting →
│        + setting →
│        + reads runtime-config SHOP_REGION
│        + reads runtime-config SHOP_TIER

suss ask "what does app/controllers/health_controller.rb reach" lists them for a file.

What you do not get yet ​

  • Raw SQL is Python only. A statement passed to SQLAlchemy's or SQLModel's text("...") is read for its kind and its table. A bare string passed to session.execute is not parsed, and suss does not read raw SQL in Ruby at all.
  • Queue sends are not classified in either language. suss records the calls as invocation effects. Nothing turns one into a channel the way the TypeScript packs do.
  • Plain Flask (@app.route) has no pack yet.
  • graphql-ruby leaves some of the schema unread: interfaces, unions and enums. A field's output reads its object type one level deep, so a type nested inside that one is named and not read.
  • Ruby's predicates are plain source text instead of structured ones, so a condition prints exactly as it was written.
  • suss corroborate is TypeScript only.

For more detail, read the package READMEs under packages/adapter/python and packages/adapter/ruby, and the one next to each pack under packages/framework and packages/client.

Released under the Apache-2.0 License.