typedframes 0.4.0__tar.gz → 0.4.1__tar.gz

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.
Files changed (36) hide show
  1. {typedframes-0.4.0 → typedframes-0.4.1}/PKG-INFO +16 -16
  2. {typedframes-0.4.0 → typedframes-0.4.1}/README.md +15 -15
  3. {typedframes-0.4.0 → typedframes-0.4.1}/pyproject.toml +5 -5
  4. {typedframes-0.4.0 → typedframes-0.4.1}/rust/Cargo.lock +1 -1
  5. {typedframes-0.4.0 → typedframes-0.4.1}/rust/Cargo.toml +1 -1
  6. {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/config.rs +32 -0
  7. {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/index.rs +214 -23
  8. {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/linter.rs +986 -178
  9. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/__init__.py +1 -1
  10. {typedframes-0.4.0 → typedframes-0.4.1}/rust/.cargo/config.toml +0 -0
  11. {typedframes-0.4.0 → typedframes-0.4.1}/rust/README.md +0 -0
  12. {typedframes-0.4.0 → typedframes-0.4.1}/rust/benches/parser_bench.rs +0 -0
  13. {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/ast_extract.rs +0 -0
  14. {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/constants.rs +0 -0
  15. {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/contract.rs +0 -0
  16. {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/errors.rs +0 -0
  17. {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/lib.rs +0 -0
  18. {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/main.rs +0 -0
  19. {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/notebook.rs +0 -0
  20. {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/pyapi.rs +0 -0
  21. {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/sql.rs +0 -0
  22. {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/typo.rs +0 -0
  23. {typedframes-0.4.0 → typedframes-0.4.1}/rust/tests/integration_test.rs +0 -0
  24. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/base_schema.py +0 -0
  25. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/cli.py +0 -0
  26. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/column.py +0 -0
  27. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/column_group.py +0 -0
  28. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/column_group_error.py +0 -0
  29. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/column_set.py +0 -0
  30. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/missing_dependency_error.py +0 -0
  31. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/mypy.py +0 -0
  32. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/pandas.py +0 -0
  33. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/pandera.py +0 -0
  34. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/polars.py +0 -0
  35. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/py.typed +0 -0
  36. {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/schema_algebra.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: typedframes
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -42,7 +42,7 @@ Project-URL: Repository, https://github.com/w-martin/typedframes
42
42
 
43
43
  > ⚠️ **Project Status: Proof of Concept**
44
44
  >
45
- > `typedframes` (v0.4.0) is currently an experimental proof-of-concept. The core static analysis and mypy/Rust
45
+ > `typedframes` (v0.4.1) is currently an experimental proof-of-concept. The core static analysis and mypy/Rust
46
46
  > integrations work, but expect rough edges. The codebase prioritizes demonstrating the viability of static DataFrame
47
47
  > column checking over production-grade stability.
48
48
  >
@@ -591,17 +591,17 @@ Notes:
591
591
  Fast feedback reduces development time. The typedframes Rust binary provides near-instant column checking.
592
592
 
593
593
  **Benchmark results** (20 runs, 3 warmup, caches cleared between runs):
594
- *2026-08-12 · Darwin 25.6.0 · arm · CPython 3.14.4 · 64GiB RAM · Great Expectations pinned @ 1.9.3*
594
+ *2026-08-18 · Darwin 25.6.0 · arm · CPython 3.14.4 · 64GiB RAM · Great Expectations pinned @ 1.20.0*
595
595
 
596
- | Tool | Version | What it does | typedframes (13 files) | great_expectations (482 files) |
596
+ | Tool | Version | What it does | typedframes (13 files) | great_expectations (485 files) |
597
597
  |------|---------|--------------|------------------------|--------------------------------|
598
- | typedframes | 0.4.0 | DataFrame column checker | 47ms ±998µs (IQR 1ms) | 183ms ±2ms (IQR 2ms) |
599
- | ruff | 0.16.2 | Linter (no type checking) | 29ms ±677µs (IQR 729µs) | 208ms ±2ms (IQR 3ms) |
600
- | ty | 0.0.69 | Type checker | 74ms ±900µs (IQR 1ms) | 941ms ±14ms (IQR 18ms) |
601
- | pyrefly | 1.2.0 | Type checker | 96ms ±2ms (IQR 2ms) | 275ms ±7ms (IQR 14ms) |
602
- | mypy | 2.3.0 | Type checker (no plugin) | 2.78s ±47ms (IQR 84ms) | 4.25s ±48ms (IQR 76ms) |
603
- | mypy + typedframes | 2.3.0 | Type checker + column checker | 2.74s ±29ms (IQR 34ms) | 4.44s ±54ms (IQR 105ms) |
604
- | pyright | 1.1.411 | Type checker | 808ms ±24ms (IQR 21ms) | 3.45s ±93ms (IQR 138ms) |
598
+ | typedframes | 0.4.1 | DataFrame column checker | 51ms ±918µs (IQR 1ms) | 219ms ±2ms (IQR 4ms) |
599
+ | ruff | 0.16.3 | Linter (no type checking) | 30ms ±764µs (IQR 922µs) | 233ms ±3ms (IQR 4ms) |
600
+ | ty | 0.0.72 | Type checker | 73ms ±1ms (IQR 2ms) | 810ms ±10ms (IQR 13ms) |
601
+ | pyrefly | 1.2.0 | Type checker | 104ms ±2ms (IQR 2ms) | 276ms ±9ms (IQR 14ms) |
602
+ | mypy | 2.3.1 | Type checker (no plugin) | 3.07s ±17ms (IQR 25ms) | 4.56s ±29ms (IQR 57ms) |
603
+ | mypy + typedframes | 2.3.1 | Type checker + column checker | 3.07s ±13ms (IQR 17ms) | 4.84s ±20ms (IQR 23ms) |
604
+ | pyright | 1.1.411 | Type checker | 781ms ±5ms (IQR 6ms) | 3.46s ±25ms (IQR 41ms) |
605
605
 
606
606
  *Run `uv run python benchmarks/benchmark_checkers.py` to reproduce.*
607
607
 
@@ -815,7 +815,7 @@ Comprehensive comparison of pandas/DataFrame typing and validation tools. **type
815
815
 
816
816
  | Feature | typedframes | Pandera | Great Expectations | strictly_typed_pandas | pandas-stubs | dataenforce | pandas-type-checks | StaticFrame | narwhals | dataframely | patito |
817
817
  |---------------------------------|------------------------|-------------|--------------------|-----------------------|--------------|-------------|--------------------|------------------|----------|------------------|------------------|
818
- | **Version tested** | 0.4.0 | 0.32.1 | 1.19.1 | 0.3.7 | 3.0.5 | 0.1.2 | 1.1.3 | 5.0.0 | 2.24.0 | 3.0.0 | 0.8.6 |
818
+ | **Version tested** | 0.4.1 | 0.32.1 | 1.20.0 | 0.3.7 | 3.0.5 | 0.1.2 | 1.1.3 | 5.1.1 | 2.24.0 | 3.0.0 | 0.8.6 |
819
819
  | **Analysis Type** |
820
820
  | When errors are caught | **Static (lint-time)** | Runtime | Runtime | Runtime | Static | Runtime | Runtime | Runtime | Runtime | Runtime | Runtime |
821
821
  | **Static Analysis (our focus)** |
@@ -860,7 +860,7 @@ Comprehensive comparison of pandas/DataFrame typing and validation tools. **type
860
860
  - **[pandas-type-checks](https://pypi.org/project/pandas-type-checks/)** (v1.1.3): Runtime validation decorator. No
861
861
  static analysis.
862
862
 
863
- - **[StaticFrame](https://github.com/static-frame/static-frame)** (v5.0.0): Alternative immutable DataFrame library.
863
+ - **[StaticFrame](https://github.com/static-frame/static-frame)** (v5.1.1): Alternative immutable DataFrame library.
864
864
  Not compatible with pandas/polars — requires a full rewrite to StaticFrame's own API. Column access is still
865
865
  string-based; mypy does not catch column name typos. Type safety comes from immutability guarantees, not schema checking.
866
866
 
@@ -869,7 +869,7 @@ Comprehensive comparison of pandas/DataFrame typing and validation tools. **type
869
869
  type safety. See [Why Abstraction Layers Don't Solve Type Safety](#why-abstraction-layers-dont-solve-type-safety)
870
870
  below.
871
871
 
872
- - **[Great Expectations](https://greatexpectations.io/)** (v1.19.1): Comprehensive data quality framework. Defines
872
+ - **[Great Expectations](https://greatexpectations.io/)** (v1.20.0): Comprehensive data quality framework. Defines
873
873
  "expectations" (assertions) about data values, distributions, and schema properties. Excellent for runtime
874
874
  validation, data documentation, and data quality monitoring. No static analysis or column-level type checking in
875
875
  code. Supports pandas, Spark, and SQL backends.
@@ -892,10 +892,10 @@ Comprehensive comparison of pandas/DataFrame typing and validation tools. **type
892
892
  These are general Python type checkers. They don't validate DataFrame column names, but they can be used alongside
893
893
  typedframes for comprehensive type checking:
894
894
 
895
- - **[mypy](https://mypy-lang.org/)** (v2.3.0): The original Python type checker. typedframes provides a mypy plugin for
895
+ - **[mypy](https://mypy-lang.org/)** (v2.3.1): The original Python type checker. typedframes provides a mypy plugin for
896
896
  column checking. See [performance benchmarks](#static-analysis-performance).
897
897
 
898
- - **[ty](https://github.com/astral-sh/ty)** (v0.0.69, Astral): New Rust-based type checker, faster than mypy on
898
+ - **[ty](https://github.com/astral-sh/ty)** (v0.0.72, Astral): New Rust-based type checker, faster than mypy on
899
899
  large codebases. Does not support mypy plugins—use typedframes standalone checker.
900
900
 
901
901
  - **[pyrefly](https://pyrefly.org/)** (v1.2.0, Meta): Rust-based type checker from Meta, replacement for Pyre. Fast,
@@ -8,7 +8,7 @@
8
8
 
9
9
  > ⚠️ **Project Status: Proof of Concept**
10
10
  >
11
- > `typedframes` (v0.4.0) is currently an experimental proof-of-concept. The core static analysis and mypy/Rust
11
+ > `typedframes` (v0.4.1) is currently an experimental proof-of-concept. The core static analysis and mypy/Rust
12
12
  > integrations work, but expect rough edges. The codebase prioritizes demonstrating the viability of static DataFrame
13
13
  > column checking over production-grade stability.
14
14
  >
@@ -557,17 +557,17 @@ Notes:
557
557
  Fast feedback reduces development time. The typedframes Rust binary provides near-instant column checking.
558
558
 
559
559
  **Benchmark results** (20 runs, 3 warmup, caches cleared between runs):
560
- *2026-08-12 · Darwin 25.6.0 · arm · CPython 3.14.4 · 64GiB RAM · Great Expectations pinned @ 1.9.3*
560
+ *2026-08-18 · Darwin 25.6.0 · arm · CPython 3.14.4 · 64GiB RAM · Great Expectations pinned @ 1.20.0*
561
561
 
562
- | Tool | Version | What it does | typedframes (13 files) | great_expectations (482 files) |
562
+ | Tool | Version | What it does | typedframes (13 files) | great_expectations (485 files) |
563
563
  |------|---------|--------------|------------------------|--------------------------------|
564
- | typedframes | 0.4.0 | DataFrame column checker | 47ms ±998µs (IQR 1ms) | 183ms ±2ms (IQR 2ms) |
565
- | ruff | 0.16.2 | Linter (no type checking) | 29ms ±677µs (IQR 729µs) | 208ms ±2ms (IQR 3ms) |
566
- | ty | 0.0.69 | Type checker | 74ms ±900µs (IQR 1ms) | 941ms ±14ms (IQR 18ms) |
567
- | pyrefly | 1.2.0 | Type checker | 96ms ±2ms (IQR 2ms) | 275ms ±7ms (IQR 14ms) |
568
- | mypy | 2.3.0 | Type checker (no plugin) | 2.78s ±47ms (IQR 84ms) | 4.25s ±48ms (IQR 76ms) |
569
- | mypy + typedframes | 2.3.0 | Type checker + column checker | 2.74s ±29ms (IQR 34ms) | 4.44s ±54ms (IQR 105ms) |
570
- | pyright | 1.1.411 | Type checker | 808ms ±24ms (IQR 21ms) | 3.45s ±93ms (IQR 138ms) |
564
+ | typedframes | 0.4.1 | DataFrame column checker | 51ms ±918µs (IQR 1ms) | 219ms ±2ms (IQR 4ms) |
565
+ | ruff | 0.16.3 | Linter (no type checking) | 30ms ±764µs (IQR 922µs) | 233ms ±3ms (IQR 4ms) |
566
+ | ty | 0.0.72 | Type checker | 73ms ±1ms (IQR 2ms) | 810ms ±10ms (IQR 13ms) |
567
+ | pyrefly | 1.2.0 | Type checker | 104ms ±2ms (IQR 2ms) | 276ms ±9ms (IQR 14ms) |
568
+ | mypy | 2.3.1 | Type checker (no plugin) | 3.07s ±17ms (IQR 25ms) | 4.56s ±29ms (IQR 57ms) |
569
+ | mypy + typedframes | 2.3.1 | Type checker + column checker | 3.07s ±13ms (IQR 17ms) | 4.84s ±20ms (IQR 23ms) |
570
+ | pyright | 1.1.411 | Type checker | 781ms ±5ms (IQR 6ms) | 3.46s ±25ms (IQR 41ms) |
571
571
 
572
572
  *Run `uv run python benchmarks/benchmark_checkers.py` to reproduce.*
573
573
 
@@ -781,7 +781,7 @@ Comprehensive comparison of pandas/DataFrame typing and validation tools. **type
781
781
 
782
782
  | Feature | typedframes | Pandera | Great Expectations | strictly_typed_pandas | pandas-stubs | dataenforce | pandas-type-checks | StaticFrame | narwhals | dataframely | patito |
783
783
  |---------------------------------|------------------------|-------------|--------------------|-----------------------|--------------|-------------|--------------------|------------------|----------|------------------|------------------|
784
- | **Version tested** | 0.4.0 | 0.32.1 | 1.19.1 | 0.3.7 | 3.0.5 | 0.1.2 | 1.1.3 | 5.0.0 | 2.24.0 | 3.0.0 | 0.8.6 |
784
+ | **Version tested** | 0.4.1 | 0.32.1 | 1.20.0 | 0.3.7 | 3.0.5 | 0.1.2 | 1.1.3 | 5.1.1 | 2.24.0 | 3.0.0 | 0.8.6 |
785
785
  | **Analysis Type** |
786
786
  | When errors are caught | **Static (lint-time)** | Runtime | Runtime | Runtime | Static | Runtime | Runtime | Runtime | Runtime | Runtime | Runtime |
787
787
  | **Static Analysis (our focus)** |
@@ -826,7 +826,7 @@ Comprehensive comparison of pandas/DataFrame typing and validation tools. **type
826
826
  - **[pandas-type-checks](https://pypi.org/project/pandas-type-checks/)** (v1.1.3): Runtime validation decorator. No
827
827
  static analysis.
828
828
 
829
- - **[StaticFrame](https://github.com/static-frame/static-frame)** (v5.0.0): Alternative immutable DataFrame library.
829
+ - **[StaticFrame](https://github.com/static-frame/static-frame)** (v5.1.1): Alternative immutable DataFrame library.
830
830
  Not compatible with pandas/polars — requires a full rewrite to StaticFrame's own API. Column access is still
831
831
  string-based; mypy does not catch column name typos. Type safety comes from immutability guarantees, not schema checking.
832
832
 
@@ -835,7 +835,7 @@ Comprehensive comparison of pandas/DataFrame typing and validation tools. **type
835
835
  type safety. See [Why Abstraction Layers Don't Solve Type Safety](#why-abstraction-layers-dont-solve-type-safety)
836
836
  below.
837
837
 
838
- - **[Great Expectations](https://greatexpectations.io/)** (v1.19.1): Comprehensive data quality framework. Defines
838
+ - **[Great Expectations](https://greatexpectations.io/)** (v1.20.0): Comprehensive data quality framework. Defines
839
839
  "expectations" (assertions) about data values, distributions, and schema properties. Excellent for runtime
840
840
  validation, data documentation, and data quality monitoring. No static analysis or column-level type checking in
841
841
  code. Supports pandas, Spark, and SQL backends.
@@ -858,10 +858,10 @@ Comprehensive comparison of pandas/DataFrame typing and validation tools. **type
858
858
  These are general Python type checkers. They don't validate DataFrame column names, but they can be used alongside
859
859
  typedframes for comprehensive type checking:
860
860
 
861
- - **[mypy](https://mypy-lang.org/)** (v2.3.0): The original Python type checker. typedframes provides a mypy plugin for
861
+ - **[mypy](https://mypy-lang.org/)** (v2.3.1): The original Python type checker. typedframes provides a mypy plugin for
862
862
  column checking. See [performance benchmarks](#static-analysis-performance).
863
863
 
864
- - **[ty](https://github.com/astral-sh/ty)** (v0.0.69, Astral): New Rust-based type checker, faster than mypy on
864
+ - **[ty](https://github.com/astral-sh/ty)** (v0.0.72, Astral): New Rust-based type checker, faster than mypy on
865
865
  large codebases. Does not support mypy plugins—use typedframes standalone checker.
866
866
 
867
867
  - **[pyrefly](https://pyrefly.org/)** (v1.2.0, Meta): Rust-based type checker from Meta, replacement for Pyre. Fast,
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "typedframes"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "Static analysis for pandas and polars DataFrames. Catch column errors at lint-time, not runtime."
9
9
  keywords = ["pandas", "polars", "type-checking", "static-analysis", "dataframe", "linter", "mypy-plugin"]
10
10
  readme = "README.md"
@@ -138,14 +138,14 @@ dev = [
138
138
  "pytest>=9.1.1",
139
139
  "pytest-cov>=7.1.0",
140
140
  "pytest-xdist>=3.8.0",
141
- "ruff>=0.16.2",
142
- "ty>=0.0.69",
141
+ "ruff>=0.16.3",
142
+ "ty>=0.0.72",
143
143
  "openpyxl>=3.1.5",
144
- "pyarrow>=25.0.0",
144
+ "pyarrow>=25.0.1",
145
145
  "xlsxwriter>=3.2.9",
146
146
  "fastexcel>=0.20.2",
147
147
  "pandas-stubs>=3.0.5.260730",
148
- "mypy>=2.3.0",
148
+ "mypy>=2.3.1",
149
149
  ]
150
150
 
151
151
  [tool.bandit]
@@ -1347,7 +1347,7 @@ checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
1347
1347
 
1348
1348
  [[package]]
1349
1349
  name = "typedframes_checker"
1350
- version = "0.4.0"
1350
+ version = "0.4.1"
1351
1351
  dependencies = [
1352
1352
  "anyhow",
1353
1353
  "criterion",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "typedframes_checker"
3
- version = "0.4.0"
3
+ version = "0.4.1"
4
4
  edition = "2021"
5
5
  description = "A high-performance Rust-based checker for typedframes DataFrame column schemas"
6
6
  license = "MIT"
@@ -36,6 +36,14 @@ pub struct LinterConfig {
36
36
  // rather than indexing all of site-packages, which would be both expensive and a
37
37
  // much larger, unbounded trust surface.
38
38
  pub(crate) trace_external_packages: Option<Vec<String>>,
39
+ // Opt-out counterpart to the default-on candidate tracing (see
40
+ // `discover_external_package_candidates` in index.rs): a package named here is
41
+ // never traced, even if the project's own code calls it in a way that looks
42
+ // DataFrame-shaped. Does NOT affect `trace_external_packages` -- an explicit
43
+ // force-include there always wins, since naming a package in both lists is a
44
+ // contradiction the user should resolve, not one this checker should silently
45
+ // arbitrate.
46
+ pub(crate) excluded_external_packages: Option<Vec<String>>,
39
47
  // Directory names to prune when collecting `.py` files. When set, REPLACES the
40
48
  // built-in default set (`DEFAULT_EXCLUDED_DIRS` -- `.git`, `.venv`, `node_modules`,
41
49
  // `__pycache__`, `.claude`, etc.) entirely rather than adding to it, matching
@@ -54,6 +62,7 @@ impl LinterConfig {
54
62
  warnings: None,
55
63
  sql_dialect: None,
56
64
  trace_external_packages: None,
65
+ excluded_external_packages: None,
57
66
  exclude: None,
58
67
  };
59
68
  }
@@ -196,4 +205,27 @@ mod tests {
196
205
  Some(vec![".claude".to_string(), "vendor".to_string()])
197
206
  );
198
207
  }
208
+
209
+ #[test]
210
+ fn test_load_linter_config_reads_excluded_external_packages() {
211
+ let temp = tempfile::tempdir().unwrap();
212
+ let root = temp.path();
213
+
214
+ fs::write(
215
+ root.join("pyproject.toml"),
216
+ "[tool.typedframes]\nenabled = true",
217
+ )
218
+ .unwrap();
219
+ assert_eq!(load_linter_config(root).excluded_external_packages, None);
220
+
221
+ fs::write(
222
+ root.join("pyproject.toml"),
223
+ "[tool.typedframes]\nexcluded_external_packages = [\"huge_untrusted_pkg\"]",
224
+ )
225
+ .unwrap();
226
+ assert_eq!(
227
+ load_linter_config(root).excluded_external_packages,
228
+ Some(vec!["huge_untrusted_pkg".to_string()])
229
+ );
230
+ }
199
231
  }
@@ -262,18 +262,17 @@ pub(crate) fn find_site_packages_dir_uncached(project_root: &Path) -> Option<Pat
262
262
  None
263
263
  }
264
264
 
265
- // Collect `.py` files for each explicitly allowlisted external package (see
266
- // LinterConfig::trace_external_packages), resolved from the project's own
267
- // site-packages directory. Deliberately narrow: only the allowlisted packages' own
268
- // directories are walked — never the whole site-packages tree, which would be both
269
- // expensive and a far larger, unbounded trust surface than an explicit opt-in list.
265
+ // Collect `.py` files for each named external package, resolved from the project's
266
+ // own site-packages directory. `packages` is the already-merged, already-filtered set
267
+ // -- see `resolve_traced_external_packages` for how `trace_external_packages`
268
+ // (explicit force-include), auto-discovered candidates, and `excluded_external_packages`
269
+ // combine into it. Deliberately narrow: only the named packages' own directories are
270
+ // walked — never the whole site-packages tree, which would be both expensive and a far
271
+ // larger, unbounded trust surface than a bounded, explainable package list.
270
272
  pub(crate) fn collect_external_package_files(
271
273
  project_root: &Path,
272
- config: &LinterConfig,
274
+ packages: &[String],
273
275
  ) -> Vec<PathBuf> {
274
- let Some(packages) = config.trace_external_packages.as_ref() else {
275
- return Vec::new();
276
- };
277
276
  if packages.is_empty() {
278
277
  return Vec::new();
279
278
  }
@@ -305,11 +304,18 @@ pub(crate) fn collect_external_package_files(
305
304
  // Runs the linter in index mode (diagnostics discarded) to collect schemas and
306
305
  // functions, then separately parses `__all__` assignments and `from X import Y`
307
306
  // statements for wildcard-import support and delegate-target resolution respectively.
307
+ // Returns the file's `IndexEntry` plus the top-level package names of any external
308
+ // (non-first-party) imports it called in an unresolved, DataFrame-shaped way (see
309
+ // `Linter::unresolved_dataframe_shaped_calls`) -- candidates for on-demand external
310
+ // package tracing, resolved by `discover_external_package_candidates` in
311
+ // `build_index_internal`. This file's own imports/module_aliases are exactly what's
312
+ // needed to resolve each candidate's raw call-site name to a module, so it's done
313
+ // here rather than requiring a second pass over the same file.
308
314
  pub(crate) fn index_file(
309
315
  path: &Path,
310
316
  project_root: &Path,
311
317
  config: &LinterConfig,
312
- ) -> Option<IndexEntry> {
318
+ ) -> Option<(IndexEntry, Vec<String>)> {
313
319
  let source = fs::read_to_string(path).ok()?;
314
320
 
315
321
  let mut linter = Linter::new();
@@ -420,17 +426,97 @@ pub(crate) fn index_file(
420
426
  }
421
427
  }
422
428
 
423
- Some(IndexEntry {
424
- schemas,
425
- schema_locations,
426
- functions,
427
- exports,
428
- imports,
429
- module_aliases,
430
- })
429
+ // Resolve each recorded unresolved-call name (an attribute-call receiver or a
430
+ // bare-call callee) to the module it was imported from, then take that module's
431
+ // top-level package name -- matching the same dotted-import-binds-first-segment
432
+ // convention as the `Stmt::Import` handling above. A name that isn't a plain
433
+ // import at all (a local variable, a class instance, a closure) simply has no
434
+ // entry in `imports`/`module_aliases` and is silently dropped here, same as any
435
+ // other unresolvable name elsewhere in this module.
436
+ let external_package_candidates: Vec<String> = linter
437
+ .unresolved_dataframe_shaped_calls
438
+ .iter()
439
+ .filter_map(|call| {
440
+ let module_name = if call.is_attribute_call {
441
+ module_aliases.get(&call.name)
442
+ } else {
443
+ imports.get(&call.name)
444
+ }?;
445
+ module_name.split('.').next().map(str::to_string)
446
+ })
447
+ .collect();
448
+
449
+ Some((
450
+ IndexEntry {
451
+ schemas,
452
+ schema_locations,
453
+ functions,
454
+ exports,
455
+ imports,
456
+ module_aliases,
457
+ },
458
+ external_package_candidates,
459
+ ))
460
+ }
461
+
462
+ // Ceiling on how many AUTO-discovered candidate packages (see
463
+ // `discover_external_package_candidates`) get traced by default. Never applies to
464
+ // `trace_external_packages` -- an explicit force-include is never capped, since the
465
+ // user opted into it deliberately and by name. Bounds the worst case for a project
466
+ // whose first-party code happens to call many different external packages in
467
+ // DataFrame-shaped ways, in the same spirit as the MAX_SQL_FILE_READS_PER_FILE /
468
+ // MAX_SQL_FILE_BYTES guardrails in linter.rs's own `read_sql_file`.
469
+ pub(crate) const MAX_AUTO_TRACED_PACKAGES: usize = 20;
470
+
471
+ // Merge `trace_external_packages` (forced, uncapped, exempt from
472
+ // `excluded_external_packages`), auto-discovered candidates (capped at
473
+ // MAX_AUTO_TRACED_PACKAGES, alphabetically for determinism, filtered by
474
+ // `excluded_external_packages` and by not already being a first-party module), into
475
+ // the final list of package names to trace.
476
+ pub(crate) fn resolve_traced_external_packages(
477
+ config: &LinterConfig,
478
+ discovered_candidates: std::collections::HashSet<String>,
479
+ project_root: &Path,
480
+ first_party_files: &HashMap<String, IndexEntry>,
481
+ ) -> Vec<String> {
482
+ let forced: std::collections::HashSet<String> = config
483
+ .trace_external_packages
484
+ .clone()
485
+ .unwrap_or_default()
486
+ .into_iter()
487
+ .collect();
488
+ let excluded: std::collections::HashSet<String> = config
489
+ .excluded_external_packages
490
+ .clone()
491
+ .unwrap_or_default()
492
+ .into_iter()
493
+ .collect();
494
+
495
+ let mut auto_candidates: Vec<String> = discovered_candidates
496
+ .into_iter()
497
+ .filter(|name| !forced.contains(name))
498
+ .filter(|name| !excluded.contains(name))
499
+ // A candidate that resolves to a first-party module isn't external at all --
500
+ // e.g. a plain `import utils` inside a `src/` layout project can look exactly
501
+ // like an unresolved external call before cross-file symbols are loaded.
502
+ .filter(|name| resolve_module_file(name, project_root, first_party_files).is_none())
503
+ .collect();
504
+ auto_candidates.sort();
505
+ auto_candidates.truncate(MAX_AUTO_TRACED_PACKAGES);
506
+
507
+ let mut result: Vec<String> = forced.into_iter().collect();
508
+ result.extend(auto_candidates);
509
+ result.sort();
510
+ result.dedup();
511
+ result
431
512
  }
432
513
 
433
- // Build a ProjectIndex by indexing every `.py` file under `project_root`.
514
+ // Build a ProjectIndex by indexing every `.py` file under `project_root`. Two-phase:
515
+ // first-party files are indexed on their own (collecting external-package candidates
516
+ // along the way, see `index_file`), THEN the resolved external-package list is indexed
517
+ // and merged in — see `resolve_traced_external_packages`. This ordering is required,
518
+ // not just convenient: deciding which external packages are worth tracing depends on
519
+ // having already seen every first-party file's own imports and DataFrame-shaped usage.
434
520
  pub(crate) fn build_index_internal(project_root: &Path) -> ProjectIndex {
435
521
  // Loaded once and threaded into every per-file `Linter`, so index-time inference
436
522
  // (this function) and check-time inference (`check_file`) agree on `sql_dialect` —
@@ -446,16 +532,30 @@ pub(crate) fn build_index_internal(project_root: &Path) -> ProjectIndex {
446
532
  .map(|s| s.to_string())
447
533
  .collect(),
448
534
  };
449
- let mut py_files = collect_py_files(project_root, &resolved_excludes);
450
- py_files.extend(collect_external_package_files(project_root, &config));
535
+ let py_files = collect_py_files(project_root, &resolved_excludes);
536
+
451
537
  let mut files = HashMap::new();
452
- for file_path in py_files {
453
- if let Some(entry) = index_file(&file_path, project_root, &config) {
538
+ let mut discovered_candidates: std::collections::HashSet<String> =
539
+ std::collections::HashSet::new();
540
+ for file_path in &py_files {
541
+ if let Some((entry, candidates)) = index_file(file_path, project_root, &config) {
542
+ discovered_candidates.extend(candidates);
543
+ if let Some(path_str) = file_path.to_str() {
544
+ files.insert(path_str.to_string(), entry);
545
+ }
546
+ }
547
+ }
548
+
549
+ let traced_packages =
550
+ resolve_traced_external_packages(&config, discovered_candidates, project_root, &files);
551
+ for file_path in collect_external_package_files(project_root, &traced_packages) {
552
+ if let Some((entry, _candidates)) = index_file(&file_path, project_root, &config) {
454
553
  if let Some(path_str) = file_path.to_str() {
455
554
  files.insert(path_str.to_string(), entry);
456
555
  }
457
556
  }
458
557
  }
558
+
459
559
  let all_schemas = compute_all_schemas(&files);
460
560
  let all_schema_locations = compute_all_schema_locations(&files);
461
561
  resolve_param_schema_requires(&mut files, &all_schemas);
@@ -1131,6 +1231,97 @@ mod tests {
1131
1231
  use crate::config::LinterConfig;
1132
1232
  use std::path::Path;
1133
1233
 
1234
+ #[test]
1235
+ fn test_should_cap_auto_discovered_candidates_but_never_force_included_ones() {
1236
+ // arrange: 25 auto-discovered candidates (more than MAX_AUTO_TRACED_PACKAGES),
1237
+ // plus one package named in trace_external_packages that would sort dead last
1238
+ // alphabetically -- it must still appear in the result, uncapped.
1239
+ let config = LinterConfig {
1240
+ enabled: None,
1241
+ warnings: None,
1242
+ sql_dialect: None,
1243
+ trace_external_packages: Some(vec!["zzz_forced_pkg".to_string()]),
1244
+ excluded_external_packages: None,
1245
+ exclude: None,
1246
+ };
1247
+ let candidates: std::collections::HashSet<String> =
1248
+ (0..25).map(|i| format!("pkg_{i:02}")).collect();
1249
+ let dir = tempfile::tempdir().unwrap();
1250
+
1251
+ // act
1252
+ let result =
1253
+ resolve_traced_external_packages(&config, candidates, dir.path(), &HashMap::new());
1254
+
1255
+ // assert: forced package present regardless of the cap ...
1256
+ assert!(result.contains(&"zzz_forced_pkg".to_string()));
1257
+ // ... and the auto-discovered portion is capped at MAX_AUTO_TRACED_PACKAGES,
1258
+ // keeping the alphabetically-first candidates for determinism.
1259
+ let auto_portion: Vec<&String> = result
1260
+ .iter()
1261
+ .filter(|name| *name != "zzz_forced_pkg")
1262
+ .collect();
1263
+ assert_eq!(auto_portion.len(), MAX_AUTO_TRACED_PACKAGES);
1264
+ assert!(auto_portion.contains(&&"pkg_00".to_string()));
1265
+ assert!(!auto_portion.contains(&&format!("pkg_{:02}", 24)));
1266
+ }
1267
+
1268
+ #[test]
1269
+ fn test_should_respect_excluded_external_packages_in_resolve_traced_external_packages() {
1270
+ // arrange
1271
+ let config = LinterConfig {
1272
+ enabled: None,
1273
+ warnings: None,
1274
+ sql_dialect: None,
1275
+ trace_external_packages: None,
1276
+ excluded_external_packages: Some(vec!["untrusted_pkg".to_string()]),
1277
+ exclude: None,
1278
+ };
1279
+ let candidates: std::collections::HashSet<String> =
1280
+ ["untrusted_pkg".to_string(), "fine_pkg".to_string()]
1281
+ .into_iter()
1282
+ .collect();
1283
+ let dir = tempfile::tempdir().unwrap();
1284
+
1285
+ // act
1286
+ let result =
1287
+ resolve_traced_external_packages(&config, candidates, dir.path(), &HashMap::new());
1288
+
1289
+ // assert
1290
+ assert!(!result.contains(&"untrusted_pkg".to_string()));
1291
+ assert!(result.contains(&"fine_pkg".to_string()));
1292
+ }
1293
+
1294
+ #[test]
1295
+ fn test_should_never_treat_a_first_party_module_as_an_external_candidate() {
1296
+ // arrange: "utils" resolves to a real first-party file already in the index --
1297
+ // a bare `import utils` looking momentarily unresolved (before cross-file
1298
+ // symbols are loaded) must never be treated as an external-tracing candidate.
1299
+ let dir = tempfile::tempdir().unwrap();
1300
+ let root = dir.path();
1301
+ let config = LinterConfig::EMPTY;
1302
+ let mut first_party_files = HashMap::new();
1303
+ first_party_files.insert(
1304
+ root.join("utils.py").to_str().unwrap().to_string(),
1305
+ IndexEntry {
1306
+ schemas: HashMap::new(),
1307
+ schema_locations: HashMap::new(),
1308
+ functions: HashMap::new(),
1309
+ exports: Vec::new(),
1310
+ imports: HashMap::new(),
1311
+ module_aliases: HashMap::new(),
1312
+ },
1313
+ );
1314
+ let candidates: std::collections::HashSet<String> =
1315
+ ["utils".to_string()].into_iter().collect();
1316
+
1317
+ // act
1318
+ let result =
1319
+ resolve_traced_external_packages(&config, candidates, root, &first_party_files);
1320
+
1321
+ // assert
1322
+ assert!(result.is_empty());
1323
+ }
1324
+
1134
1325
  #[test]
1135
1326
  fn test_should_not_hang_on_mutually_delegating_functions() {
1136
1327
  // arrange: f and g delegate to each other — a cycle in the delegate graph.