Skip to content

Commit da9cf0f

Browse files
Bordaclaude[bot]github-advanced-security[bot]
authored
fix(cli,audit): scan dedup, plain dirs, versions (#215)
- Fixed audit scans to deduplicate re-exported wrappers without dropping wrappers from non-recursive scans or external defining modules - Fixed audit report formatting so chained proxy targets are read without emitting warnings, consuming warning budget, or fabricating module paths - Fixed CLI plain-directory scans so successful checks exit 0 and nested-file skip warnings are reported on stderr - Fixed CLI aggregate status handling so status-rendering failures do not change the overall exit code - Improved CLI version detection for packages whose import names differ from distribution names, with a note when no version can be resolved - Fixed expiry checks to propagate user-package import failures instead of misreporting them as missing version metadata - Updated CI workflow permissions and link-check handling for release artifact downloads and timeout responses - Updated documentation for CLI/audit behavior, public exports, FAQ metadata, contribution examples, troubleshooting anchors, and audit examples - Updated project policy to allow bare `assert` statements inside pytest test functions - Updated changelog entries for the shipped CLI and audit behavior fixes --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1 parent 3af1dbe commit da9cf0f

22 files changed

Lines changed: 581 additions & 76 deletions

.github/CONTRIBUTING.md

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -330,15 +330,18 @@ pyDeprecate/
330330
├── src/deprecate/ # Core library code
331331
│ ├── __about__.py # Version and metadata
332332
│ ├── __init__.py # Public API exports
333-
│ ├── docstring/ # Docstring utilities subpackage
334-
│ │ ├── inject.py # Runtime injection helpers: TEMPLATE_DOC_*, _update_docstring_*()
335-
│ │ ├── griffe_ext.py # Griffe extension for mkdocstrings / MkDocs (beta)
336-
│ │ └── sphinx_ext.py # Sphinx autodoc extension (beta)
333+
│ ├── __main__.py # python -m deprecate entry point
334+
│ ├── _cli.py # CLI subcommands: check, expiry, chains, all, status
335+
│ ├── _pkg.py # Version and path resolution helpers
337336
│ ├── _types.py # Shared type definitions: DeprecationConfig, _ProxyConfig
338337
│ ├── deprecation.py # @deprecated decorator and warning logic
339-
│ ├── audit.py # Audit tools: validate_*, find_deprecation_wrappers()
340338
│ ├── proxy.py # Instance/class proxy: deprecated_class(), deprecated_instance()
341-
│ └── utils.py # Low-level helpers: void(), no_warning_call()
339+
│ ├── audit.py # Audit tools: validate_*, find_deprecation_wrappers()
340+
│ ├── utils.py # Low-level helpers: void(), assert_no_warnings()
341+
│ └── docstring/ # Docstring utilities subpackage
342+
│ ├── inject.py # Runtime injection helpers: TEMPLATE_DOC_*, _update_docstring_*()
343+
│ ├── griffe_ext.py # Griffe extension for mkdocstrings / MkDocs (beta)
344+
│ └── sphinx_ext.py # Sphinx autodoc extension (beta)
342345
├── tests/ # Test suite
343346
│ ├── collection_targets.py # Target functions (new implementations)
344347
│ ├── collection_deprecate.py # Deprecated wrappers (@deprecated)
@@ -393,13 +396,13 @@ Tests live in `tests/` and follow a **three-layer separation**:
393396
> - Use `print()` for values you want to verify, paired with a `<details><summary>Output: <code>expression</code></summary>` block immediately after the code block. The `<summary>` label shows the **expression** being evaluated (e.g. `cfg.timeout`), not the `print()` wrapper.
394397
> - Only import and use `pytest.raises` when an example intentionally raises an exception — this prevents the extracted test from crashing. Do **not** use `pytest.warns`; deprecation warnings are emitted to stderr and do not cause test failures.
395398
> - **Never use `with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always")`** in any `.md` code block (README, docs, docstrings). Use direct calls annotated with `# warns: FutureWarning` or `# silent` instead. Output blocks show only return values — not warning counts or `w[0].category.__name__`.
396-
> - Do **not** use bare `assert` statements — they crash the test with an unhelpful `AssertionError` if the value changes.
399+
> - Do **not** use bare `assert` statements in top-level example code — they crash the test with an unhelpful `AssertionError` if the value changes. **Exception:** bare `assert` statements inside `def test_...` function bodies shown as pytest integration examples are allowed and idiomatic — the test function itself is the test.
397400
> - Regenerate `test_readme.py` after any README change: `phmdoctest README.md --outfile tests/integration/test_readme.py`
398401
399402
> **Docs examples must use `print()` + output blocks — no `assert`.** For all `docs/**/*.md` blocks that execute code:
400403
>
401404
> - Use `print()` to display values; follow immediately with a `<details><summary>Output: <code>expression</code></summary>` block showing expected output.
402-
> - Do **not** use bare `assert` statements (e.g. `assert pt.x == 1.0`, `assert isinstance(obj, MyClass)`) — use `print()` instead so the value is visible rather than crashing with `AssertionError`.
405+
> - Do **not** use bare `assert` statements in top-level example code (e.g. `assert pt.x == 1.0`, `assert isinstance(obj, MyClass)`) — use `print()` instead so the value is visible rather than crashing with `AssertionError`. **Exception:** bare `assert` statements inside `def test_...` function bodies shown as pytest integration examples are allowed and idiomatic.
403406
> - Avoid placeholders that do not validate behavior.
404407
> - **Never import a fictional package name** in runnable examples — executable examples must import from actual test collection modules (`from tests import collection_deprecate`, `collection_misconfigured`, or `collection_chains`). For CI-template snippets that intentionally show a placeholder import, add `# phmdoctest:skip — CI template: replace my_package with your actual package` as the first line of the code block so phmdoctest skips execution.
405408
@@ -469,7 +472,7 @@ Examples:
469472
When adding a parametrized test that covers both forms, always add both fixtures and share the same `deprecated(...)` instance to guarantee identical configuration:
470473
471474
```python
472-
from deprecate import deprecated, void
475+
from deprecate import TargetMode, deprecated, void
473476
474477
475478
# original_* is declared first — _deprecation_* refers to it immediately after.
@@ -481,7 +484,7 @@ def original_sum_warn_only(a: int, b: int = 5) -> int:
481484
# The _deprecation_* variable is the deprecation tool (the decorator instance),
482485
# NOT a deprecated callable — that distinction is why it's named _deprecation_*
483486
# rather than _depr_* (which would imply the thing being deprecated).
484-
_deprecation_warn_only = deprecated(target=None, deprecated_in="0.2", remove_in="0.3")
487+
_deprecation_warn_only = deprecated(target=TargetMode.NOTIFY, deprecated_in="0.2", remove_in="0.3")
485488
486489
487490
@_deprecation_warn_only
@@ -526,7 +529,7 @@ WrappedWidget = _class_deprecation_widget(_OriginalWidget)
526529
527530
> **Rule**: when a `Decorated<Name>` / `Wrapped<Name>` pair exists, both **must** share a single `_class_deprecation_<name>` instance. Duplicating the `deprecated_class(...)` kwargs is a bug — a silent config drift will cause the parametrized test to compare two different deprecations instead of the same one in two application forms.
528531
529-
**Unification pattern — shared version kwargs and hoisted instances:**
532+
#### Unification pattern — shared version kwargs and hoisted instances
530533
531534
When three or more `@deprecated(...)` or `@deprecated_class(...)` call sites share the same `(deprecated_in, remove_in[, num_warns])` combination, extract the repeated kwargs into a named `dict` constant and splat it at each call site. This eliminates silent version drift and makes bulk version-bump changes a one-line edit.
532535
@@ -566,16 +569,16 @@ Functions in `collection_deprecate.py`, `collection_misconfigured.py`, `collecti
566569
Use a one-line summary of the deprecation pattern, then an `Examples:` section describing the user scenario:
567570
568571
```python
569-
from deprecate import deprecated
572+
from deprecate import TargetMode, deprecated
570573
571574
572-
@deprecated(target=None, deprecated_in="0.2", remove_in="0.3")
575+
@deprecated(target=TargetMode.NOTIFY, deprecated_in="0.2", remove_in="0.3")
573576
def decorated_sum_warn_only(a: int, b: int = 5) -> int:
574577
"""Warning-only deprecation with no forwarding.
575578
576579
Examples:
577580
The function is going away but has no replacement yet. The user gets
578-
warned, but the original body still executes (`target=None`).
581+
warned, but the original body still executes (TargetMode.NOTIFY).
579582
"""
580583
```
581584
@@ -670,10 +673,10 @@ def test_deprecation_warning() -> None:
670673
<summary>Argument renaming</summary>
671674

672675
```python
673-
from deprecate import deprecated
676+
from deprecate import TargetMode, deprecated
674677

675678

676-
@deprecated(target=True, deprecated_in="1.0", remove_in="2.0", args_mapping={"old_param": "new_param"})
679+
@deprecated(target=TargetMode.ARGS_REMAP, deprecated_in="1.0", remove_in="2.0", args_mapping={"old_param": "new_param"})
677680
def my_func(old_param: int = 0, new_param: int = 0) -> int:
678681
"""Function with renamed parameter."""
679682
return new_param
@@ -685,11 +688,11 @@ def my_func(old_param: int = 0, new_param: int = 0) -> int:
685688
<summary>Testing without warnings</summary>
686689

687690
```python
688-
from deprecate import no_warning_call
691+
from deprecate import assert_no_warnings
689692

690693

691694
def test_without_warning() -> None:
692-
with no_warning_call(FutureWarning):
695+
with assert_no_warnings(FutureWarning):
693696
# ... test code that should not emit warnings
694697
pass
695698
```

.github/workflows/_create-pkg.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ on:
1515
type: string
1616
default: "ubuntu-latest"
1717

18+
permissions:
19+
contents: read
20+
1821
jobs:
1922
package:
2023
runs-on: ${{ inputs.os }}

.github/workflows/ci_check-links.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ on:
88
# Run once a week on Sundays
99
- cron: "0 9 * * 0"
1010

11+
permissions:
12+
contents: read
13+
1114
jobs:
1215
links-check:
1316
runs-on: ubuntu-latest
@@ -35,7 +38,7 @@ jobs:
3538
--no-progress
3639
--cache
3740
--max-cache-age 1d
38-
--accept '100..=103,200..=299,429'
41+
--accept '100..=103,200..=299,408,429'
3942
--cache-exclude-status 429
4043
'./**/*.md'
4144
'./**/*.rst'

.github/workflows/ci_install-pkg.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ on: # Trigger the workflow on push or pull request, but only for the main branch
77
pull_request:
88
branches: [main]
99

10+
permissions:
11+
contents: read
12+
1013
defaults:
1114
run:
1215
shell: bash

.github/workflows/ci_testing.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ on: # Trigger the workflow on push or pull request, but only for the main branch
77
pull_request:
88
branches: [main]
99

10+
permissions:
11+
contents: read
12+
1013
jobs:
1114
pytester:
1215
runs-on: ${{ matrix.os }}

.github/workflows/greetings.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ name: Greetings
33

44
on: [issues] # pull_request
55

6+
permissions:
7+
issues: write
8+
69
jobs:
710
greeting:
811
runs-on: ubuntu-latest

.github/workflows/publish-pkg.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ on: # Trigger the workflow on push or pull request for the main branch, tag push
1010
release:
1111
types: [published]
1212

13+
permissions:
14+
contents: read
15+
1316
jobs:
1417
build:
1518
uses: ./.github/workflows/_create-pkg.yml
@@ -46,6 +49,9 @@ jobs:
4649
if: github.event_name == 'release'
4750
runs-on: ubuntu-latest
4851
timeout-minutes: 5
52+
permissions:
53+
actions: read # required for actions/download-artifact
54+
contents: write # required for uploading release assets
4955
steps:
5056
- name: 📥 Download artifact
5157
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ Write a clear explanation linking to both sources, then let maintainers decide o
9090
- **New troubleshooting item**: add to `docs/troubleshooting.md` AND the FAQPage JSON-LD in `docs/overrides/main.html`
9191
- **`docs/overrides/main.html`** is Jinja2 (prettier-excluded); do not put content files there
9292
- **`docs/robots.txt`** — AI crawler access policy; add a `User-agent: <bot> / Allow: /` block when a new mainstream AI crawler is released. The comment referencing the `docs/llms.txt` URL must stay current.
93-
- See [Documentation Site](.github/CONTRIBUTING.md#documentation-site) for the full consistency rules
93+
- See [Documentation Site](.github/CONTRIBUTING.md#-documentation-site) for the full consistency rules
9494

9595
## 🚫 Critical Constraints
9696

@@ -101,7 +101,7 @@ Write a clear explanation linking to both sources, then let maintainers decide o
101101
- Use bare `except:` clauses
102102
- Define deprecated wrappers inside test files
103103
- Use `with warnings.catch_warnings(...)` in any `.md` documentation example in any form — neither `simplefilter("always")` for capturing nor `simplefilter("ignore", ...)` for suppressing; annotate the call with `# warns: FutureWarning` or `# warns: UserWarning` instead; output blocks show only return values
104-
- Use bare `assert` statements in `.md` documentation examples (e.g. `assert pt.x == 1.0`, `assert isinstance(obj, MyClass)`) — use `print()` instead and follow with a `<details><summary>Output: <code>expression</code></summary>` block showing expected output
104+
- Use bare `assert` statements in `.md` documentation examples outside of `def test_...` bodies (e.g. `assert pt.x == 1.0`, `assert isinstance(obj, MyClass)`) — use `print()` instead and follow with a `<details><summary>Output: <code>expression</code></summary>` block showing expected output. **Exception:** bare `assert` inside `def test_...` function bodies shown as pytest integration examples is allowed and idiomatic
105105
- Import a fictional package name in runnable `.md` examples — executable examples must import from actual test collection modules (`from tests import collection_deprecate`, `collection_misconfigured`, or `collection_chains`). For CI-template snippets that intentionally show a placeholder import, add `# phmdoctest:skip — CI template: replace my_package with your actual package` as the first line so phmdoctest skips execution
106106
- Skip test coverage for new features or bug fixes
107107
- Implement features without maintainer approval
@@ -156,4 +156,4 @@ This file provides quick reference for agents. For complete, authoritative guide
156156
- **PR review guidelines**[Contributing: Reviewing PRs](.github/CONTRIBUTING.md#reviewing-prs)
157157
- **Security reporting**[Security Policy](.github/SECURITY.md)
158158
- **Community guidelines**[Code of Conduct](.github/CODE_OF_CONDUCT.md)
159-
- **Documentation site**[Contributing: Documentation Site](.github/CONTRIBUTING.md#documentation-site)
159+
- **Documentation site**[Contributing: Documentation Site](.github/CONTRIBUTING.md#-documentation-site)

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@
2222

2323
### Fixed
2424

25+
- **Audit scans no longer double-count re-exported wrappers.** Recursive `find_deprecation_wrappers` previously reported a wrapper once per importing module (e.g. once under the package root re-export and once under its defining submodule), inflating expiry counts and table rows; wrappers are now attributed to their defining module and deduplicated by identity across the scan. ([#215](https://github.com/Borda/pyDeprecate/pull/215))
26+
27+
- **Audit report formatting no longer triggers chained proxies.** Formatting a report for a wrapper whose `target` is itself a deprecated proxy previously emitted a spurious `FutureWarning` from inside the audit tooling, consumed the proxy's warn budget, and printed a fabricated module path; proxy targets are now read via static metadata access. ([#215](https://github.com/Borda/pyDeprecate/pull/215))
28+
29+
- **CLI: `all` and `status` no longer fail on plain directories.** Scanning a directory without `__init__.py` exited 1 from the status-table step even when every check passed; module-name resolution is now lazy and a status-rendering failure cannot change the aggregate exit code. ([#215](https://github.com/Borda/pyDeprecate/pull/215))
30+
31+
- **CLI: version auto-detection resolves distributions whose name differs from the import name.** `importlib.metadata` lookup previously failed for packages like pyDeprecate itself (import `deprecate`, distribution `pyDeprecate`); the import name is now mapped via `packages_distributions()`, and expiry prints an explicit note when it runs without a resolved version. ([#215](https://github.com/Borda/pyDeprecate/pull/215))
32+
2533
- **`num_warns` quota is now thread-safe.** Concurrent first calls to a shared wrapper could each pass the quota check before any counter increment, emitting up to one warning per thread instead of the configured budget; the warn path now synchronizes on a per-wrapper lock, so exactly `num_warns` warnings are emitted under concurrency. ([#214](https://github.com/Borda/pyDeprecate/pull/214))
2634

2735
- **Cross-class forwarding between staticmethods no longer raises a spurious `TypeError`.** The cross-class guard exists to prevent `self` carrying the wrong type, but staticmethods have no `self`; `@deprecated(target=NewCls.compute)` on a staticmethod forwarding to another class's staticmethod is now allowed. ([#214](https://github.com/Borda/pyDeprecate/pull/214))

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -996,7 +996,7 @@ red
996996

997997
</details>
998998

999-
Each deprecated attribute name has its own independent warning counter — with `num_warns=1`, both `color` and `size` each emit one warning, not one shared across all entries. See [Selective attribute deprecation](https://borda.github.io/pyDeprecate/stable/guide/use-cases.html#selective-attribute-deprecation) in the docs for write/delete redirect and Enum member alias examples.
999+
Each deprecated attribute name has its own independent warning counter — with `num_warns=1`, both `color` and `size` each emit one warning, not one shared across all entries. See [Selective attribute deprecation](https://borda.github.io/pyDeprecate/stable/guide/classes.html#selective-attribute-deprecation) in the docs for write/delete redirect and Enum member alias examples.
10001000

10011001
**Dataclass auto-expand** — when the wrapped class is a `@dataclass`, a single `deprecated_class(attrs_mapping={"old": "new"})` call automatically warns on both attribute access (`obj.old`) and constructor kwargs (`DC(old=5)`). No need to set `args_mapping` separately for a field rename. For non-`@dataclass` targets, `attrs_mapping` covers attribute access only — also set `args_mapping` to cover constructor kwargs.
10021002

@@ -1972,7 +1972,7 @@ print(MyClass(42).x)
19721972

19731973
### ❗ TypeError: `Failed mapping`
19741974

1975-
**Problem:** `TypeError: Failed mapping of 'my_func', arguments missing in target source: ['old_arg']`
1975+
**Problem:** `TypeError: Failed mapping of 'my_func', arguments not accepted by target: ['old_arg']`
19761976

19771977
**Cause:** Your deprecated function has arguments that the target function doesn't accept.
19781978

@@ -2136,9 +2136,11 @@ True
21362136

21372137
### 📦 Deprecation Not Working Across Modules
21382138

2139-
If you're moving functions to a different module or package, show the pattern rather than importing a non-existent package in the docs.
2139+
**Problem:** The deprecation notice shows an unexpected path when a function is moved to a different module.
21402140

2141-
The warning will correctly show the full path for real imports when used in your package.
2141+
**Cause:** pyDeprecate resolves the target path from `target.__module__` and `target.__qualname__` at decoration time — not at call time. If the target is imported via an alias or re-export at the point where `@deprecated` is applied, the stored path reflects the alias, not the canonical location.
2142+
2143+
**Solution:** Import the target from its canonical module location before applying `@deprecated`. The deprecation notice will then show the fully-qualified path where the function actually lives.
21422144

21432145
## 🤝 Contributing
21442146

0 commit comments

Comments
 (0)