Skip to content

CLI

The typedframes command-line interface runs the Rust-based static checker on Python source files.

Basic usage

# Check a single file
typedframes check pipeline.py

# Check an entire directory (builds a cross-file project index)
typedframes check src/

# Check without building the project index (each file checked independently)
typedframes check src/ --no-index

# Downgrade untracked-dataframe from a warning to a quiet info-level note for bare
# DataFrame loads (warning by default)
typedframes check src/ --lenient-ingest

# Output formats
typedframes check src/ --output-format text    # default — ty-style, auto-colored in terminal
typedframes check src/ --output-format json    # machine-readable JSON
typedframes check src/ --output-format github  # GitHub Actions annotations

Supported file formats

The checker reads column information from load calls for all common formats:

# pandas
pd.read_csv("data.csv", usecols=["a", "b"])
pd.read_parquet("data.parquet", columns=["a", "b"])
pd.read_json("data.json", dtype={"a": int, "b": str})
pd.read_excel("data.xlsx", usecols=["a", "b"])

# polars
pl.read_csv("data.csv", columns=["a", "b"])
pl.read_parquet("data.parquet", columns=["a", "b"])
pl.read_json("data.json", schema={"a": int, "b": str})

Any usecols= (pandas) or columns= / schema= (polars) argument teaches the checker which columns are available, regardless of file format.

SQL and warehouse column inference

For database/warehouse loads, the checker infers columns from the query's SELECT list instead of a kwarg:

pd.read_sql("SELECT a, b FROM t", conn)
pd.read_sql_query("SELECT a, b FROM t", conn)
pl.read_database("SELECT a, b FROM t", connection)
pl.read_database_uri("SELECT a, b FROM t", uri)
pd.read_gbq("SELECT a, b FROM t", project_id=...)

This also traces the query text back through:

  • a single-assignment variable (QUERY = "SELECT a, b FROM t", used later) — a variable assigned more than once anywhere in the file is not resolved, since the checker can't know which assignment was in effect at the call site;
  • a .sql file (Path("query.sql").read_text(), open("query.sql").read(), project-root-relative only);
  • SQLAlchemy's text(...) wrapper, select(Model.col1, Model.col2, ...) (Core, against a declarative model's columns), and connector-specific shapes like client.query(sql).to_dataframe() (BigQuery), spark.sql(sql).toPandas() (PySpark/Databricks), duckdb.sql(sql).df(), and the cursor.execute(sql) / cursor.fetch_pandas_all() pattern (Snowflake, Redshift).

An f-string or .format()-built query is deliberately not resolved — that's the SQL injection anti-pattern parameterized queries exist to avoid — and falls through to untracked-dataframe like any other unresolvable load, without a separate injection warning (the checker has no taint analysis to distinguish a safe interpolation from a real vulnerability).

Set sql_dialect in pyproject.toml to fold identifier case the way a specific engine does (e.g. Snowflake upper-cases unquoted identifiers, Postgres/Redshift lower-case them) — see Project-level configuration. Full examples for eleven connectors, including Feast and SQLAlchemy, live in the repo's examples/sql_connectors/ directory (snowflake/, bigquery/, athena/, redshift/, databricks/, pyspark/, duckdb/, connectorx/, sqlalchemy/, feast/, azure_synapse/).

A wrapper function that queries one of these connectors and then case-folds the result (e.g. an internal package that queries Snowflake and lower-cases its genuinely-upper-cased columns before returning) is also traced, cross-file, via .rename(columns=str.lower) / df.columns = df.columns.str.lower() (and the .upper() equivalents) — see usage.md's "Supported column-set transforms" for exactly what's recognized and why arbitrary custom transform functions aren't.

Output format

src/pipeline.py:42:8: error[unknown-column] Column 'revenue' not in OrderSchema
src/pipeline.py:57:8: error[reserved-name] Column 'user_id' renamed to 'customer_id', use 'customer_id'
src/pipeline.py:10:1: warning[untracked-dataframe] columns unknown at lint time; specify usecols= or annotate
src/pipeline.py:12:5: error[missing-column] 'customers' passed to contact_label (transforms.py:2) is missing
  column(s) {email} — available: {customer_id, name, region}, required: {email, name}

The format matches ty and ruff: file:line:col: severity[code] message. Most editors, CI systems, and LSP clients parse this automatically. Colors are applied when the output is a terminal (TTY); piping or redirecting strips them.

Error codes

Code Meaning Default
unknown-column Column not found in schema or inferred set Always shown
reserved-name Column was renamed — use the new name Always shown
untracked-dataframe Bare DataFrame load — no column info for checker Always shown (use --lenient-ingest to downgrade to info)
dropped-unknown-column Dropped column doesn't exist in schema Always shown
missing-column Argument's columns don't satisfy the called function's parameter contract Always shown

Project-level configuration

Add to pyproject.toml to disable all warnings project-wide:

[tool.typedframes]
enabled  = true
warnings = false

Set sql_dialect to fold unquoted SQL identifier case the way a specific engine does when inferring columns from a SELECT list (see SQL and warehouse column inference). Unset or unrecognized values default to no folding (columns keep the exact case written in the query).

[tool.typedframes]
sql_dialect = "snowflake"  # one of: bigquery, snowflake, redshift, databricks,
                           # duckdb, postgres, mysql, hive, spark, mssql
                           # (mssql aliases: sqlserver, synapse, fabric)

SQL parsing also accepts T-SQL's [bracket-quoted] identifiers (SQL Server, Azure SQL, Synapse, Fabric Warehouse) alongside standard "double-quoted" ones, regardless of sql_dialect.

Tracing installed (non-project) packages

Only files under the project itself are indexed by default — nothing inside .venv or any other installed-dependency location — except an installed package whose function your own first-party code calls in a way that looks like a DataFrame source: the result of an unrecognized call is later subscripted, or has a pandas/polars method called on it. That package's Annotated[...]/BaseSchema declarations and recognized transform patterns (rename, drop, case-fold, etc. — the same fixed set already applied to first-party code) are then indexed automatically, the same as any project-local helper. This is what makes a company-internal package (e.g. one that wraps a SQL connector) traceable with no configuration: your own code calling it in that shape is the signal.

# your project's own code — no config needed for this to get traced
from internal_snowflake_pkg import load_orders

orders = load_orders(query)  # unrecognized call...
print(orders["order_id"])  # ...but subscripted like a DataFrame — traced automatically

Auto-discovery only ever considers packages your own code actually imports and calls this way — it never scans the whole site-packages tree, so pandas/polars/numpy/etc. being installed (and never having a call site of their own left unresolved, since pd.read_csv-style calls are always recognized directly) never triggers tracing into their own, often enormous, internal source. Auto-discovered candidates are also capped at 20 packages per project, so a codebase that happens to call many different external packages in DataFrame-shaped ways can't balloon index-build cost; trace_external_packages entries are never subject to that cap.

Two settings give you explicit control over this:

[tool.typedframes]
trace_external_packages = ["internal_snowflake_pkg"]      # force-trace, even if auto-discovery wouldn't catch it
excluded_external_packages = ["some_huge_or_untrusted_pkg"] # opt out, even if auto-discovery would have traced it
  • trace_external_packages always wins — a package named there is traced regardless of excluded_external_packages or the auto-discovery cap. Use it for a package whose usage pattern is too indirect for auto-discovery to catch (e.g. passed as a callback rather than called directly).
  • excluded_external_packages only suppresses auto-discovered candidates. It has no effect on a package also named in trace_external_packages.
  • The package's install location is auto-detected from the project's own .venv (lib/pythonX.Y/site-packages on Unix, Lib/site-packages on Windows) — there is no path override in this version, and no other virtualenv-manager layout is searched.
  • Only the resolved packages' own directories are walked — never the whole site-packages tree.
  • Editable installs are not supported in this version. A package installed via pip install -e (or uv's equivalent) resolves through a .pth/direct_url.json redirect rather than living directly under site-packages, and isn't found by the current auto-detection. Install the package normally (a real, non-editable install) for it to be traced.
  • Indexing an external package means trusting its source enough to run static analysis over it. Auto-discovery only ever adds packages your own project's code demonstrably calls in a DataFrame-shaped way, never anything installed but unused this way — but if you want a hard guarantee that nothing outside the project is ever indexed without an explicit name, list the specific packages to keep out in excluded_external_packages. There is no single setting to disable auto-discovery project-wide in this version; exclusion is by exact package name.

Excluding directories

The following are pruned by default — no config needed:

.bzr  .claude  .direnv  .eggs  .git  .git-rewrite  .hg  .ipynb_checkpoints
.mypy_cache  .nox  .pants.d  .pytest_cache  .pytype  .ruff_cache  .svn  .tox
.venv  .vscode  .idea  __pycache__  _build  buck-out  build  dist
node_modules  site-packages  venv

exclude replaces this default list entirely — matching by bare directory name, not a path/glob pattern:

[tool.typedframes]
exclude = [".claude", "legacy"]

This mirrors ruff's own exclude (there's no separate extend-exclude-style option here): once exclude is set, only the names you list are pruned, so re-list .venv (or anything else from the default set you still want ignored) alongside your own additions, or it'll be walked. An explicit exclude = [] is a deliberate way to prune nothing at all.

Applies both when checking a directory directly (typedframes check .) and to the cross-file project index — a single exclude value controls both.


typedframes.cli.main(argv=None)

Entry point for the typedframes CLI.

Source code in src/typedframes/cli.py
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
def main(argv: list[str] | None = None) -> None:
    """Entry point for the typedframes CLI."""
    parser = argparse.ArgumentParser(prog="typedframes", description="Static analysis for DataFrame column schemas.")
    subparsers = parser.add_subparsers(dest="command")

    check_parser = subparsers.add_parser("check", help="Check Python files for column errors.")
    check_parser.add_argument("path", type=Path, help="File or directory to check.")
    check_parser.add_argument("--strict", action="store_true", help="Exit with code 1 if any errors are found.")
    check_parser.add_argument(
        "--output-format",
        choices=["text", "json", "github"],
        default="text",
        dest="output_format",
        help="Output format: text (default), json, or github (GitHub Actions annotations).",
    )
    # --json kept as a hidden alias for backward compatibility
    check_parser.add_argument(
        "--json",
        dest="output_format",
        action="store_const",
        const="json",
        help=argparse.SUPPRESS,
    )
    check_parser.add_argument("--no-index", action="store_true", help="Disable cross-file index.")
    check_parser.add_argument(
        "--no-warnings",
        action="store_true",
        help="Suppress all warnings (dropped-unknown-column and untracked-dataframe).",
    )
    check_parser.add_argument(
        "--strict-ingest",
        action="store_true",
        help="Deprecated, no-op: untracked-dataframe is a warning-level diagnostic by default now.",
    )
    check_parser.add_argument(
        "--lenient-ingest",
        action="store_true",
        help=(
            "Downgrade untracked-dataframe from a warning-level diagnostic back to "
            "info-level for bare DataFrame loads without usecols= or columns=."
        ),
    )
    check_parser.add_argument(
        "--no-info",
        action="store_true",
        help="Suppress informational output: the DataFrame schema coverage summary and info-level diagnostics.",
    )
    check_parser.add_argument(
        "--fail-under",
        type=_percentage,
        default=None,
        dest="fail_under",
        metavar="N",
        help=(
            "Enforce a minimum DataFrame schema coverage: exit 1 if fewer than N%% of "
            "DataFrames have recognized column info. A total override: it applies one "
            "threshold everywhere and ignores [tool.typedframes.coverage] entirely, "
            "per-path overrides included."
        ),
    )
    check_parser.add_argument(
        "--coverage-report",
        choices=list(_COVERAGE_REPORTS),
        default=None,
        dest="coverage_report",
        help=(
            "DataFrame schema coverage detail to print: summary (default, one line), term-missing "
            "(per-file table plus the DataFrame sites lacking column info), or json. "
            "Overrides the `report` key in [tool.typedframes.coverage]."
        ),
    )

    args = parser.parse_args(argv)

    if args.command != "check":
        parser.print_help()
        sys.exit(2)

    _run_check(args)