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.
Files changed (30) hide show
  1. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/PKG-INFO +1 -1
  2. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/CHANGELOG.md +8 -0
  3. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/Cargo.lock +1 -1
  4. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/Cargo.toml +1 -1
  5. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/MIGRATIONS.md +16 -1
  6. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/src/lib.rs +30 -1
  7. testing_conventions-0.0.3/rust/src/location.rs +138 -0
  8. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/clean/pkg/helper.py +1 -0
  9. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/clean/pkg/helper_test.py +1 -0
  10. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/clean/widget.py +1 -0
  11. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/clean/widget_test.py +1 -0
  12. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/exempt/__init__.py +1 -0
  13. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/exempt/pkg/__init__.py +1 -0
  14. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/exempt/widget.py +1 -0
  15. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/exempt/widget_test.py +1 -0
  16. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/red/lonely.py +1 -0
  17. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/red/pkg/orphan.py +1 -0
  18. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/red/pkg/paired.py +1 -0
  19. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/red/pkg/paired_test.py +1 -0
  20. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/red/widget.py +1 -0
  21. testing_conventions-0.0.3/rust/tests/fixtures/unit_location/red/widget_test.py +1 -0
  22. testing_conventions-0.0.3/rust/tests/unit_location.rs +90 -0
  23. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/README.md +0 -0
  24. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/pyproject.toml +0 -0
  25. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/src/config.rs +0 -0
  26. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/src/main.rs +0 -0
  27. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/tests/config_loader.rs +0 -0
  28. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/tests/fixtures/malformed.toml +0 -0
  29. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/tests/fixtures/unknown_key.toml +0 -0
  30. {testing_conventions-0.0.2 → testing_conventions-0.0.3}/rust/tests/fixtures/valid.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: testing-conventions
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Enforce testing conventions in libraries (Python, TypeScript, and Rust).
5
5
  Author-email: Kevin Scott <me@thekevinscott.com>
6
6
  License: MIT
@@ -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
 
@@ -226,7 +226,7 @@ dependencies = [
226
226
 
227
227
  [[package]]
228
228
  name = "testing-conventions"
229
- version = "0.0.2"
229
+ version = "0.0.3"
230
230
  dependencies = [
231
231
  "anyhow",
232
232
  "clap",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "testing-conventions"
3
- version = "0.0.2"
3
+ version = "0.0.3"
4
4
  edition = "2021"
5
5
  license = "MIT"
6
6
  authors = ["Kevin Scott <me@thekevinscott.com>"]
@@ -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
- // Rules aren't implemented yet; the scaffold just proves the wiring.
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
+ }