Skip to content

Commit a8e4a7a

Browse files
authored
feat: add strict mode for package content tests to enforce file matching (#1677)
1 parent e815446 commit a8e4a7a

12 files changed

+276
-85
lines changed

docs/reference/recipe_file.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,10 @@ tests:
890890
bin:
891891
- mamba
892892

893+
# enable strict mode: error if any file in the package is not matched by one of the globs
894+
# (default: false)
895+
strict: true
896+
893897
# searches for `$PREFIX/lib/libmamba.so` or `$PREFIX/lib/libmamba.dylib` on Linux or macOS,
894898
# on Windows for %PREFIX%\Library\lib\mamba.dll & %PREFIX%\Library\bin\mamba.bin
895899
lib:

docs/testing.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ tests:
6363
- package_contents:
6464
files:
6565
- share/package/*.txt
66+
67+
# test with strict mode: fails if there are any files not matched by the globs
68+
- package_contents:
69+
strict: true
70+
files:
71+
- share/package/*.txt
72+
- bin/myapp
73+
lib:
74+
- mylib
6675
```
6776
6877
### Testing package contents
@@ -78,6 +87,7 @@ It has multiple sub-keys that help when building cross-platform packages:
7887
- **`include`**: matches files under the `include` directory in the package. You can specify the file name like `foo.h`.
7988
- **`bin`**: matches files under the `bin` directory in the package. You can specify executable names like `foo` which will match `foo.exe` on Windows and `foo` on Linux and macOS.
8089
- **`site_packages`**: matches files under the `site-packages` directory in the package. You can specify the import path like `foobar.api` which will match `foobar/api.py` and `foobar/api/__init__.py`.
90+
- **`strict`**: when set to `true`, enables strict mode. In strict mode, the test will fail if there are any files in the package that don't match any of the specified globs. (default: `false`).
8191

8292
## Testing existing packages
8393

src/package_test/content_test.rs

Lines changed: 85 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::HashSet;
12
use std::path::PathBuf;
23

34
use crate::recipe::parser::PackageContentsTest;
@@ -229,106 +230,90 @@ impl PackageContentsTest {
229230
let span = tracing::info_span!("Package content test");
230231
let _enter = span.enter();
231232
let target_platform = output.target_platform();
232-
let paths = paths
233-
.paths
234-
.iter()
235-
.map(|p| &p.relative_path)
236-
.collect::<Vec<_>>();
237-
238-
let include_globs = self.include_as_globs(target_platform)?;
239-
let bin_globs = self.bin_as_globs(target_platform)?;
240-
let lib_globs = self.lib_as_globs(target_platform)?;
241-
let site_package_globs = self.site_packages_as_globs(
242-
target_platform,
243-
output.recipe.build().is_python_version_independent(),
244-
)?;
245-
let file_globs = self.files_as_globs()?;
246-
247-
fn match_glob<'a>(glob: &GlobSet, paths: &'a Vec<&PathBuf>) -> Vec<&'a PathBuf> {
248-
let mut matches: Vec<&'a PathBuf> = Vec::new();
249-
for path in paths {
250-
if glob.is_match(path) {
251-
matches.push(path);
252-
}
253-
}
254-
matches
255-
}
256-
257-
let mut collected_issues = Vec::new();
258-
259-
for glob in include_globs {
260-
let matches = match_glob(&glob.1, &paths);
261-
262-
if !matches.is_empty() {
263-
display_success(&matches, &glob.0, "include");
264-
}
265-
266-
if matches.is_empty() {
267-
collected_issues.push(format!("No match for include glob: {}", glob.0));
268-
}
269-
}
270-
271-
for glob in bin_globs {
272-
let matches = match_glob(&glob.1, &paths);
273-
274-
if !matches.is_empty() {
275-
display_success(&matches, &glob.0, "bin");
276-
}
277-
278-
if matches.is_empty() {
279-
collected_issues.push(format!("No match for bin glob: {}", glob.0));
280-
}
281-
}
282-
283-
for glob in lib_globs {
284-
let matches = match_glob(&glob.1, &paths);
285-
286-
if !matches.is_empty() {
287-
display_success(&matches, &glob.0, "lib");
288-
}
289-
290-
if matches.is_empty() {
291-
collected_issues.push(format!("No match for lib glob: {}", glob.0));
292-
}
293-
}
233+
let paths: Vec<&PathBuf> = paths.paths.iter().map(|p| &p.relative_path).collect();
234+
235+
// Collect all glob patterns
236+
let all_globs = [
237+
("include", self.include_as_globs(target_platform)?),
238+
("bin", self.bin_as_globs(target_platform)?),
239+
("lib", self.lib_as_globs(target_platform)?),
240+
(
241+
"site_packages",
242+
self.site_packages_as_globs(
243+
target_platform,
244+
output.recipe.build().is_python_version_independent(),
245+
)?,
246+
),
247+
("files", self.files_as_globs()?),
248+
];
294249

295-
for glob in site_package_globs {
296-
let matches = match_glob(&glob.1, &paths);
250+
let mut matched_paths = HashSet::<&PathBuf>::new();
251+
let mut issues = Vec::new();
297252

298-
if !matches.is_empty() {
299-
display_success(&matches, &glob.0, "site_packages");
300-
}
253+
// Check all globs
254+
for (section, globs) in &all_globs {
255+
for (glob_str, globset) in globs {
256+
let matches: Vec<&PathBuf> = paths
257+
.iter()
258+
.filter(|path| globset.is_match(path))
259+
.copied()
260+
.collect();
301261

302-
if matches.is_empty() {
303-
collected_issues.push(format!("No match for site_package glob: {}", glob.0));
262+
if matches.is_empty() {
263+
issues.push(format!("No match for {} glob: {}", section, glob_str));
264+
} else {
265+
display_success(&matches, glob_str, section);
266+
matched_paths.extend(&matches);
267+
}
304268
}
305269
}
306270

307-
for glob in file_globs {
308-
let matches = match_glob(&glob.1, &paths);
309-
310-
if !matches.is_empty() {
311-
display_success(&matches, &glob.0, "file");
312-
}
313-
314-
if matches.is_empty() {
315-
collected_issues.push(format!("No match for file glob: {}", glob.0));
271+
// Check strict mode
272+
let strict_mode_issue = if self.strict {
273+
let unmatched: Vec<&PathBuf> = paths
274+
.iter()
275+
.filter(|p| !matched_paths.contains(*p))
276+
.copied()
277+
.collect();
278+
279+
if !unmatched.is_empty() {
280+
Some((
281+
format!("Strict mode: {} unmatched files found", unmatched.len()),
282+
unmatched,
283+
))
284+
} else {
285+
None
316286
}
317-
}
287+
} else {
288+
None
289+
};
318290

319-
if !collected_issues.is_empty() {
291+
if !issues.is_empty() || strict_mode_issue.is_some() {
320292
tracing::error!("Package content test failed:");
321-
for issue in &collected_issues {
293+
294+
// Print regular issues first
295+
for issue in &issues {
322296
tracing::error!(
323297
"- {} {}",
324298
console::style(console::Emoji("❌", " ")).red(),
325299
issue
326300
);
327301
}
328302

329-
return Err(TestError::PackageContentTestFailed(
330-
collected_issues.join("\n"),
331-
));
303+
// Print strict mode issues if any
304+
if let Some((message, unmatched)) = &strict_mode_issue {
305+
tracing::error!("\nStrict mode violations:");
306+
for file in unmatched {
307+
tracing::error!(
308+
"- {} {}",
309+
console::style(console::Emoji("❌", " ")).red(),
310+
file.display()
311+
);
312+
}
313+
issues.push(message.clone());
314+
}
315+
316+
return Err(TestError::PackageContentTestFailed(issues.join("\n")));
332317
}
333318

334319
Ok(())
@@ -524,4 +509,20 @@ mod tests {
524509
let test_case = load_test_case(Path::new("test_files.yaml"));
525510
evaluate_test_case(test_case).unwrap();
526511
}
512+
513+
#[test]
514+
fn test_strict_mode() {
515+
let strict_contents = PackageContentsTest {
516+
files: GlobVec::from_vec(vec!["matched.txt"], None),
517+
strict: true,
518+
..Default::default()
519+
};
520+
assert!(strict_contents.strict);
521+
522+
let non_strict_contents = PackageContentsTest {
523+
files: GlobVec::from_vec(vec!["*.txt"], None),
524+
..Default::default()
525+
};
526+
assert!(!non_strict_contents.strict);
527+
}
527528
}

src/recipe/parser/snapshots/rattler_build__recipe__parser__test__test__script_parsing.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ expression: yaml_serde
3232
files:
3333
- foo
3434
- bar
35+
strict: false
3536
- package_contents:
3637
lib:
3738
- libfoo.so
3839
- libbar.so
3940
include:
4041
- xtensor/xarray.hpp
42+
strict: false

src/recipe/parser/test.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,9 @@ pub struct PackageContentsTest {
194194
/// check if include path contains the file, direct or glob?
195195
#[serde(default, skip_serializing_if = "GlobVec::is_empty")]
196196
pub include: GlobVec,
197+
/// whether to enable strict mode (error on non-matched files or missing files)
198+
#[serde(default)]
199+
pub strict: bool,
197200
}
198201

199202
impl TryConvertNode<Vec<TestType>> for RenderedNode {
@@ -474,7 +477,8 @@ impl TryConvertNode<PackageContentsTest> for RenderedMappingNode {
474477
site_packages,
475478
lib,
476479
bin,
477-
include
480+
include,
481+
strict
478482
);
479483
Ok(package_contents)
480484
}
@@ -630,4 +634,43 @@ mod test {
630634
_ => panic!("expected python test"),
631635
}
632636
}
637+
638+
#[test]
639+
fn test_package_contents_strict_mode() {
640+
let test_section = r#"
641+
tests:
642+
- package_contents:
643+
strict: true
644+
files:
645+
- "**/*.txt"
646+
bin:
647+
- rust
648+
- package_contents:
649+
files:
650+
- "**/*.txt"
651+
"#;
652+
653+
let yaml_root = RenderedNode::parse_yaml(0, test_section)
654+
.map_err(|err| vec![err])
655+
.unwrap();
656+
let tests_node = yaml_root.as_mapping().unwrap().get("tests").unwrap();
657+
let tests: Vec<TestType> = tests_node.try_convert("tests").unwrap();
658+
659+
match &tests[0] {
660+
TestType::PackageContents { package_contents } => {
661+
assert!(package_contents.strict);
662+
assert_eq!(package_contents.files.include_globs().len(), 1);
663+
assert_eq!(package_contents.bin.include_globs().len(), 1);
664+
}
665+
_ => panic!("expected package contents test"),
666+
}
667+
668+
match &tests[1] {
669+
TestType::PackageContents { package_contents } => {
670+
assert!(!package_contents.strict);
671+
assert_eq!(package_contents.files.include_globs().len(), 1);
672+
}
673+
_ => panic!("expected package contents test"),
674+
}
675+
}
633676
}

src/recipe/snapshots/rattler_build__recipe__parser__tests__complete_recipe.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ tests:
169169
include:
170170
- test.h
171171
- test.h*
172+
strict: false
172173
- python:
173174
imports:
174175
- numpy

src/recipe/snapshots/rattler_build__recipe__parser__tests__recipe_windows.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,7 @@ Recipe {
366366
include: [
367367
"xtensor/xarray.hpp{,/**}",
368368
],
369+
strict: false,
369370
},
370371
},
371372
Command(

src/recipe/snapshots/rattler_build__recipe__parser__tests__unix_recipe.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,7 @@ Recipe {
388388
include: [
389389
"xtensor/xarray.hpp{,/**}",
390390
],
391+
strict: false,
391392
},
392393
},
393394
Command(
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package:
2+
name: test-strict-fail
3+
version: 0.1.0
4+
5+
build:
6+
script:
7+
- if: unix
8+
then:
9+
- echo "test" > $PREFIX/test.txt
10+
- echo "extra" > $PREFIX/extra.txt
11+
else:
12+
- echo "test" > %PREFIX%\test.txt
13+
- echo "extra" > %PREFIX%\extra.txt
14+
15+
tests:
16+
- package_contents:
17+
strict: true
18+
files:
19+
- test.txt
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package:
2+
name: test-strict-many
3+
version: 0.1.0
4+
5+
build:
6+
script:
7+
- if: unix
8+
then:
9+
- echo "matched" > $PREFIX/matched.txt
10+
- echo "unmatched1" > $PREFIX/unmatched1.txt
11+
- echo "unmatched2" > $PREFIX/unmatched2.txt
12+
- echo "unmatched3" > $PREFIX/unmatched3.txt
13+
- echo "unmatched4" > $PREFIX/unmatched4.txt
14+
- echo "unmatched5" > $PREFIX/unmatched5.txt
15+
- echo "unmatched6" > $PREFIX/unmatched6.txt
16+
- echo "unmatched7" > $PREFIX/unmatched7.txt
17+
else:
18+
- echo "matched" > %PREFIX%\matched.txt
19+
- echo "unmatched1" > %PREFIX%\unmatched1.txt
20+
- echo "unmatched2" > %PREFIX%\unmatched2.txt
21+
- echo "unmatched3" > %PREFIX%\unmatched3.txt
22+
- echo "unmatched4" > %PREFIX%\unmatched4.txt
23+
- echo "unmatched5" > %PREFIX%\unmatched5.txt
24+
- echo "unmatched6" > %PREFIX%\unmatched6.txt
25+
- echo "unmatched7" > %PREFIX%\unmatched7.txt
26+
27+
tests:
28+
- package_contents:
29+
strict: true
30+
files:
31+
- matched.txt

0 commit comments

Comments
 (0)