filefilter 0.2.2__tar.gz → 0.2.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: filefilter
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Filter files in a directory tree based on configurable glob rules.
5
5
  Author-email: "Ioannis D (devcoons)" <support@devcoons.com>
6
6
  License-Expression: MIT
@@ -161,11 +161,10 @@ When scanning files, the library applies filters in this exact sequence:
161
161
  - If the file extension matches `exclude.extensions` → **excluded immediately**.
162
162
  - This step is not overridden by include patterns.
163
163
 
164
- 2️⃣ **Path excludes vs. includes (specificity wins)**
164
+ 2️⃣ **Path excludes vs. override patterns (`include.odirs`, `include.ofiles`)**
165
165
  - If the file path matches `exclude.files`, or its parent directory matches `exclude.dirs`, the file is a candidate for exclusion.
166
- - At the same time, the library checks all matching patterns in `include.dirs` and `include.files`.
167
- - Each pattern receives a **specificity score** (more literal segments = higher score; more wildcards = lower score).
168
- - If the **best matching include pattern is more specific** than the best matching exclude pattern → the exclude is **ignored** for this file.
166
+ - **`include.dirs` and `include.files` do not override excludes** — they only gate inclusion later.
167
+ - If a matching pattern in `include.odirs` or `include.ofiles` is **more specific** than the best matching exclude pattern → the exclude is **ignored** for this file.
169
168
  - Otherwise → **excluded**.
170
169
 
171
170
  | Pattern | Relative specificity |
@@ -173,8 +172,11 @@ When scanning files, the library applies filters in this exact sequence:
173
172
  | `**` | very low (broad) |
174
173
  | `**/KLM/**` | higher |
175
174
  | `**/KLM/ABC/**` | highest |
175
+ | `**/test_*.py` vs `**/test_keep.py` | ofile wins (more specific) |
176
176
 
177
- > Put both the broad include and the exception in the same `include.dirs` list — no separate override section is needed.
177
+ > Use `include.dirs` for normal scoping (e.g. `**`, `src/**`).
178
+ > Use `include.odirs` for directory carve-outs under `exclude.dirs` / `exclude.files`.
179
+ > Use `include.ofiles` for file carve-outs under `exclude.files` / `exclude.dirs`.
178
180
 
179
181
  3️⃣ **Include-file fast path**
180
182
  - If the file matches any `include.files` pattern → **included immediately**,
@@ -201,6 +203,8 @@ When scanning files, the library applies filters in this exact sequence:
201
203
  "filters": {
202
204
  "include": {
203
205
  "dirs": [],
206
+ "odirs": [],
207
+ "ofiles": [],
204
208
  "files": [],
205
209
  "extensions": []
206
210
  },
@@ -216,8 +220,10 @@ When scanning files, the library applies filters in this exact sequence:
216
220
  | Field | Description |
217
221
  |-------|--------------|
218
222
  | `root_dir` | Base directory (absolute or relative). |
219
- | `filters.include.dirs` | Directory inclusion patterns. Broader and narrower patterns can coexist; a more specific include dir beats a broader exclude dir (see decision order). |
220
- | `filters.include.files` | File inclusion patterns (glob-like). Also participates in specificity comparisons against excludes. |
223
+ | `filters.include.dirs` | Directory inclusion patterns for gating (must match when any include filters are set). Never overrides excludes. |
224
+ | `filters.include.odirs` | Optional. Override directory patterns (defaults to `[]` when omitted). When more specific than a matching exclude, the exclude is ignored for that path. Also satisfies dir gating. |
225
+ | `filters.include.ofiles` | Optional. Override file patterns (defaults to `[]` when omitted). When more specific than a matching exclude, the exclude is ignored for that path. Also satisfies file gating (does not skip extension whitelist). |
226
+ | `filters.include.files` | File inclusion patterns (glob-like). Forces inclusion (skips extension whitelist) once path excludes are resolved. Does not override excludes. |
221
227
  | `filters.include.extensions` | Extension whitelist. |
222
228
  | `filters.exclude.*` | Same structure, but acts as exclusion filters. |
223
229
 
@@ -435,14 +441,15 @@ When scanning files, the library applies filters in this exact sequence:
435
441
  ### Example 7 — Exclude a subtree, except an explicit include path
436
442
 
437
443
  Exclude everything under `KLM`, but still collect files under `KLM/ABC`.
438
- List the exception alongside the broad include in normal `include.dirs`:
444
+ Use `include.dirs` for the broad scope and `include.odirs` for the exception:
439
445
 
440
446
  ```json
441
447
  {
442
448
  "root_dir": ".",
443
449
  "filters": {
444
450
  "include": {
445
- "dirs": ["**", "**/KLM/ABC/**"],
451
+ "dirs": ["**"],
452
+ "odirs": ["**/KLM/ABC/**"],
446
453
  "files": [],
447
454
  "extensions": ["c"]
448
455
  },
@@ -458,20 +465,52 @@ List the exception alongside the broad include in normal `include.dirs`:
458
465
  | File Path | Result | Reason |
459
466
  |------------|---------|--------|
460
467
  | `src/foo.c` | ✅ | broad `**` include + `.c` extension |
461
- | `src/KLM/skip.c` | ❌ | `**/KLM/**` exclude beats broad `**` |
462
- | `src/KLM/ABC/keep.c` | ✅ | `**/KLM/ABC/**` include beats `**/KLM/**` exclude |
463
- | `src/x/KLM/ABC/deep/keep.c` | ✅ | exception works at any depth |
468
+ | `src/KLM/skip.c` | ❌ | `**/KLM/**` exclude; no matching `odirs` |
469
+ | `src/KLM/ABC/keep.c` | ✅ | `**/KLM/ABC/**` odir beats `**/KLM/**` exclude |
470
+ | `src/x/KLM/ABC/deep/keep.c` | ✅ | odir exception works at any depth |
464
471
  | `src/KLM/ABC/keep.txt` | ❌ | wrong extension (still inside the allowed dir) |
465
472
 
466
- The same technique applies to other “exclude a path part, except one branch” cases — for example, exclude `**/ABC/**` but keep `**/ABC/KLM/**` by adding both `src/**` (or `**`) and `**/ABC/KLM/**` to `include.dirs`.
473
+ The same technique applies to other “exclude a path part, except one branch” cases — for example, exclude `**/ABC/**` but keep `**/ABC/KLM/**` via `include.odirs: ["**/ABC/KLM/**"]` alongside `include.dirs: ["src/**"]`.
474
+
475
+ ---
476
+
477
+ ### Example 8 — Exclude file patterns, except specific files
478
+
479
+ Exclude all `test_*.py` files, but keep `test_keep.py`:
480
+
481
+ ```json
482
+ {
483
+ "root_dir": ".",
484
+ "filters": {
485
+ "include": {
486
+ "dirs": ["**"],
487
+ "ofiles": ["**/test_keep.py"],
488
+ "files": [],
489
+ "extensions": ["py"]
490
+ },
491
+ "exclude": {
492
+ "dirs": [],
493
+ "files": ["**/test_*.py"],
494
+ "extensions": []
495
+ }
496
+ }
497
+ }
498
+ ```
499
+
500
+ | File Path | Result | Reason |
501
+ |------------|---------|--------|
502
+ | `src/app.py` | ✅ | broad include + `.py` extension |
503
+ | `src/test_foo.py` | ❌ | `**/test_*.py` exclude; no matching `ofiles` |
504
+ | `src/test_keep.py` | ✅ | `**/test_keep.py` ofile beats `**/test_*.py` exclude |
505
+ | `nested/test_keep.py` | ✅ | ofile exception works at any depth |
467
506
 
468
507
  ---
469
508
 
470
509
  ## Summary of Behavior
471
510
 
472
511
  - `exclude.extensions` are always applied first (hard exclude).
473
- - For path rules, **the most specific matching pattern wins** — a narrow `include.dirs` entry can beat a broader `exclude.dirs` entry in the same config.
474
- - Broad includes such as `**` do **not** cancel excludes; only a **more specific** include does.
512
+ - `include.dirs` and `include.files` **never** override path excludes — they only gate inclusion.
513
+ - Only `include.odirs` and `include.ofiles` can override a matching exclude, and only when the override pattern is **more specific**.
475
514
  - A matching `include.files` pattern forces inclusion (skips extension whitelist), once path excludes are resolved.
476
515
  - If any include filters exist, at least one must match (inclusion gating).
477
516
  - Extensions act as a **final whitelist** when not bypassed by `include.files`.
@@ -128,11 +128,10 @@ When scanning files, the library applies filters in this exact sequence:
128
128
  - If the file extension matches `exclude.extensions` → **excluded immediately**.
129
129
  - This step is not overridden by include patterns.
130
130
 
131
- 2️⃣ **Path excludes vs. includes (specificity wins)**
131
+ 2️⃣ **Path excludes vs. override patterns (`include.odirs`, `include.ofiles`)**
132
132
  - If the file path matches `exclude.files`, or its parent directory matches `exclude.dirs`, the file is a candidate for exclusion.
133
- - At the same time, the library checks all matching patterns in `include.dirs` and `include.files`.
134
- - Each pattern receives a **specificity score** (more literal segments = higher score; more wildcards = lower score).
135
- - If the **best matching include pattern is more specific** than the best matching exclude pattern → the exclude is **ignored** for this file.
133
+ - **`include.dirs` and `include.files` do not override excludes** — they only gate inclusion later.
134
+ - If a matching pattern in `include.odirs` or `include.ofiles` is **more specific** than the best matching exclude pattern → the exclude is **ignored** for this file.
136
135
  - Otherwise → **excluded**.
137
136
 
138
137
  | Pattern | Relative specificity |
@@ -140,8 +139,11 @@ When scanning files, the library applies filters in this exact sequence:
140
139
  | `**` | very low (broad) |
141
140
  | `**/KLM/**` | higher |
142
141
  | `**/KLM/ABC/**` | highest |
142
+ | `**/test_*.py` vs `**/test_keep.py` | ofile wins (more specific) |
143
143
 
144
- > Put both the broad include and the exception in the same `include.dirs` list — no separate override section is needed.
144
+ > Use `include.dirs` for normal scoping (e.g. `**`, `src/**`).
145
+ > Use `include.odirs` for directory carve-outs under `exclude.dirs` / `exclude.files`.
146
+ > Use `include.ofiles` for file carve-outs under `exclude.files` / `exclude.dirs`.
145
147
 
146
148
  3️⃣ **Include-file fast path**
147
149
  - If the file matches any `include.files` pattern → **included immediately**,
@@ -168,6 +170,8 @@ When scanning files, the library applies filters in this exact sequence:
168
170
  "filters": {
169
171
  "include": {
170
172
  "dirs": [],
173
+ "odirs": [],
174
+ "ofiles": [],
171
175
  "files": [],
172
176
  "extensions": []
173
177
  },
@@ -183,8 +187,10 @@ When scanning files, the library applies filters in this exact sequence:
183
187
  | Field | Description |
184
188
  |-------|--------------|
185
189
  | `root_dir` | Base directory (absolute or relative). |
186
- | `filters.include.dirs` | Directory inclusion patterns. Broader and narrower patterns can coexist; a more specific include dir beats a broader exclude dir (see decision order). |
187
- | `filters.include.files` | File inclusion patterns (glob-like). Also participates in specificity comparisons against excludes. |
190
+ | `filters.include.dirs` | Directory inclusion patterns for gating (must match when any include filters are set). Never overrides excludes. |
191
+ | `filters.include.odirs` | Optional. Override directory patterns (defaults to `[]` when omitted). When more specific than a matching exclude, the exclude is ignored for that path. Also satisfies dir gating. |
192
+ | `filters.include.ofiles` | Optional. Override file patterns (defaults to `[]` when omitted). When more specific than a matching exclude, the exclude is ignored for that path. Also satisfies file gating (does not skip extension whitelist). |
193
+ | `filters.include.files` | File inclusion patterns (glob-like). Forces inclusion (skips extension whitelist) once path excludes are resolved. Does not override excludes. |
188
194
  | `filters.include.extensions` | Extension whitelist. |
189
195
  | `filters.exclude.*` | Same structure, but acts as exclusion filters. |
190
196
 
@@ -402,14 +408,15 @@ When scanning files, the library applies filters in this exact sequence:
402
408
  ### Example 7 — Exclude a subtree, except an explicit include path
403
409
 
404
410
  Exclude everything under `KLM`, but still collect files under `KLM/ABC`.
405
- List the exception alongside the broad include in normal `include.dirs`:
411
+ Use `include.dirs` for the broad scope and `include.odirs` for the exception:
406
412
 
407
413
  ```json
408
414
  {
409
415
  "root_dir": ".",
410
416
  "filters": {
411
417
  "include": {
412
- "dirs": ["**", "**/KLM/ABC/**"],
418
+ "dirs": ["**"],
419
+ "odirs": ["**/KLM/ABC/**"],
413
420
  "files": [],
414
421
  "extensions": ["c"]
415
422
  },
@@ -425,20 +432,52 @@ List the exception alongside the broad include in normal `include.dirs`:
425
432
  | File Path | Result | Reason |
426
433
  |------------|---------|--------|
427
434
  | `src/foo.c` | ✅ | broad `**` include + `.c` extension |
428
- | `src/KLM/skip.c` | ❌ | `**/KLM/**` exclude beats broad `**` |
429
- | `src/KLM/ABC/keep.c` | ✅ | `**/KLM/ABC/**` include beats `**/KLM/**` exclude |
430
- | `src/x/KLM/ABC/deep/keep.c` | ✅ | exception works at any depth |
435
+ | `src/KLM/skip.c` | ❌ | `**/KLM/**` exclude; no matching `odirs` |
436
+ | `src/KLM/ABC/keep.c` | ✅ | `**/KLM/ABC/**` odir beats `**/KLM/**` exclude |
437
+ | `src/x/KLM/ABC/deep/keep.c` | ✅ | odir exception works at any depth |
431
438
  | `src/KLM/ABC/keep.txt` | ❌ | wrong extension (still inside the allowed dir) |
432
439
 
433
- The same technique applies to other “exclude a path part, except one branch” cases — for example, exclude `**/ABC/**` but keep `**/ABC/KLM/**` by adding both `src/**` (or `**`) and `**/ABC/KLM/**` to `include.dirs`.
440
+ The same technique applies to other “exclude a path part, except one branch” cases — for example, exclude `**/ABC/**` but keep `**/ABC/KLM/**` via `include.odirs: ["**/ABC/KLM/**"]` alongside `include.dirs: ["src/**"]`.
441
+
442
+ ---
443
+
444
+ ### Example 8 — Exclude file patterns, except specific files
445
+
446
+ Exclude all `test_*.py` files, but keep `test_keep.py`:
447
+
448
+ ```json
449
+ {
450
+ "root_dir": ".",
451
+ "filters": {
452
+ "include": {
453
+ "dirs": ["**"],
454
+ "ofiles": ["**/test_keep.py"],
455
+ "files": [],
456
+ "extensions": ["py"]
457
+ },
458
+ "exclude": {
459
+ "dirs": [],
460
+ "files": ["**/test_*.py"],
461
+ "extensions": []
462
+ }
463
+ }
464
+ }
465
+ ```
466
+
467
+ | File Path | Result | Reason |
468
+ |------------|---------|--------|
469
+ | `src/app.py` | ✅ | broad include + `.py` extension |
470
+ | `src/test_foo.py` | ❌ | `**/test_*.py` exclude; no matching `ofiles` |
471
+ | `src/test_keep.py` | ✅ | `**/test_keep.py` ofile beats `**/test_*.py` exclude |
472
+ | `nested/test_keep.py` | ✅ | ofile exception works at any depth |
434
473
 
435
474
  ---
436
475
 
437
476
  ## Summary of Behavior
438
477
 
439
478
  - `exclude.extensions` are always applied first (hard exclude).
440
- - For path rules, **the most specific matching pattern wins** — a narrow `include.dirs` entry can beat a broader `exclude.dirs` entry in the same config.
441
- - Broad includes such as `**` do **not** cancel excludes; only a **more specific** include does.
479
+ - `include.dirs` and `include.files` **never** override path excludes — they only gate inclusion.
480
+ - Only `include.odirs` and `include.ofiles` can override a matching exclude, and only when the override pattern is **more specific**.
442
481
  - A matching `include.files` pattern forces inclusion (skips extension whitelist), once path excludes are resolved.
443
482
  - If any include filters exist, at least one must match (inclusion gating).
444
483
  - Extensions act as a **final whitelist** when not bypassed by `include.files`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "filefilter"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "Filter files in a directory tree based on configurable glob rules."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Ioannis D (devcoons)", email = "support@devcoons.com" }]
@@ -24,7 +24,7 @@
24
24
  # #
25
25
  #########################################################################################
26
26
 
27
- __version__ = '0.2.2'
27
+ __version__ = '0.2.4'
28
28
 
29
29
  #########################################################################################
30
30
  # IMPORTS #
@@ -164,12 +164,13 @@ def should_include(full_path: str, cfg: Ruleset) -> bool:
164
164
  return False
165
165
 
166
166
  inc_dirs = _merge(cfg.inc_dirs_root, cfg.inc_dirs_one, cfg.inc_dirs_any)
167
- inc_path_spec = best_matching_specificity(inc_dirs, match_dir, dir_rel)
168
- if cfg.include_files:
169
- inc_path_spec = max(
170
- inc_path_spec,
171
- best_matching_specificity(cfg.include_files, match_file, rel),
172
- )
167
+ odirs = _merge(cfg.inc_odirs_root, cfg.inc_odirs_one, cfg.inc_odirs_any)
168
+ gate_dirs = _merge(inc_dirs, odirs)
169
+
170
+ odir_spec = best_matching_specificity(odirs, match_dir, dir_rel)
171
+ ofiles = cfg.include_ofiles or []
172
+ ofile_spec = best_matching_specificity(ofiles, match_file, rel)
173
+ override_spec = max(odir_spec, ofile_spec)
173
174
 
174
175
  exc_dirs = _merge(cfg.exc_dirs_root, cfg.exc_dirs_one, cfg.exc_dirs_any)
175
176
  exc_path_spec = -1
@@ -186,15 +187,16 @@ def should_include(full_path: str, cfg: Ruleset) -> bool:
186
187
  exc_path_spec,
187
188
  best_matching_specificity(exc_dirs, match_dir, dir_rel),
188
189
  )
189
- if exclude_path_hit and inc_path_spec <= exc_path_spec:
190
+ if exclude_path_hit and override_spec <= exc_path_spec:
190
191
  return False
191
192
 
192
- files_present = bool(cfg.include_files)
193
- files_match = files_present and match_file(rel, cfg.include_files)
194
- if files_match:
193
+ gate_files = _merge(cfg.include_files, ofiles)
194
+ if cfg.include_files and match_file(rel, cfg.include_files):
195
195
  return True
196
- dirs_present = bool(inc_dirs)
197
- dirs_match = dirs_present and match_dir(dir_rel, inc_dirs)
196
+ files_present = bool(gate_files)
197
+ files_match = files_present and match_file(rel, gate_files)
198
+ dirs_present = bool(gate_dirs)
199
+ dirs_match = dirs_present and match_dir(dir_rel, gate_dirs)
198
200
  if (files_present or dirs_present) and not (files_match or dirs_match):
199
201
  return False
200
202
  if cfg.inc_exts and not ext_matches(ext, cfg.inc_exts, name):
@@ -54,9 +54,11 @@ class Ruleset:
54
54
  exc = data['filters']['exclude']
55
55
 
56
56
  self.inc_dirs_root, self.inc_dirs_one, self.inc_dirs_any = parse_dir_patterns(inc.get('dirs', []))
57
+ self.inc_odirs_root, self.inc_odirs_one, self.inc_odirs_any = parse_dir_patterns(inc.get('odirs', []))
57
58
  self.exc_dirs_root, self.exc_dirs_one, self.exc_dirs_any = parse_dir_patterns(exc.get('dirs', []))
58
59
 
59
60
  self.include_files = parse_file_patterns(inc.get('files', []))
61
+ self.include_ofiles = parse_file_patterns(inc.get('ofiles', []))
60
62
  self.exclude_files = parse_file_patterns(exc.get('files', []))
61
63
 
62
64
  self.inc_exts = parse_extensions(inc.get('extensions', []))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: filefilter
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Filter files in a directory tree based on configurable glob rules.
5
5
  Author-email: "Ioannis D (devcoons)" <support@devcoons.com>
6
6
  License-Expression: MIT
@@ -161,11 +161,10 @@ When scanning files, the library applies filters in this exact sequence:
161
161
  - If the file extension matches `exclude.extensions` → **excluded immediately**.
162
162
  - This step is not overridden by include patterns.
163
163
 
164
- 2️⃣ **Path excludes vs. includes (specificity wins)**
164
+ 2️⃣ **Path excludes vs. override patterns (`include.odirs`, `include.ofiles`)**
165
165
  - If the file path matches `exclude.files`, or its parent directory matches `exclude.dirs`, the file is a candidate for exclusion.
166
- - At the same time, the library checks all matching patterns in `include.dirs` and `include.files`.
167
- - Each pattern receives a **specificity score** (more literal segments = higher score; more wildcards = lower score).
168
- - If the **best matching include pattern is more specific** than the best matching exclude pattern → the exclude is **ignored** for this file.
166
+ - **`include.dirs` and `include.files` do not override excludes** — they only gate inclusion later.
167
+ - If a matching pattern in `include.odirs` or `include.ofiles` is **more specific** than the best matching exclude pattern → the exclude is **ignored** for this file.
169
168
  - Otherwise → **excluded**.
170
169
 
171
170
  | Pattern | Relative specificity |
@@ -173,8 +172,11 @@ When scanning files, the library applies filters in this exact sequence:
173
172
  | `**` | very low (broad) |
174
173
  | `**/KLM/**` | higher |
175
174
  | `**/KLM/ABC/**` | highest |
175
+ | `**/test_*.py` vs `**/test_keep.py` | ofile wins (more specific) |
176
176
 
177
- > Put both the broad include and the exception in the same `include.dirs` list — no separate override section is needed.
177
+ > Use `include.dirs` for normal scoping (e.g. `**`, `src/**`).
178
+ > Use `include.odirs` for directory carve-outs under `exclude.dirs` / `exclude.files`.
179
+ > Use `include.ofiles` for file carve-outs under `exclude.files` / `exclude.dirs`.
178
180
 
179
181
  3️⃣ **Include-file fast path**
180
182
  - If the file matches any `include.files` pattern → **included immediately**,
@@ -201,6 +203,8 @@ When scanning files, the library applies filters in this exact sequence:
201
203
  "filters": {
202
204
  "include": {
203
205
  "dirs": [],
206
+ "odirs": [],
207
+ "ofiles": [],
204
208
  "files": [],
205
209
  "extensions": []
206
210
  },
@@ -216,8 +220,10 @@ When scanning files, the library applies filters in this exact sequence:
216
220
  | Field | Description |
217
221
  |-------|--------------|
218
222
  | `root_dir` | Base directory (absolute or relative). |
219
- | `filters.include.dirs` | Directory inclusion patterns. Broader and narrower patterns can coexist; a more specific include dir beats a broader exclude dir (see decision order). |
220
- | `filters.include.files` | File inclusion patterns (glob-like). Also participates in specificity comparisons against excludes. |
223
+ | `filters.include.dirs` | Directory inclusion patterns for gating (must match when any include filters are set). Never overrides excludes. |
224
+ | `filters.include.odirs` | Optional. Override directory patterns (defaults to `[]` when omitted). When more specific than a matching exclude, the exclude is ignored for that path. Also satisfies dir gating. |
225
+ | `filters.include.ofiles` | Optional. Override file patterns (defaults to `[]` when omitted). When more specific than a matching exclude, the exclude is ignored for that path. Also satisfies file gating (does not skip extension whitelist). |
226
+ | `filters.include.files` | File inclusion patterns (glob-like). Forces inclusion (skips extension whitelist) once path excludes are resolved. Does not override excludes. |
221
227
  | `filters.include.extensions` | Extension whitelist. |
222
228
  | `filters.exclude.*` | Same structure, but acts as exclusion filters. |
223
229
 
@@ -435,14 +441,15 @@ When scanning files, the library applies filters in this exact sequence:
435
441
  ### Example 7 — Exclude a subtree, except an explicit include path
436
442
 
437
443
  Exclude everything under `KLM`, but still collect files under `KLM/ABC`.
438
- List the exception alongside the broad include in normal `include.dirs`:
444
+ Use `include.dirs` for the broad scope and `include.odirs` for the exception:
439
445
 
440
446
  ```json
441
447
  {
442
448
  "root_dir": ".",
443
449
  "filters": {
444
450
  "include": {
445
- "dirs": ["**", "**/KLM/ABC/**"],
451
+ "dirs": ["**"],
452
+ "odirs": ["**/KLM/ABC/**"],
446
453
  "files": [],
447
454
  "extensions": ["c"]
448
455
  },
@@ -458,20 +465,52 @@ List the exception alongside the broad include in normal `include.dirs`:
458
465
  | File Path | Result | Reason |
459
466
  |------------|---------|--------|
460
467
  | `src/foo.c` | ✅ | broad `**` include + `.c` extension |
461
- | `src/KLM/skip.c` | ❌ | `**/KLM/**` exclude beats broad `**` |
462
- | `src/KLM/ABC/keep.c` | ✅ | `**/KLM/ABC/**` include beats `**/KLM/**` exclude |
463
- | `src/x/KLM/ABC/deep/keep.c` | ✅ | exception works at any depth |
468
+ | `src/KLM/skip.c` | ❌ | `**/KLM/**` exclude; no matching `odirs` |
469
+ | `src/KLM/ABC/keep.c` | ✅ | `**/KLM/ABC/**` odir beats `**/KLM/**` exclude |
470
+ | `src/x/KLM/ABC/deep/keep.c` | ✅ | odir exception works at any depth |
464
471
  | `src/KLM/ABC/keep.txt` | ❌ | wrong extension (still inside the allowed dir) |
465
472
 
466
- The same technique applies to other “exclude a path part, except one branch” cases — for example, exclude `**/ABC/**` but keep `**/ABC/KLM/**` by adding both `src/**` (or `**`) and `**/ABC/KLM/**` to `include.dirs`.
473
+ The same technique applies to other “exclude a path part, except one branch” cases — for example, exclude `**/ABC/**` but keep `**/ABC/KLM/**` via `include.odirs: ["**/ABC/KLM/**"]` alongside `include.dirs: ["src/**"]`.
474
+
475
+ ---
476
+
477
+ ### Example 8 — Exclude file patterns, except specific files
478
+
479
+ Exclude all `test_*.py` files, but keep `test_keep.py`:
480
+
481
+ ```json
482
+ {
483
+ "root_dir": ".",
484
+ "filters": {
485
+ "include": {
486
+ "dirs": ["**"],
487
+ "ofiles": ["**/test_keep.py"],
488
+ "files": [],
489
+ "extensions": ["py"]
490
+ },
491
+ "exclude": {
492
+ "dirs": [],
493
+ "files": ["**/test_*.py"],
494
+ "extensions": []
495
+ }
496
+ }
497
+ }
498
+ ```
499
+
500
+ | File Path | Result | Reason |
501
+ |------------|---------|--------|
502
+ | `src/app.py` | ✅ | broad include + `.py` extension |
503
+ | `src/test_foo.py` | ❌ | `**/test_*.py` exclude; no matching `ofiles` |
504
+ | `src/test_keep.py` | ✅ | `**/test_keep.py` ofile beats `**/test_*.py` exclude |
505
+ | `nested/test_keep.py` | ✅ | ofile exception works at any depth |
467
506
 
468
507
  ---
469
508
 
470
509
  ## Summary of Behavior
471
510
 
472
511
  - `exclude.extensions` are always applied first (hard exclude).
473
- - For path rules, **the most specific matching pattern wins** — a narrow `include.dirs` entry can beat a broader `exclude.dirs` entry in the same config.
474
- - Broad includes such as `**` do **not** cancel excludes; only a **more specific** include does.
512
+ - `include.dirs` and `include.files` **never** override path excludes — they only gate inclusion.
513
+ - Only `include.odirs` and `include.ofiles` can override a matching exclude, and only when the override pattern is **more specific**.
475
514
  - A matching `include.files` pattern forces inclusion (skips extension whitelist), once path excludes are resolved.
476
515
  - If any include filters exist, at least one must match (inclusion gating).
477
516
  - Extensions act as a **final whitelist** when not bypassed by `include.files`.
@@ -262,7 +262,8 @@ def test_regression_file_matching(path: str, patterns: list[str], expected: bool
262
262
 
263
263
  SPECIFICITY_SCENARIOS = [
264
264
  pytest.param(
265
- ["**", "**/KLM/ABC/**"],
265
+ ["**"],
266
+ ["**/KLM/ABC/**"],
266
267
  ["**/KLM/**"],
267
268
  {
268
269
  "src/foo.py": True,
@@ -273,7 +274,8 @@ SPECIFICITY_SCENARIOS = [
273
274
  id="klm-abc-exception",
274
275
  ),
275
276
  pytest.param(
276
- ["src/**", "**/ABC/KLM/**"],
277
+ ["src/**"],
278
+ ["**/ABC/KLM/**"],
277
279
  ["**/ABC/**"],
278
280
  {
279
281
  "src/foo.py": True,
@@ -286,6 +288,7 @@ SPECIFICITY_SCENARIOS = [
286
288
  ),
287
289
  pytest.param(
288
290
  ["**"],
291
+ [],
289
292
  ["**/__pycache__/**"],
290
293
  {
291
294
  "src/app.py": True,
@@ -294,7 +297,8 @@ SPECIFICITY_SCENARIOS = [
294
297
  id="pycache-broad-exclude-wins",
295
298
  ),
296
299
  pytest.param(
297
- ["**", "**/keep/**"],
300
+ ["**"],
301
+ [],
298
302
  ["**/skip/**"],
299
303
  {
300
304
  "skip/only.py": False,
@@ -306,10 +310,11 @@ SPECIFICITY_SCENARIOS = [
306
310
  ]
307
311
 
308
312
 
309
- @pytest.mark.parametrize("inc_dirs,exc_dirs,files", SPECIFICITY_SCENARIOS)
313
+ @pytest.mark.parametrize("inc_dirs,inc_odirs,exc_dirs,files", SPECIFICITY_SCENARIOS)
310
314
  def test_specificity_integration(
311
315
  tmp_path: Path,
312
316
  inc_dirs: list[str],
317
+ inc_odirs: list[str],
313
318
  exc_dirs: list[str],
314
319
  files: dict[str, bool],
315
320
  ):
@@ -317,6 +322,7 @@ def test_specificity_integration(
317
322
  touch(tmp_path / rel)
318
323
  cfg = make_config(
319
324
  include_dirs=inc_dirs,
325
+ include_odirs=inc_odirs,
320
326
  include_extensions=["py"],
321
327
  exclude_dirs=exc_dirs,
322
328
  )
@@ -179,8 +179,8 @@ def test_dotfile_file_pattern_not_extension_filter(tree: Path):
179
179
  assert str(tree / "main.py") not in paths
180
180
 
181
181
 
182
- def test_specific_include_dir_beats_broader_exclude(tmp_path: Path):
183
- """More specific include.dirs in the same list override broader exclude.dirs."""
182
+ def test_specific_odir_beats_broader_exclude(tmp_path: Path):
183
+ """Only include.odirs override excludes; include.dirs do not."""
184
184
  touch(tmp_path / "src" / "foo.c")
185
185
  touch(tmp_path / "src" / "KLM" / "skip.c")
186
186
  touch(tmp_path / "src" / "KLM" / "ABC" / "keep.c")
@@ -188,7 +188,8 @@ def test_specific_include_dir_beats_broader_exclude(tmp_path: Path):
188
188
  touch(tmp_path / "src" / "x" / "KLM" / "ABC" / "keep.c")
189
189
 
190
190
  cfg = make_config(
191
- include_dirs=["**", "**/KLM/ABC/**"],
191
+ include_dirs=["**"],
192
+ include_odirs=["**/KLM/ABC/**"],
192
193
  include_extensions=["c"],
193
194
  exclude_dirs=["**/KLM/**"],
194
195
  )
@@ -201,6 +202,17 @@ def test_specific_include_dir_beats_broader_exclude(tmp_path: Path):
201
202
  assert str(tmp_path / "src" / "x" / "KLM" / "ABC" / "keep.c") in paths
202
203
 
203
204
 
205
+ def test_include_dirs_do_not_override_exclude(tmp_path: Path):
206
+ touch(tmp_path / "src" / "KLM" / "ABC" / "keep.c")
207
+ cfg = make_config(
208
+ include_dirs=["**", "**/KLM/ABC/**"],
209
+ include_extensions=["c"],
210
+ exclude_dirs=["**/KLM/**"],
211
+ )
212
+ paths = select_paths(tmp_path, cfg)
213
+ assert str(tmp_path / "src" / "KLM" / "ABC" / "keep.c") not in paths
214
+
215
+
204
216
  def test_broad_include_dir_does_not_beat_specific_exclude(tree: Path):
205
217
  cfg = make_config(
206
218
  include_dirs=["**"],
@@ -213,8 +225,8 @@ def test_broad_include_dir_does_not_beat_specific_exclude(tree: Path):
213
225
  assert str(tree / "src" / "__pycache__" / "cached.py") not in paths
214
226
 
215
227
 
216
- def test_abc_klm_exception_via_include_dirs_only(tmp_path: Path):
217
- """Earlier ABC/KLM scenario using only include.dirs + exclude.dirs."""
228
+ def test_abc_klm_exception_via_include_odirs(tmp_path: Path):
229
+ """ABC/KLM carve-out uses include.odirs, not include.dirs."""
218
230
  touch(tmp_path / "src" / "foo.c")
219
231
  touch(tmp_path / "src" / "ABC" / "only.c")
220
232
  touch(tmp_path / "src" / "ABC" / "other" / "skip.c")
@@ -224,7 +236,8 @@ def test_abc_klm_exception_via_include_dirs_only(tmp_path: Path):
224
236
  touch(tmp_path / "src" / "x" / "ABC" / "KLM" / "keep.c")
225
237
 
226
238
  cfg = make_config(
227
- include_dirs=["src/**", "**/ABC/KLM/**"],
239
+ include_dirs=["src/**"],
240
+ include_odirs=["**/ABC/KLM/**"],
228
241
  include_extensions=["c"],
229
242
  exclude_dirs=["**/ABC/**"],
230
243
  )
@@ -237,3 +250,65 @@ def test_abc_klm_exception_via_include_dirs_only(tmp_path: Path):
237
250
  assert str(tmp_path / "src" / "ABC" / "KLM" / "nested" / "keep.c") in paths
238
251
  assert str(tmp_path / "src" / "x" / "ABC" / "other" / "skip.c") not in paths
239
252
  assert str(tmp_path / "src" / "x" / "ABC" / "KLM" / "keep.c") in paths
253
+
254
+
255
+ def test_specific_ofile_beats_broader_exclude_file(tmp_path: Path):
256
+ """Only include.ofiles override excludes; include.files do not."""
257
+ touch(tmp_path / "src" / "app.py")
258
+ touch(tmp_path / "src" / "test_foo.py")
259
+ touch(tmp_path / "src" / "test_keep.py")
260
+ touch(tmp_path / "src" / "nested" / "test_keep.py")
261
+
262
+ cfg = make_config(
263
+ include_dirs=["**"],
264
+ include_ofiles=["**/test_keep.py"],
265
+ include_extensions=["py"],
266
+ exclude_files=["**/test_*.py"],
267
+ )
268
+ paths = select_paths(tmp_path, cfg)
269
+
270
+ assert str(tmp_path / "src" / "app.py") in paths
271
+ assert str(tmp_path / "src" / "test_foo.py") not in paths
272
+ assert str(tmp_path / "src" / "test_keep.py") in paths
273
+ assert str(tmp_path / "src" / "nested" / "test_keep.py") in paths
274
+
275
+
276
+ def test_include_files_do_not_override_exclude_file(tmp_path: Path):
277
+ touch(tmp_path / "src" / "test_keep.py")
278
+ cfg = make_config(
279
+ include_dirs=["**"],
280
+ include_files=["**/test_keep.py", "**/test_*.py"],
281
+ include_extensions=["py"],
282
+ exclude_files=["**/test_*.py"],
283
+ )
284
+ paths = select_paths(tmp_path, cfg)
285
+ assert str(tmp_path / "src" / "test_keep.py") not in paths
286
+
287
+
288
+ def test_ofile_overrides_exclude_dir_for_single_file(tmp_path: Path):
289
+ touch(tmp_path / "build" / "app.py")
290
+ touch(tmp_path / "build" / "keep.py")
291
+ cfg = make_config(
292
+ include_dirs=["**"],
293
+ include_ofiles=["**/build/keep.py"],
294
+ include_extensions=["py"],
295
+ exclude_dirs=["**/build/**"],
296
+ )
297
+ paths = select_paths(tmp_path, cfg)
298
+ assert str(tmp_path / "build" / "app.py") not in paths
299
+ assert str(tmp_path / "build" / "keep.py") in paths
300
+
301
+
302
+ def test_ofile_satisfies_file_gating_without_fast_path(tmp_path: Path):
303
+ """ofiles satisfy gating but do not skip extension whitelist."""
304
+ touch(tmp_path / "src" / "keep.txt")
305
+ touch(tmp_path / "src" / "skip.txt")
306
+ cfg = make_config(
307
+ include_files=["**/only.py"],
308
+ include_ofiles=["**/keep.txt"],
309
+ include_extensions=["py"],
310
+ exclude_files=["**/*.txt"],
311
+ )
312
+ paths = select_paths(tmp_path, cfg)
313
+ assert str(tmp_path / "src" / "keep.txt") not in paths
314
+ assert str(tmp_path / "src" / "skip.txt") not in paths
@@ -123,7 +123,12 @@ README_EXAMPLES = [
123
123
  {
124
124
  "root_dir": ".",
125
125
  "filters": {
126
- "include": {"dirs": ["**", "**/KLM/ABC/**"], "files": [], "extensions": ["c"]},
126
+ "include": {
127
+ "dirs": ["**"],
128
+ "odirs": ["**/KLM/ABC/**"],
129
+ "files": [],
130
+ "extensions": ["c"],
131
+ },
127
132
  "exclude": {"dirs": ["**/KLM/**"], "files": [], "extensions": []},
128
133
  },
129
134
  },
@@ -191,7 +196,12 @@ def test_readme_abc_klm_branch_exception(tmp_path: Path):
191
196
  cfg = {
192
197
  "root_dir": ".",
193
198
  "filters": {
194
- "include": {"dirs": ["src/**", "**/ABC/KLM/**"], "files": [], "extensions": ["c"]},
199
+ "include": {
200
+ "dirs": ["src/**"],
201
+ "odirs": ["**/ABC/KLM/**"],
202
+ "files": [],
203
+ "extensions": ["c"],
204
+ },
195
205
  "exclude": {"dirs": ["**/ABC/**"], "files": [], "extensions": []},
196
206
  },
197
207
  }
@@ -6,7 +6,8 @@ from pathlib import Path
6
6
 
7
7
  import pytest
8
8
 
9
- from filefilter import Ruleset, load
9
+ from filefilter import Ruleset, load, matches
10
+ from conftest import touch
10
11
 
11
12
 
12
13
  def test_ruleset_resolves_relative_root_against_base(tmp_path: Path):
@@ -53,6 +54,67 @@ def test_parse_extensions_normalizes_values():
53
54
  assert rules.exc_exts == [".log"]
54
55
 
55
56
 
57
+ def test_missing_odirs_defaults_to_empty(tmp_path: Path):
58
+ cfg = {
59
+ "root_dir": ".",
60
+ "filters": {
61
+ "include": {"dirs": ["**"], "extensions": ["py"]},
62
+ "exclude": {"dirs": ["**/build/**"]},
63
+ },
64
+ }
65
+ touch(tmp_path / "src" / "app.py")
66
+ touch(tmp_path / "build" / "app.py")
67
+ rules = load(json.dumps(cfg), base=str(tmp_path))
68
+ assert rules.inc_odirs_root == []
69
+ assert rules.inc_odirs_one == []
70
+ assert rules.inc_odirs_any == []
71
+ assert matches(str(tmp_path / "src" / "app.py"), rules) is True
72
+ assert matches(str(tmp_path / "build" / "app.py"), rules) is False
73
+
74
+
75
+ def test_parse_odirs_buckets_like_dirs():
76
+ cfg = {
77
+ "root_dir": ".",
78
+ "filters": {
79
+ "include": {"dirs": ["src"], "odirs": ["**/keep/**", "*/pkg"]},
80
+ "exclude": {},
81
+ },
82
+ }
83
+ rules = Ruleset(cfg, resolve_base=".")
84
+ assert rules.inc_dirs_root == ["src"]
85
+ assert rules.inc_odirs_any == ["**/keep/**"]
86
+ assert rules.inc_odirs_one == ["*/pkg"]
87
+
88
+
89
+ def test_missing_ofiles_defaults_to_empty(tmp_path: Path):
90
+ cfg = {
91
+ "root_dir": ".",
92
+ "filters": {
93
+ "include": {"dirs": ["**"], "extensions": ["py"]},
94
+ "exclude": {"files": ["**/skip.py"]},
95
+ },
96
+ }
97
+ touch(tmp_path / "app.py")
98
+ touch(tmp_path / "skip.py")
99
+ rules = load(json.dumps(cfg), base=str(tmp_path))
100
+ assert rules.include_ofiles == []
101
+ assert matches(str(tmp_path / "app.py"), rules) is True
102
+ assert matches(str(tmp_path / "skip.py"), rules) is False
103
+
104
+
105
+ def test_parse_ofiles_like_files():
106
+ cfg = {
107
+ "root_dir": ".",
108
+ "filters": {
109
+ "include": {"files": ["*.py"], "ofiles": ["**/keep.txt", "README.*"]},
110
+ "exclude": {},
111
+ },
112
+ }
113
+ rules = Ruleset(cfg, resolve_base=".")
114
+ assert rules.include_files == ["*.py"]
115
+ assert rules.include_ofiles == ["**/keep.txt", "readme.*"]
116
+
117
+
56
118
  def test_empty_dir_patterns_are_ignored(tmp_path: Path):
57
119
  cfg = {
58
120
  "root_dir": ".",
File without changes
File without changes