filefilter 0.2.2__tar.gz → 0.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: filefilter
3
- Version: 0.2.2
3
+ Version: 0.2.3
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 dirs (`include.odirs`)**
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` 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 |
@@ -174,7 +173,8 @@ When scanning files, the library applies filters in this exact sequence:
174
173
  | `**/KLM/**` | higher |
175
174
  | `**/KLM/ABC/**` | highest |
176
175
 
177
- > Put both the broad include and the exception in the same `include.dirs` list — no separate override section is needed.
176
+ > Use `include.dirs` for normal scoping (e.g. `**`, `src/**`).
177
+ > Use `include.odirs` only for explicit exceptions to `exclude.dirs` / `exclude.files`.
178
178
 
179
179
  3️⃣ **Include-file fast path**
180
180
  - If the file matches any `include.files` pattern → **included immediately**,
@@ -201,6 +201,7 @@ When scanning files, the library applies filters in this exact sequence:
201
201
  "filters": {
202
202
  "include": {
203
203
  "dirs": [],
204
+ "odirs": [],
204
205
  "files": [],
205
206
  "extensions": []
206
207
  },
@@ -216,8 +217,9 @@ When scanning files, the library applies filters in this exact sequence:
216
217
  | Field | Description |
217
218
  |-------|--------------|
218
219
  | `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. |
220
+ | `filters.include.dirs` | Directory inclusion patterns for gating (must match when any include filters are set). Never overrides excludes. |
221
+ | `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. |
222
+ | `filters.include.files` | File inclusion patterns (glob-like). Forces inclusion (skips extension whitelist) once path excludes are resolved. Does not override excludes. |
221
223
  | `filters.include.extensions` | Extension whitelist. |
222
224
  | `filters.exclude.*` | Same structure, but acts as exclusion filters. |
223
225
 
@@ -435,14 +437,15 @@ When scanning files, the library applies filters in this exact sequence:
435
437
  ### Example 7 — Exclude a subtree, except an explicit include path
436
438
 
437
439
  Exclude everything under `KLM`, but still collect files under `KLM/ABC`.
438
- List the exception alongside the broad include in normal `include.dirs`:
440
+ Use `include.dirs` for the broad scope and `include.odirs` for the exception:
439
441
 
440
442
  ```json
441
443
  {
442
444
  "root_dir": ".",
443
445
  "filters": {
444
446
  "include": {
445
- "dirs": ["**", "**/KLM/ABC/**"],
447
+ "dirs": ["**"],
448
+ "odirs": ["**/KLM/ABC/**"],
446
449
  "files": [],
447
450
  "extensions": ["c"]
448
451
  },
@@ -458,20 +461,20 @@ List the exception alongside the broad include in normal `include.dirs`:
458
461
  | File Path | Result | Reason |
459
462
  |------------|---------|--------|
460
463
  | `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 |
464
+ | `src/KLM/skip.c` | ❌ | `**/KLM/**` exclude; no matching `odirs` |
465
+ | `src/KLM/ABC/keep.c` | ✅ | `**/KLM/ABC/**` odir beats `**/KLM/**` exclude |
466
+ | `src/x/KLM/ABC/deep/keep.c` | ✅ | odir exception works at any depth |
464
467
  | `src/KLM/ABC/keep.txt` | ❌ | wrong extension (still inside the allowed dir) |
465
468
 
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`.
469
+ 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/**"]`.
467
470
 
468
471
  ---
469
472
 
470
473
  ## Summary of Behavior
471
474
 
472
475
  - `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.
476
+ - `include.dirs` and `include.files` **never** override path excludes — they only gate inclusion.
477
+ - Only `include.odirs` can override a matching exclude, and only when the odir pattern is **more specific**.
475
478
  - A matching `include.files` pattern forces inclusion (skips extension whitelist), once path excludes are resolved.
476
479
  - If any include filters exist, at least one must match (inclusion gating).
477
480
  - 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 dirs (`include.odirs`)**
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` 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 |
@@ -141,7 +140,8 @@ When scanning files, the library applies filters in this exact sequence:
141
140
  | `**/KLM/**` | higher |
142
141
  | `**/KLM/ABC/**` | highest |
143
142
 
144
- > Put both the broad include and the exception in the same `include.dirs` list — no separate override section is needed.
143
+ > Use `include.dirs` for normal scoping (e.g. `**`, `src/**`).
144
+ > Use `include.odirs` only for explicit exceptions to `exclude.dirs` / `exclude.files`.
145
145
 
146
146
  3️⃣ **Include-file fast path**
147
147
  - If the file matches any `include.files` pattern → **included immediately**,
@@ -168,6 +168,7 @@ When scanning files, the library applies filters in this exact sequence:
168
168
  "filters": {
169
169
  "include": {
170
170
  "dirs": [],
171
+ "odirs": [],
171
172
  "files": [],
172
173
  "extensions": []
173
174
  },
@@ -183,8 +184,9 @@ When scanning files, the library applies filters in this exact sequence:
183
184
  | Field | Description |
184
185
  |-------|--------------|
185
186
  | `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. |
187
+ | `filters.include.dirs` | Directory inclusion patterns for gating (must match when any include filters are set). Never overrides excludes. |
188
+ | `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. |
189
+ | `filters.include.files` | File inclusion patterns (glob-like). Forces inclusion (skips extension whitelist) once path excludes are resolved. Does not override excludes. |
188
190
  | `filters.include.extensions` | Extension whitelist. |
189
191
  | `filters.exclude.*` | Same structure, but acts as exclusion filters. |
190
192
 
@@ -402,14 +404,15 @@ When scanning files, the library applies filters in this exact sequence:
402
404
  ### Example 7 — Exclude a subtree, except an explicit include path
403
405
 
404
406
  Exclude everything under `KLM`, but still collect files under `KLM/ABC`.
405
- List the exception alongside the broad include in normal `include.dirs`:
407
+ Use `include.dirs` for the broad scope and `include.odirs` for the exception:
406
408
 
407
409
  ```json
408
410
  {
409
411
  "root_dir": ".",
410
412
  "filters": {
411
413
  "include": {
412
- "dirs": ["**", "**/KLM/ABC/**"],
414
+ "dirs": ["**"],
415
+ "odirs": ["**/KLM/ABC/**"],
413
416
  "files": [],
414
417
  "extensions": ["c"]
415
418
  },
@@ -425,20 +428,20 @@ List the exception alongside the broad include in normal `include.dirs`:
425
428
  | File Path | Result | Reason |
426
429
  |------------|---------|--------|
427
430
  | `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 |
431
+ | `src/KLM/skip.c` | ❌ | `**/KLM/**` exclude; no matching `odirs` |
432
+ | `src/KLM/ABC/keep.c` | ✅ | `**/KLM/ABC/**` odir beats `**/KLM/**` exclude |
433
+ | `src/x/KLM/ABC/deep/keep.c` | ✅ | odir exception works at any depth |
431
434
  | `src/KLM/ABC/keep.txt` | ❌ | wrong extension (still inside the allowed dir) |
432
435
 
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`.
436
+ 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/**"]`.
434
437
 
435
438
  ---
436
439
 
437
440
  ## Summary of Behavior
438
441
 
439
442
  - `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.
443
+ - `include.dirs` and `include.files` **never** override path excludes — they only gate inclusion.
444
+ - Only `include.odirs` can override a matching exclude, and only when the odir pattern is **more specific**.
442
445
  - A matching `include.files` pattern forces inclusion (skips extension whitelist), once path excludes are resolved.
443
446
  - If any include filters exist, at least one must match (inclusion gating).
444
447
  - 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.3"
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.3'
28
28
 
29
29
  #########################################################################################
30
30
  # IMPORTS #
@@ -164,12 +164,10 @@ 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)
173
171
 
174
172
  exc_dirs = _merge(cfg.exc_dirs_root, cfg.exc_dirs_one, cfg.exc_dirs_any)
175
173
  exc_path_spec = -1
@@ -186,15 +184,15 @@ def should_include(full_path: str, cfg: Ruleset) -> bool:
186
184
  exc_path_spec,
187
185
  best_matching_specificity(exc_dirs, match_dir, dir_rel),
188
186
  )
189
- if exclude_path_hit and inc_path_spec <= exc_path_spec:
187
+ if exclude_path_hit and odir_spec <= exc_path_spec:
190
188
  return False
191
189
 
192
190
  files_present = bool(cfg.include_files)
193
191
  files_match = files_present and match_file(rel, cfg.include_files)
194
192
  if files_match:
195
193
  return True
196
- dirs_present = bool(inc_dirs)
197
- dirs_match = dirs_present and match_dir(dir_rel, inc_dirs)
194
+ dirs_present = bool(gate_dirs)
195
+ dirs_match = dirs_present and match_dir(dir_rel, gate_dirs)
198
196
  if (files_present or dirs_present) and not (files_match or dirs_match):
199
197
  return False
200
198
  if cfg.inc_exts and not ext_matches(ext, cfg.inc_exts, name):
@@ -54,6 +54,7 @@ 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', []))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: filefilter
3
- Version: 0.2.2
3
+ Version: 0.2.3
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 dirs (`include.odirs`)**
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` 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 |
@@ -174,7 +173,8 @@ When scanning files, the library applies filters in this exact sequence:
174
173
  | `**/KLM/**` | higher |
175
174
  | `**/KLM/ABC/**` | highest |
176
175
 
177
- > Put both the broad include and the exception in the same `include.dirs` list — no separate override section is needed.
176
+ > Use `include.dirs` for normal scoping (e.g. `**`, `src/**`).
177
+ > Use `include.odirs` only for explicit exceptions to `exclude.dirs` / `exclude.files`.
178
178
 
179
179
  3️⃣ **Include-file fast path**
180
180
  - If the file matches any `include.files` pattern → **included immediately**,
@@ -201,6 +201,7 @@ When scanning files, the library applies filters in this exact sequence:
201
201
  "filters": {
202
202
  "include": {
203
203
  "dirs": [],
204
+ "odirs": [],
204
205
  "files": [],
205
206
  "extensions": []
206
207
  },
@@ -216,8 +217,9 @@ When scanning files, the library applies filters in this exact sequence:
216
217
  | Field | Description |
217
218
  |-------|--------------|
218
219
  | `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. |
220
+ | `filters.include.dirs` | Directory inclusion patterns for gating (must match when any include filters are set). Never overrides excludes. |
221
+ | `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. |
222
+ | `filters.include.files` | File inclusion patterns (glob-like). Forces inclusion (skips extension whitelist) once path excludes are resolved. Does not override excludes. |
221
223
  | `filters.include.extensions` | Extension whitelist. |
222
224
  | `filters.exclude.*` | Same structure, but acts as exclusion filters. |
223
225
 
@@ -435,14 +437,15 @@ When scanning files, the library applies filters in this exact sequence:
435
437
  ### Example 7 — Exclude a subtree, except an explicit include path
436
438
 
437
439
  Exclude everything under `KLM`, but still collect files under `KLM/ABC`.
438
- List the exception alongside the broad include in normal `include.dirs`:
440
+ Use `include.dirs` for the broad scope and `include.odirs` for the exception:
439
441
 
440
442
  ```json
441
443
  {
442
444
  "root_dir": ".",
443
445
  "filters": {
444
446
  "include": {
445
- "dirs": ["**", "**/KLM/ABC/**"],
447
+ "dirs": ["**"],
448
+ "odirs": ["**/KLM/ABC/**"],
446
449
  "files": [],
447
450
  "extensions": ["c"]
448
451
  },
@@ -458,20 +461,20 @@ List the exception alongside the broad include in normal `include.dirs`:
458
461
  | File Path | Result | Reason |
459
462
  |------------|---------|--------|
460
463
  | `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 |
464
+ | `src/KLM/skip.c` | ❌ | `**/KLM/**` exclude; no matching `odirs` |
465
+ | `src/KLM/ABC/keep.c` | ✅ | `**/KLM/ABC/**` odir beats `**/KLM/**` exclude |
466
+ | `src/x/KLM/ABC/deep/keep.c` | ✅ | odir exception works at any depth |
464
467
  | `src/KLM/ABC/keep.txt` | ❌ | wrong extension (still inside the allowed dir) |
465
468
 
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`.
469
+ 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/**"]`.
467
470
 
468
471
  ---
469
472
 
470
473
  ## Summary of Behavior
471
474
 
472
475
  - `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.
476
+ - `include.dirs` and `include.files` **never** override path excludes — they only gate inclusion.
477
+ - Only `include.odirs` can override a matching exclude, and only when the odir pattern is **more specific**.
475
478
  - A matching `include.files` pattern forces inclusion (skips extension whitelist), once path excludes are resolved.
476
479
  - If any include filters exist, at least one must match (inclusion gating).
477
480
  - 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
  )
@@ -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,38 @@ 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
+
56
89
  def test_empty_dir_patterns_are_ignored(tmp_path: Path):
57
90
  cfg = {
58
91
  "root_dir": ".",
File without changes
File without changes