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.
- {typedframes-0.4.0 → typedframes-0.4.1}/PKG-INFO +16 -16
- {typedframes-0.4.0 → typedframes-0.4.1}/README.md +15 -15
- {typedframes-0.4.0 → typedframes-0.4.1}/pyproject.toml +5 -5
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/Cargo.lock +1 -1
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/Cargo.toml +1 -1
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/config.rs +32 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/index.rs +214 -23
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/linter.rs +986 -178
- {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/__init__.py +1 -1
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/.cargo/config.toml +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/README.md +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/benches/parser_bench.rs +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/ast_extract.rs +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/constants.rs +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/contract.rs +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/errors.rs +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/lib.rs +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/main.rs +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/notebook.rs +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/pyapi.rs +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/sql.rs +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/src/typo.rs +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/rust/tests/integration_test.rs +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/base_schema.py +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/cli.py +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/column.py +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/column_group.py +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/column_group_error.py +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/column_set.py +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/missing_dependency_error.py +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/mypy.py +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/pandas.py +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/pandera.py +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/polars.py +0 -0
- {typedframes-0.4.0 → typedframes-0.4.1}/src/typedframes/py.typed +0 -0
- {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.
|
|
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.
|
|
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-
|
|
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 (
|
|
596
|
+
| Tool | Version | What it does | typedframes (13 files) | great_expectations (485 files) |
|
|
597
597
|
|------|---------|--------------|------------------------|--------------------------------|
|
|
598
|
-
| typedframes | 0.4.
|
|
599
|
-
| ruff | 0.16.
|
|
600
|
-
| ty | 0.0.
|
|
601
|
-
| pyrefly | 1.2.0 | Type checker |
|
|
602
|
-
| mypy | 2.3.
|
|
603
|
-
| mypy + typedframes | 2.3.
|
|
604
|
-
| pyright | 1.1.411 | Type checker |
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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-
|
|
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 (
|
|
562
|
+
| Tool | Version | What it does | typedframes (13 files) | great_expectations (485 files) |
|
|
563
563
|
|------|---------|--------------|------------------------|--------------------------------|
|
|
564
|
-
| typedframes | 0.4.
|
|
565
|
-
| ruff | 0.16.
|
|
566
|
-
| ty | 0.0.
|
|
567
|
-
| pyrefly | 1.2.0 | Type checker |
|
|
568
|
-
| mypy | 2.3.
|
|
569
|
-
| mypy + typedframes | 2.3.
|
|
570
|
-
| pyright | 1.1.411 | Type checker |
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
142
|
-
"ty>=0.0.
|
|
141
|
+
"ruff>=0.16.3",
|
|
142
|
+
"ty>=0.0.72",
|
|
143
143
|
"openpyxl>=3.1.5",
|
|
144
|
-
"pyarrow>=25.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.
|
|
148
|
+
"mypy>=2.3.1",
|
|
149
149
|
]
|
|
150
150
|
|
|
151
151
|
[tool.bandit]
|
|
@@ -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
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
450
|
-
|
|
535
|
+
let py_files = collect_py_files(project_root, &resolved_excludes);
|
|
536
|
+
|
|
451
537
|
let mut files = HashMap::new();
|
|
452
|
-
|
|
453
|
-
|
|
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.
|