testing-conventions 0.0.2__tar.gz → 0.0.3__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.
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/PKG-INFO +1 -1
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/CHANGELOG.md +8 -0
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/Cargo.lock +1 -1
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/Cargo.toml +1 -1
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/MIGRATIONS.md +16 -1
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/src/lib.rs +30 -1
- testing_conventions-0.0.3/rust/src/location.rs +138 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/clean/pkg/helper.py +1 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/clean/pkg/helper_test.py +1 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/clean/widget.py +1 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/clean/widget_test.py +1 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/exempt/__init__.py +1 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/exempt/pkg/__init__.py +1 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/exempt/widget.py +1 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/exempt/widget_test.py +1 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/red/lonely.py +1 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/red/pkg/orphan.py +1 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/red/pkg/paired.py +1 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/red/pkg/paired_test.py +1 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/red/widget.py +1 -0
- testing_conventions-0.0.3/rust/tests/fixtures/unit_location/red/widget_test.py +1 -0
- testing_conventions-0.0.3/rust/tests/unit_location.rs +90 -0
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/README.md +0 -0
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/pyproject.toml +0 -0
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/src/config.rs +0 -0
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/src/main.rs +0 -0
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/tests/config_loader.rs +0 -0
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/tests/fixtures/malformed.toml +0 -0
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/tests/fixtures/unknown_key.toml +0 -0
- {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/tests/fixtures/valid.toml +0 -0
|
@@ -12,6 +12,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
12
12
|
`[python]` / `[typescript]` / `[rust]` tables) and self-validates on load:
|
|
13
13
|
unknown keys and malformed TOML are rejected. The parsed config is not
|
|
14
14
|
consumed yet. (#12)
|
|
15
|
+
- `location` module — `missing_unit_tests()` walks a directory tree and returns
|
|
16
|
+
every Python source file (`*.py`) that has no colocated `*_test.py`, enforcing
|
|
17
|
+
the README's "Location & Naming" rule. Test files (`*_test.py`) and the package
|
|
18
|
+
marker (`__init__.py`) are exempt; the orphan list is sorted for deterministic
|
|
19
|
+
output. (#15)
|
|
20
|
+
- `unit-location <PATH>` CLI subcommand — runs that check over a directory,
|
|
21
|
+
printing each source file missing its colocated `_test.py` and exiting
|
|
22
|
+
non-zero when any are missing. (#15)
|
|
15
23
|
|
|
16
24
|
### Changed
|
|
17
25
|
|
|
@@ -21,9 +21,16 @@ reads one TOML file into it and validates the config itself (the self-guard) —
|
|
|
21
21
|
unknown keys and malformed TOML are rejected rather than silently accepted.
|
|
22
22
|
Purely additive; nothing consumes the parsed config yet.
|
|
23
23
|
|
|
24
|
+
Also adds the first structural rule. The `location` module's
|
|
25
|
+
`missing_unit_tests()` walks a directory and returns every Python source file
|
|
26
|
+
with no colocated `*_test.py`, and the new `unit-location <PATH>` subcommand
|
|
27
|
+
runs that check and exits non-zero on any orphan. Purely additive.
|
|
28
|
+
|
|
24
29
|
### Required changes
|
|
25
30
|
|
|
26
|
-
None. New, additive API: `testing_conventions::config::{Config, load_config}
|
|
31
|
+
None. New, additive API: `testing_conventions::config::{Config, load_config}`,
|
|
32
|
+
`testing_conventions::location::missing_unit_tests`, and the
|
|
33
|
+
`unit-location <PATH>` CLI subcommand.
|
|
27
34
|
|
|
28
35
|
### Deprecations removed
|
|
29
36
|
|
|
@@ -41,3 +48,11 @@ cd packages/rust && cargo test --test config_loader
|
|
|
41
48
|
|
|
42
49
|
Expected: the loader's integration tests pass — the canonical config loads into
|
|
43
50
|
memory, and unknown-key, malformed, and missing-file configs are rejected.
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
cd packages/rust && cargo test --test unit_location
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Expected: the location check's integration tests pass — the clean fixture
|
|
57
|
+
reports no orphans, the red fixture reports both missing twins, and the
|
|
58
|
+
`unit-location` subcommand exits non-zero on the red fixture.
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
pub mod config;
|
|
2
|
+
pub mod location;
|
|
3
|
+
|
|
4
|
+
use std::path::{Path, PathBuf};
|
|
2
5
|
|
|
3
6
|
use clap::{Parser, Subcommand};
|
|
4
7
|
|
|
@@ -18,6 +21,11 @@ pub struct Cli {
|
|
|
18
21
|
enum Command {
|
|
19
22
|
/// Check the repository against its testing-conventions config.
|
|
20
23
|
Check,
|
|
24
|
+
/// Check that every Python source file has a colocated `_test.py` unit test.
|
|
25
|
+
UnitLocation {
|
|
26
|
+
/// Directory to scan recursively for `*.py` sources.
|
|
27
|
+
path: PathBuf,
|
|
28
|
+
},
|
|
21
29
|
}
|
|
22
30
|
|
|
23
31
|
pub fn run<I, T>(args: I) -> anyhow::Result<i32>
|
|
@@ -27,9 +35,30 @@ where
|
|
|
27
35
|
{
|
|
28
36
|
let cli = Cli::try_parse_from(args)?;
|
|
29
37
|
match cli.command {
|
|
30
|
-
//
|
|
38
|
+
// The config-driven `check` umbrella isn't wired yet; the scaffold
|
|
39
|
+
// proves the wiring while individual rules land as their own subcommand.
|
|
31
40
|
Some(Command::Check) | None => Ok(0),
|
|
41
|
+
Some(Command::UnitLocation { path }) => run_unit_location(&path),
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/// Run the Python unit-test location check over `root`, reporting orphans.
|
|
46
|
+
///
|
|
47
|
+
/// Returns `0` when every source file has its colocated `_test.py`; otherwise
|
|
48
|
+
/// prints each orphan to stderr and returns `1`.
|
|
49
|
+
fn run_unit_location(root: &Path) -> anyhow::Result<i32> {
|
|
50
|
+
let orphans = location::missing_unit_tests(root)?;
|
|
51
|
+
if orphans.is_empty() {
|
|
52
|
+
return Ok(0);
|
|
53
|
+
}
|
|
54
|
+
for orphan in &orphans {
|
|
55
|
+
eprintln!("missing colocated unit test: {}", orphan.display());
|
|
32
56
|
}
|
|
57
|
+
eprintln!(
|
|
58
|
+
"error: {} source file(s) missing a colocated `_test.py`",
|
|
59
|
+
orphans.len()
|
|
60
|
+
);
|
|
61
|
+
Ok(1)
|
|
33
62
|
}
|
|
34
63
|
|
|
35
64
|
#[cfg(test)]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
//! Unit-test location/naming check for Python sources (issue #15).
|
|
2
|
+
//!
|
|
3
|
+
//! The convention (README "Location & Naming"; `internals/python/testing.md`):
|
|
4
|
+
//! a Python source file `foo.py` is unit-tested by a colocated `foo_test.py`.
|
|
5
|
+
//! [`missing_unit_tests`] walks a directory tree and returns every source file
|
|
6
|
+
//! that has no such sibling — an "orphan". Files that are themselves tests
|
|
7
|
+
//! (`*_test.py`) are what the check looks *for*, never subjects, and the
|
|
8
|
+
//! package marker (`__init__.py`) is exempt.
|
|
9
|
+
|
|
10
|
+
use std::collections::HashSet;
|
|
11
|
+
use std::path::{Path, PathBuf};
|
|
12
|
+
|
|
13
|
+
use anyhow::{Context, Result};
|
|
14
|
+
|
|
15
|
+
/// The extension that marks a Python file.
|
|
16
|
+
const PY_EXTENSION: &str = "py";
|
|
17
|
+
/// The stem suffix that marks a file as a unit test: `foo` → `foo_test`.
|
|
18
|
+
const TEST_STEM_SUFFIX: &str = "_test";
|
|
19
|
+
/// The package marker, which is never a unit-test subject.
|
|
20
|
+
const PACKAGE_MARKER: &str = "__init__.py";
|
|
21
|
+
|
|
22
|
+
/// Walk `root` recursively and return every Python source file that has no
|
|
23
|
+
/// colocated `<stem>_test.py`, sorted for deterministic output.
|
|
24
|
+
///
|
|
25
|
+
/// A file whose stem ends in `_test` is itself a test and is never treated as a
|
|
26
|
+
/// subject; every other `*.py` file is a subject and must have its colocated
|
|
27
|
+
/// test sibling. Returns an error if the tree under `root` cannot be read.
|
|
28
|
+
pub fn missing_unit_tests(root: impl AsRef<Path>) -> Result<Vec<PathBuf>> {
|
|
29
|
+
let mut python_files = Vec::new();
|
|
30
|
+
collect_python_files(root.as_ref(), &mut python_files)?;
|
|
31
|
+
|
|
32
|
+
// Every `*.py` path we found, so a subject's expected twin is a lookup
|
|
33
|
+
// rather than a second pass over the filesystem.
|
|
34
|
+
let present: HashSet<&Path> = python_files.iter().map(PathBuf::as_path).collect();
|
|
35
|
+
|
|
36
|
+
let mut orphans: Vec<PathBuf> = Vec::new();
|
|
37
|
+
for source in &python_files {
|
|
38
|
+
if is_test_file(source) || is_exempt(source) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if !present.contains(expected_test_path(source).as_path()) {
|
|
42
|
+
orphans.push(source.clone());
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
orphans.sort();
|
|
46
|
+
Ok(orphans)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/// Recursively collect every `*.py` file under `dir` into `out`.
|
|
50
|
+
fn collect_python_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
|
|
51
|
+
let entries =
|
|
52
|
+
std::fs::read_dir(dir).with_context(|| format!("reading directory `{}`", dir.display()))?;
|
|
53
|
+
for entry in entries {
|
|
54
|
+
let path = entry
|
|
55
|
+
.with_context(|| format!("reading an entry under `{}`", dir.display()))?
|
|
56
|
+
.path();
|
|
57
|
+
if path.is_dir() {
|
|
58
|
+
collect_python_files(&path, out)?;
|
|
59
|
+
} else if is_python_source(&path) {
|
|
60
|
+
out.push(path);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
Ok(())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// `true` for a file with a `.py` extension.
|
|
67
|
+
fn is_python_source(path: &Path) -> bool {
|
|
68
|
+
path.extension().and_then(|ext| ext.to_str()) == Some(PY_EXTENSION)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// `true` when `path` is itself a unit test (`*_test.py`), never a subject.
|
|
72
|
+
fn is_test_file(path: &Path) -> bool {
|
|
73
|
+
stem_of(path).ends_with(TEST_STEM_SUFFIX)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/// `true` for the package marker (`__init__.py`), which never needs a test.
|
|
77
|
+
fn is_exempt(path: &Path) -> bool {
|
|
78
|
+
path.file_name().and_then(|name| name.to_str()) == Some(PACKAGE_MARKER)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// The colocated test a source is expected to have: `foo.py` → `foo_test.py`.
|
|
82
|
+
fn expected_test_path(source: &Path) -> PathBuf {
|
|
83
|
+
source.with_file_name(format!(
|
|
84
|
+
"{}{}.{}",
|
|
85
|
+
stem_of(source),
|
|
86
|
+
TEST_STEM_SUFFIX,
|
|
87
|
+
PY_EXTENSION
|
|
88
|
+
))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// The file stem (the name without its extension), lossily decoded.
|
|
92
|
+
fn stem_of(path: &Path) -> String {
|
|
93
|
+
path.file_stem()
|
|
94
|
+
.map(|stem| stem.to_string_lossy().into_owned())
|
|
95
|
+
.unwrap_or_default()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#[cfg(test)]
|
|
99
|
+
mod tests {
|
|
100
|
+
use super::*;
|
|
101
|
+
|
|
102
|
+
#[test]
|
|
103
|
+
fn recognizes_python_sources_by_extension() {
|
|
104
|
+
assert!(is_python_source(Path::new("a.py")));
|
|
105
|
+
assert!(is_python_source(Path::new("pkg/widget.py")));
|
|
106
|
+
assert!(!is_python_source(Path::new("a.pyi")));
|
|
107
|
+
assert!(!is_python_source(Path::new("a.txt")));
|
|
108
|
+
assert!(!is_python_source(Path::new("README")));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
#[test]
|
|
112
|
+
fn recognizes_test_files_by_stem_suffix() {
|
|
113
|
+
assert!(is_test_file(Path::new("widget_test.py")));
|
|
114
|
+
assert!(is_test_file(Path::new("pkg/helper_test.py")));
|
|
115
|
+
assert!(!is_test_file(Path::new("widget.py")));
|
|
116
|
+
assert!(!is_test_file(Path::new("pkg/helper.py")));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#[test]
|
|
120
|
+
fn exempts_the_package_marker() {
|
|
121
|
+
assert!(is_exempt(Path::new("__init__.py")));
|
|
122
|
+
assert!(is_exempt(Path::new("pkg/__init__.py")));
|
|
123
|
+
assert!(!is_exempt(Path::new("conftest.py")));
|
|
124
|
+
assert!(!is_exempt(Path::new("widget.py")));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
#[test]
|
|
128
|
+
fn expected_test_path_is_the_colocated_twin() {
|
|
129
|
+
assert_eq!(
|
|
130
|
+
expected_test_path(Path::new("pkg/widget.py")),
|
|
131
|
+
PathBuf::from("pkg/widget_test.py")
|
|
132
|
+
);
|
|
133
|
+
assert_eq!(
|
|
134
|
+
expected_test_path(Path::new("widget.py")),
|
|
135
|
+
PathBuf::from("widget_test.py")
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location clean fixture: nested source colocated with helper_test.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location clean fixture: nested unit test for helper.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location clean fixture: source colocated with widget_test.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location clean fixture: unit test for widget.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location exempt fixture: package marker — never a unit-test subject
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location exempt fixture: nested package marker — never a unit-test subject
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location exempt fixture: real source colocated with widget_test.py (OK)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location exempt fixture: unit test for widget.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location red fixture: ORPHAN — no colocated lonely_test.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location red fixture: ORPHAN — no colocated orphan_test.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location red fixture: nested source colocated with paired_test.py (OK)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location red fixture: nested unit test for paired.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location red fixture: source colocated with widget_test.py (paired, OK)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# unit_location red fixture: unit test for widget.py
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
//! Integration tests for the Python unit-test location/naming check (issue #15).
|
|
2
|
+
//!
|
|
3
|
+
//! These pin the contract from #15: a Python source file `foo.py` must have a
|
|
4
|
+
//! colocated `foo_test.py`. `missing_unit_tests` walks a directory and returns
|
|
5
|
+
//! every source file missing its twin; the `unit-location` subcommand turns a
|
|
6
|
+
//! non-empty result into a non-zero exit.
|
|
7
|
+
//!
|
|
8
|
+
//! Per the #3 guardrail, the check ships a clean fixture
|
|
9
|
+
//! (`unit_location/clean`, every source paired — must pass) and a red fixture
|
|
10
|
+
//! (`unit_location/red`, two orphans — must fail).
|
|
11
|
+
|
|
12
|
+
use std::ffi::OsString;
|
|
13
|
+
use std::path::{Path, PathBuf};
|
|
14
|
+
|
|
15
|
+
use testing_conventions::location::missing_unit_tests;
|
|
16
|
+
use testing_conventions::run;
|
|
17
|
+
|
|
18
|
+
/// Absolute path to a fixture tree under `tests/fixtures/unit_location/`.
|
|
19
|
+
fn fixture(name: &str) -> PathBuf {
|
|
20
|
+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
21
|
+
.join("tests/fixtures/unit_location")
|
|
22
|
+
.join(name)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Orphans reported under `root`, as `/`-joined paths relative to it.
|
|
26
|
+
fn relative_orphans(root: &Path) -> Vec<String> {
|
|
27
|
+
missing_unit_tests(root)
|
|
28
|
+
.expect("walking a readable tree should succeed")
|
|
29
|
+
.iter()
|
|
30
|
+
.map(|path| {
|
|
31
|
+
path.strip_prefix(root)
|
|
32
|
+
.expect("an orphan must live under the scanned root")
|
|
33
|
+
.to_string_lossy()
|
|
34
|
+
.replace('\\', "/")
|
|
35
|
+
})
|
|
36
|
+
.collect()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/// Invoke the `unit-location <fixture>` subcommand, returning its exit code.
|
|
40
|
+
fn run_unit_location(fixture_name: &str) -> i32 {
|
|
41
|
+
let args: Vec<OsString> = vec![
|
|
42
|
+
"testing-conventions".into(),
|
|
43
|
+
"unit-location".into(),
|
|
44
|
+
fixture(fixture_name).into_os_string(),
|
|
45
|
+
];
|
|
46
|
+
run(args).expect("a readable tree should not error")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
#[test]
|
|
50
|
+
fn clean_tree_reports_no_orphans() {
|
|
51
|
+
assert!(
|
|
52
|
+
relative_orphans(&fixture("clean")).is_empty(),
|
|
53
|
+
"every source in the clean tree has a colocated _test.py"
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
#[test]
|
|
58
|
+
fn red_tree_reports_every_missing_twin() {
|
|
59
|
+
assert_eq!(
|
|
60
|
+
relative_orphans(&fixture("red")),
|
|
61
|
+
vec!["lonely.py", "pkg/orphan.py"],
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#[test]
|
|
66
|
+
fn package_markers_are_not_orphans() {
|
|
67
|
+
assert!(
|
|
68
|
+
relative_orphans(&fixture("exempt")).is_empty(),
|
|
69
|
+
"__init__.py is a package marker, never a unit-test subject"
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
#[test]
|
|
74
|
+
fn a_missing_root_is_an_error() {
|
|
75
|
+
let result = missing_unit_tests(fixture("does_not_exist"));
|
|
76
|
+
assert!(
|
|
77
|
+
result.is_err(),
|
|
78
|
+
"an unreadable root must be an error, got: {result:?}"
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#[test]
|
|
83
|
+
fn subcommand_exits_zero_on_a_clean_tree() {
|
|
84
|
+
assert_eq!(run_unit_location("clean"), 0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#[test]
|
|
88
|
+
fn subcommand_exits_nonzero_on_a_red_tree() {
|
|
89
|
+
assert_eq!(run_unit_location("red"), 1);
|
|
90
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/tests/fixtures/unknown_key.toml
RENAMED
|
File without changes
|
|
File without changes
|