diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..661c88c --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,44 @@ +name: Deploy Docs + +on: + push: + branches: [main] + paths: + - "docs/**" + - "mkdocs.yml" + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: astral-sh/setup-uv@v4 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - run: uv pip install --system mkdocs mkdocs-shadcn + - run: mkdocs build --strict + - uses: actions/upload-pages-artifact@v4 + with: + path: site/ + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index d6add4d..2888ee8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ build/ .venv/ .pytest_cache/ .ruff_cache/ +site/ diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..135ed0b --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [0.1.0] - 2026-03-30 + +### Documentation + +- Write comprehensive README with examples and configuration guide + +### Features + +- Add config parsing for rules and apply sections +- Add module pattern matcher with wildcard and capture support +- Add Violation dataclass for checker results +- Add variable name checker with source/transform and case support +- Add function/method name checker with filter and prefix support +- Add class name checker with base_class filter and regex support +- Add module name checker with class_name derivation and regex +- Add package name checker with case and regex support +- line output format +- Add CLI with pnl check command and end-to-end integration + +### Miscellaneous + +- Set up project scaffolding and tooling +- Add runtime dependencies and CLI entry point +- Fix ruff lint and format issues diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 0000000..9d16e9e --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,36 @@ +# CLI + +## `pnl check` + +Run the linter against your project: + +```bash +# Check with auto-discovered config (searches upward from cwd) +pnl check + +# Specify config file (project root = config file's parent directory) +pnl check --config path/to/config.yaml +``` + +### Options + +| Option | Description | +|--------|-------------| +| `--config` | Path to a config file. The config file's parent directory is used as the project root. | + +### Config Auto-Discovery + +If `--config` is not provided, `pnl check` searches upward from the current working directory for either: + +- `.python-naming-linter.yaml` +- `pyproject.toml` (with a `[tool.python-naming-linter]` section) + +The first matching file found is used, and its parent directory becomes the project root. + +## Exit Codes + +| Code | Meaning | +|------|---------| +| `0` | No violations found | +| `1` | One or more violations found | +| `2` | Config file not found | diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..cf2d59b --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,84 @@ +# Contributing + +## Commit Convention + +Commit messages must follow [Conventional Commits](https://www.conventionalcommits.org/) with [gitmoji](https://gitmoji.dev/) prefix. + +### Format + +``` + : +``` + +- The first letter after the colon must be **capitalized**. +- The description must be in **English**. + +### Types + +| Gitmoji | Type | Description | +|---------|------------|--------------------------| +| ✨ | `feat` | New feature | +| 🐛 | `fix` | Bug fix | +| ♻️ | `refactor` | Code refactoring | +| 📝 | `docs` | Documentation | +| ✅ | `test` | Adding or updating tests | +| 🔧 | `chore` | Maintenance tasks | +| 👷 | `ci` | CI/CD changes | +| ⚡ | `perf` | Performance improvement | + +### Examples + +``` +✨ feat: Add support for relative imports +🐛 fix: Use exit code 2 for config file not found +♻️ refactor: Simplify module resolver logic +``` + +## Pull Request Convention + +- PRs are always **squash merged**, so the PR title becomes the final commit message. +- PR titles must follow the same format as commit messages (` : `). +- PR descriptions must be written in **English**. + +## Pre-commit Hooks + +This project uses [pre-commit](https://pre-commit.com/) for linting, formatting, and type checking. + +```bash +# Install pre-commit hooks +pre-commit install + +# Run manually +pre-commit run --all-files +``` + +All commits must pass the pre-commit hooks before being accepted. + +## Release + +Releases are automated via GitHub Actions. You only need to create and push a version tag. + +### Steps + +1. Calculate the next version based on conventional commits: + ```bash + uvx git-cliff --bumped-version + ``` +2. Review the commits since the last tag: + ```bash + git log $(git describe --tags --abbrev=0)..HEAD --oneline + ``` +3. Push the latest commits to `main`: + ```bash + git push origin main + ``` +4. Create and push the tag: + ```bash + git tag + git push origin + ``` + +The GitHub Actions workflow will then automatically: +- Generate `CHANGELOG.md` and commit it to `main` +- Create a GitHub Release with release notes +- Publish the package to PyPI diff --git a/docs/cookbook/attribute-matches-type.md b/docs/cookbook/attribute-matches-type.md new file mode 100644 index 0000000..e588835 --- /dev/null +++ b/docs/cookbook/attribute-matches-type.md @@ -0,0 +1,52 @@ +# Attribute Matches Type + +## Purpose + +When an attribute holds a repository, service, or other typed object, keeping the attribute name in sync with the type annotation removes ambiguity and makes dependency injection transparent at a glance. This rule requires each class attribute name to be the snake_case form of its type annotation. + +## Configuration + +```yaml +rules: + - name: attribute-matches-type + type: variable + filter: { target: attribute } + naming: { source: type_annotation, transform: snake_case } + +apply: + - name: domain-layer + rules: [attribute-matches-type] + modules: contexts.*.domain +``` + +## Violation Example + +```python +# contexts/billing/domain/service.py + +class BillingService: + def __init__(self, repo: SubscriptionRepository) -> None: + self.repo = repo # should be subscription_repository +``` + +## Passing Example + +```python +# contexts/billing/domain/service.py + +class BillingService: + def __init__(self, repo: SubscriptionRepository) -> None: + self.subscription_repository = repo +``` + +The `{prefix}_{expected}` form is also allowed. For example, `source_object_context: ObjectContext` passes because the name ends with `_object_context`. + +## Output + +``` +$ pnl check +contexts/billing/domain/service.py:5 + [attribute-matches-type] repo (expected: subscription_repository) + +Found 1 violation(s). +``` diff --git a/docs/cookbook/bool-method-prefix.md b/docs/cookbook/bool-method-prefix.md new file mode 100644 index 0000000..db79aa2 --- /dev/null +++ b/docs/cookbook/bool-method-prefix.md @@ -0,0 +1,50 @@ +# Bool Method Prefix + +## Purpose + +Functions that return `bool` are easier to read at call sites when their names read as a question. This rule enforces that any function or method with a `bool` return type annotation starts with `is_`, `has_`, or `should_`. + +## Configuration + +```yaml +rules: + - name: bool-method-prefix + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_, should_] } + +apply: + - name: all + rules: [bool-method-prefix] + modules: "**" +``` + +## Violation Example + +```python +# src/domain/service.py + +class SubscriptionService: + def validate(self) -> bool: # missing required prefix + return self._status == "active" +``` + +## Passing Example + +```python +# src/domain/service.py + +class SubscriptionService: + def is_valid(self) -> bool: + return self._status == "active" +``` + +## Output + +``` +$ pnl check +src/domain/service.py:4 + [bool-method-prefix] validate (expected prefix: is_ | has_ | should_) + +Found 1 violation(s). +``` diff --git a/docs/cookbook/constant-upper-case.md b/docs/cookbook/constant-upper-case.md new file mode 100644 index 0000000..5feb932 --- /dev/null +++ b/docs/cookbook/constant-upper-case.md @@ -0,0 +1,51 @@ +# Constant Upper Case + +## Purpose + +Module-level constants are easier to distinguish from regular variables when they follow the UPPER_CASE convention. This rule catches constants that were accidentally written in snake_case and flags them for renaming. + +## Configuration + +```yaml +rules: + - name: constant-upper-case + type: variable + filter: { target: constant } + naming: { case: UPPER_CASE } + +apply: + - name: all + rules: [constant-upper-case] + modules: "**" +``` + +## Violation Example + +```python +# src/config.py + +max_retry_count = 3 # constant in snake_case +default_timeout_seconds = 30 # constant in snake_case +``` + +## Passing Example + +```python +# src/config.py + +MAX_RETRY_COUNT = 3 +DEFAULT_TIMEOUT_SECONDS = 30 +``` + +## Output + +``` +$ pnl check +src/config.py:3 + [constant-upper-case] max_retry_count (expected case: UPPER_CASE) + +src/config.py:4 + [constant-upper-case] default_timeout_seconds (expected case: UPPER_CASE) + +Found 2 violation(s). +``` diff --git a/docs/cookbook/decorator-filtering.md b/docs/cookbook/decorator-filtering.md new file mode 100644 index 0000000..a3f20a5 --- /dev/null +++ b/docs/cookbook/decorator-filtering.md @@ -0,0 +1,84 @@ +# Decorator Filtering + +## Purpose + +Some naming conventions only apply to a specific kind of function or class. Decorator-based filtering lets you target `@staticmethod` methods, `@dataclass` classes, or any other decorated construct without affecting the rest of the codebase. + +## Configuration + +### Example 1 — `@staticmethod` methods must start with `create_` or `build_` + +```yaml +rules: + - name: static-factory-prefix + type: function + filter: { decorator: staticmethod } + naming: { prefix: [create_, build_] } + +apply: + - name: all + rules: [static-factory-prefix] + modules: "**" +``` + +### Example 2 — `@dataclass` classes must use PascalCase and end with `Data` or `Config` + +```yaml +rules: + - name: dataclass-naming + type: class + filter: { decorator: dataclass } + naming: { suffix: [Data, Config] } + +apply: + - name: all + rules: [dataclass-naming] + modules: "**" +``` + +## Violation Example + +```python +# src/domain/order.py +from dataclasses import dataclass + +class OrderRepository: + @staticmethod + def from_dict(raw: dict) -> "OrderRepository": # missing create_/build_ prefix + return OrderRepository(**raw) + +@dataclass +class OrderPayload: # missing Data/Config suffix + order_id: str + amount: float +``` + +## Passing Example + +```python +# src/domain/order.py +from dataclasses import dataclass + +class OrderRepository: + @staticmethod + def create_from_dict(raw: dict) -> "OrderRepository": + return OrderRepository(**raw) + +@dataclass +class OrderData: + order_id: str + amount: float +``` + +## Output + +``` +$ pnl check +src/domain/order.py:5 + [static-factory-prefix] from_dict (expected prefix: create_ | build_) + +src/domain/order.py:9 + [dataclass-naming] OrderPayload (expected suffix: Data | Config) + +Found 2 violation(s). +``` diff --git a/docs/cookbook/exception-naming.md b/docs/cookbook/exception-naming.md new file mode 100644 index 0000000..0d4ad18 --- /dev/null +++ b/docs/cookbook/exception-naming.md @@ -0,0 +1,48 @@ +# Exception Naming + +## Purpose + +Consistent exception names make error handling code easier to scan and understand. This rule enforces a structured pattern: exceptions must start with an upper-case word, optionally followed by more words, and end with one of the recognised semantic suffixes (`NotFound`, `Invalid`, `Denied`, `Conflict`, or `Failed`) before the mandatory `Error` suffix. + +## Configuration + +```yaml +rules: + - name: exception-naming + type: class + filter: { base_class: Exception } + naming: { regex: "^[A-Z][a-zA-Z]+(NotFound|Invalid|Denied|Conflict|Failed)Error$" } + +apply: + - name: all + rules: [exception-naming] + modules: "**" +``` + +## Violation Example + +```python +# src/domain/exceptions.py + +class FilterError(Exception): # missing semantic suffix before Error + pass +``` + +## Passing Example + +```python +# src/domain/exceptions.py + +class FilterNotFoundError(Exception): + pass +``` + +## Output + +``` +$ pnl check +src/domain/exceptions.py:3 + [exception-naming] FilterError (expected pattern: ^[A-Z][a-zA-Z]+(NotFound|Invalid|...)Error$) + +Found 1 violation(s). +``` diff --git a/docs/cookbook/index.md b/docs/cookbook/index.md new file mode 100644 index 0000000..f3e093c --- /dev/null +++ b/docs/cookbook/index.md @@ -0,0 +1,15 @@ +# Cookbook + +The cookbook provides ready-to-use recipes for common naming convention scenarios. Each recipe shows a complete configuration, a violation example, and a passing example so you can adapt it to your project immediately. + +## Recipes + +| Recipe | Description | +|--------|-------------| +| [Bool Method Prefix](./bool-method-prefix.md) | Require `is_`, `has_`, or `should_` prefix on functions that return `bool` | +| [Exception Naming](./exception-naming.md) | Enforce a structured suffix pattern on exception class names | +| [Attribute Matches Type](./attribute-matches-type.md) | Require attribute names to match their type annotation in snake_case | +| [Module Matches Class](./module-matches-class.md) | Require module filenames to match the primary class they contain | +| [Layer-Based Rules](./layer-based-rules.md) | Apply different rule sets to different layers of your codebase | +| [Constant Upper Case](./constant-upper-case.md) | Require module-level constants to use UPPER_CASE | +| [Decorator Filtering](./decorator-filtering.md) | Apply naming rules only to functions or classes with a specific decorator | diff --git a/docs/cookbook/layer-based-rules.md b/docs/cookbook/layer-based-rules.md new file mode 100644 index 0000000..ccb7432 --- /dev/null +++ b/docs/cookbook/layer-based-rules.md @@ -0,0 +1,103 @@ +# Layer-Based Rules + +## Purpose + +Real projects have distinct layers — domain, infrastructure, API — each with its own naming conventions. Instead of applying every rule globally, you can scope each rule set to the layer where it belongs, reducing false positives and making the intent of each rule explicit. + +## Configuration + +```yaml +rules: + - name: attribute-matches-type + type: variable + filter: { target: attribute } + naming: { source: type_annotation, transform: snake_case } + + - name: bool-method-prefix + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_, should_] } + + - name: domain-module-naming + type: module + naming: { source: class_name, transform: snake_case } + + - name: constant-upper-case + type: variable + filter: { target: constant } + naming: { case: UPPER_CASE } + + - name: exception-naming + type: class + filter: { base_class: Exception } + naming: { regex: "^[A-Z][a-zA-Z]+(NotFound|Invalid|Denied|Conflict|Failed)Error$" } + +apply: + - name: domain-layer + rules: + - attribute-matches-type + - bool-method-prefix + - domain-module-naming + - constant-upper-case + modules: contexts.*.domain + + - name: global-exceptions + rules: [exception-naming] + modules: "**" +``` + +The `domain-layer` apply block targets every `contexts//domain` package, while `global-exceptions` runs the exception naming rule across the entire codebase. + +## Violation Example + +```python +# contexts/billing/domain/service.py + +max_retry = 3 # constant not in UPPER_CASE + +class BillingService: + def validate(self) -> bool: # bool method missing prefix + return self._status == "active" +``` + +```python +# contexts/billing/domain/exceptions.py + +class BillingError(Exception): # exception missing semantic suffix + pass +``` + +## Passing Example + +```python +# contexts/billing/domain/service.py + +MAX_RETRY = 3 + +class BillingService: + def is_valid(self) -> bool: + return self._status == "active" +``` + +```python +# contexts/billing/domain/exceptions.py + +class BillingNotFoundError(Exception): + pass +``` + +## Output + +``` +$ pnl check +contexts/billing/domain/service.py:3 + [constant-upper-case] max_retry (expected case: UPPER_CASE) + +contexts/billing/domain/service.py:6 + [bool-method-prefix] validate (expected prefix: is_ | has_ | should_) + +contexts/billing/domain/exceptions.py:3 + [exception-naming] BillingError (expected pattern: ^[A-Z][a-zA-Z]+(NotFound|Invalid|...)Error$) + +Found 3 violation(s). +``` diff --git a/docs/cookbook/module-matches-class.md b/docs/cookbook/module-matches-class.md new file mode 100644 index 0000000..bda62b2 --- /dev/null +++ b/docs/cookbook/module-matches-class.md @@ -0,0 +1,47 @@ +# Module Matches Class + +## Purpose + +When each module contains one primary class, keeping the filename in sync with the class name makes it immediately obvious what a file exports. This rule requires the module filename (without the `.py` extension) to be the snake_case form of the primary class name in that file. + +## Configuration + +```yaml +rules: + - name: domain-module-naming + type: module + naming: { source: class_name, transform: snake_case } + +apply: + - name: domain-layer + rules: [domain-module-naming] + modules: contexts.*.domain +``` + +## Violation Example + +```python +# contexts/catalog/domain/custom.py ← filename does not match class name + +class CustomObject: + pass +``` + +## Passing Example + +```python +# contexts/catalog/domain/custom_object.py ← matches CustomObject in snake_case + +class CustomObject: + pass +``` + +## Output + +``` +$ pnl check +contexts/catalog/domain/custom.py:1 + [domain-module-naming] custom (expected: custom_object) + +Found 1 violation(s). +``` diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md new file mode 100644 index 0000000..7b84544 --- /dev/null +++ b/docs/getting-started/configuration.md @@ -0,0 +1,85 @@ +# Configuration + +`pnl` supports two config file formats: a standalone YAML file or an inline section inside `pyproject.toml`. + +## Config File Discovery + +When you run `pnl check` without `--config`, the tool searches **upward from the current working directory** for one of: + +- `.python-naming-linter.yaml` +- `pyproject.toml` (containing a `[tool.python-naming-linter]` section) + +The first matching file is used, and its parent directory becomes the project root. + +To use a specific config file, pass it explicitly: + +```bash +pnl check --config path/to/config.yaml +``` + +## YAML Format + +Create `.python-naming-linter.yaml` in your project root: + +```yaml +rules: + - name: bool-method-prefix + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_, should_] } + +apply: + - name: all + rules: [bool-method-prefix] + modules: "**" +``` + +## pyproject.toml Format + +You can embed the same configuration inside `pyproject.toml` using the `[tool.python-naming-linter]` namespace: + +```toml +[[tool.python-naming-linter.rules]] +name = "bool-method-prefix" +type = "function" + +[tool.python-naming-linter.rules.filter] +return_type = "bool" + +[tool.python-naming-linter.rules.naming] +prefix = ["is_", "has_", "should_"] + +[[tool.python-naming-linter.apply]] +name = "all" +rules = ["bool-method-prefix"] +modules = "**" +``` + +Both formats are equivalent — use whichever fits your project's conventions. + +## Top-Level Keys + +| Key | Description | +|-----|-------------| +| `rules` | List of naming rule definitions | +| `apply` | List of rule-to-module mappings | +| `include` | Paths to include when scanning (optional) | +| `exclude` | Paths to exclude when scanning (optional) | + +### include / exclude + +Control which files are scanned: + +```yaml +include: + - src +exclude: + - src/generated/** +``` + +Behavior: + +- **Neither** — all `.py` files under the project root are scanned. +- **`include` only** — only files matching the given paths are scanned. +- **`exclude` only** — all files except those matching the given paths are scanned. +- **Both** — `include` is applied first, then `exclude` filters within that result. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..65eb5b4 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,25 @@ +# Installation + +`python-naming-linter` is available on PyPI and can be installed with any standard Python package manager. + +## pip + +```bash +pip install python-naming-linter +``` + +## uv + +```bash +uv add python-naming-linter +``` + +## Verify the Installation + +After installation, confirm the CLI is available: + +```bash +pnl --help +``` + +You should see the help output listing the available commands. diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md new file mode 100644 index 0000000..8bf9501 --- /dev/null +++ b/docs/getting-started/quick-start.md @@ -0,0 +1,63 @@ +# Quick Start + +Get `pnl` running in your project in three steps. + +## Step 1: Create a Config File + +Create `.python-naming-linter.yaml` in your project root and define your naming rules: + +```yaml +rules: + - name: bool-method-prefix + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_, should_] } + + - name: exception-naming + type: class + filter: { base_class: Exception } + naming: { regex: "^[A-Z][a-zA-Z]+(NotFound|Invalid|Denied|Conflict|Failed)Error$" } + +apply: + - name: all + rules: [bool-method-prefix, exception-naming] + modules: "**" +``` + +This config defines two rules: + +- `bool-method-prefix` — functions that return `bool` must start with `is_`, `has_`, or `should_`. +- `exception-naming` — classes that extend `Exception` must follow the given regex pattern. + +Both rules are applied to all modules (`**`). + +## Step 2: Run the Linter + +From your project root, run: + +```bash +pnl check +``` + +`pnl` automatically discovers the config file by searching upward from the current working directory. + +## Step 3: Review the Output + +Violations are reported with the file path, line number, rule name, and what was expected: + +``` +src/domain/service.py:12 + [bool-method-prefix] validate (expected prefix: is_ | has_ | should_) + +src/domain/exceptions.py:8 + [exception-naming] FilterError (expected pattern: ^[A-Z][a-zA-Z]+(NotFound|Invalid|...)Error$) + +Found 2 violation(s). +``` + +Fix the reported names and re-run `pnl check` until no violations remain. + +## Next Steps + +- Learn all available config options in [Configuration](./configuration.md). +- See rule type details and naming constraint options in the full reference. diff --git a/docs/guide/apply-and-modules.md b/docs/guide/apply-and-modules.md new file mode 100644 index 0000000..790132b --- /dev/null +++ b/docs/guide/apply-and-modules.md @@ -0,0 +1,161 @@ +# Apply & Modules + +The `apply` block connects rules to the parts of your codebase where they should be enforced. Without an `apply` entry, a rule is defined but never executed. + +--- + +## Structure + +Each entry in `apply` is a named group that maps one or more rules to one or more modules: + +```yaml +apply: + - name: domain-layer # A label for this group (used in output) + rules: # Rules to enforce in this group + - attribute-matches-type + - bool-method-prefix + modules: contexts.*.domain # Module path pattern to match +``` + +### Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Human-readable label for this application group | +| `rules` | Yes | List of rule names to enforce (must be defined in the `rules` block) | +| `modules` | Yes | A module path pattern that selects which files to check | + +--- + +## Module Patterns + +Module paths use Python's dotted notation — the same way you would import them. For example, `src/domain/service.py` becomes `src.domain.service`. + +### Exact Match + +To target a single module, write its full dotted path: + +```yaml +modules: myapp.core.utils +``` + +This matches only the file `myapp/core/utils.py`. + +### `*` — Single Level + +`*` matches exactly one segment in a dotted module path. It cannot match across dots. + +```yaml +modules: contexts.*.domain +``` + +This matches: + +- `contexts.boards.domain` +- `contexts.auth.domain` +- `contexts.payments.domain` + +But **not:** + +- `contexts.domain` (missing the middle segment) +- `contexts.boards.sub.domain` (too many levels between `contexts` and `domain`) + +**Example:** + +```yaml +apply: + - name: domain-layer + rules: [attribute-matches-type] + modules: contexts.*.domain +``` + +### `**` — One or More Levels + +`**` matches one or more segments. Use it to select all modules under a path, regardless of depth. + +```yaml +modules: contexts.**.domain +``` + +This matches: + +- `contexts.boards.domain` +- `contexts.boards.sub.domain` +- `contexts.a.b.c.domain` + +**Example — apply a rule to the entire codebase:** + +```yaml +apply: + - name: all + rules: [bool-method-prefix] + modules: "**" +``` + +The `"**"` pattern matches every module in the project. Use quotes to avoid YAML parsing issues. + +**Example — apply rules to all modules under a sub-package:** + +```yaml +apply: + - name: services + rules: [function-snake-case] + modules: myapp.services.** +``` + +### Named Capture (`{name}`) + +`{name}` captures a single path segment (equivalent to `*`) and makes the captured value available for back-referencing within the same pattern. + +```yaml +modules: contexts.{context}.domain +``` + +This behaves like `contexts.*.domain` but the captured value (e.g. `boards`) is bound to the name `context`. + +**Example:** + +```yaml +apply: + - name: domain-isolation + rules: [attribute-matches-type] + modules: contexts.{context}.domain +``` + +Every module matching `contexts..domain` is selected, and the middle segment is captured as `context`. This can be used in rule logic that references the captured value, enabling context-aware enforcement. + +--- + +## Multiple Apply Groups + +You can define multiple `apply` groups to apply different rules to different parts of your codebase: + +```yaml +apply: + - name: domain-layer + rules: + - attribute-matches-type + - bool-method-prefix + - domain-module-naming + - constant-upper-case + modules: contexts.*.domain + + - name: global-exceptions + rules: [exception-naming] + modules: "**" +``` + +Here, the domain-specific rules are enforced only in `contexts.*.domain`, while `exception-naming` is enforced everywhere. A single module can be matched by multiple groups — all matching rules will be applied. + +--- + +## Summary + +| Concept | Syntax | Description | +|---------|--------|-------------| +| `apply` block | `name`, `rules`, `modules` | Connects rules to specific module paths | +| Exact match | `myapp.core.utils` | Matches a single specific module | +| Single-level wildcard | `*` | Matches exactly one level in a dotted module path | +| Multi-level wildcard | `**` | Matches one or more levels in a dotted module path | +| Named capture | `{name}` | Captures a single level for back-referencing | +| Multiple groups | multiple `apply` entries | Different rules for different parts of the codebase | diff --git a/docs/guide/include-exclude.md b/docs/guide/include-exclude.md new file mode 100644 index 0000000..c8faff4 --- /dev/null +++ b/docs/guide/include-exclude.md @@ -0,0 +1,137 @@ +# Include / Exclude + +The `include` and `exclude` keys control which files `pnl` scans. They are top-level config keys and are applied before any rule matching. + +```yaml +include: + - src + +exclude: + - src/generated/** + +rules: + - name: ... + +apply: + - name: ... +``` + +Both keys accept a list of file path patterns. Patterns are matched against file paths relative to the project root. + +--- + +## Scenarios + +### No `include` or `exclude` + +When neither key is present, `pnl` scans all `.py` files under the project root recursively. + +```yaml +rules: + - name: bool-method-prefix + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_, should_] } + +apply: + - name: all + rules: [bool-method-prefix] + modules: "**" +``` + +Every `.py` file in the project is a candidate for scanning. The `apply` block's `modules` pattern then determines which of those files are actually checked by each rule. + +--- + +### Only `include` + +When only `include` is specified, only files matching the listed paths are scanned. Everything else is ignored. + +```yaml +include: + - src + +rules: + - name: bool-method-prefix + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_, should_] } + +apply: + - name: all + rules: [bool-method-prefix] + modules: "**" +``` + +Only `.py` files under `src/` are scanned. Files in `tests/`, `scripts/`, or other top-level directories are not checked, even if they match the `modules` pattern in `apply`. + +Use `include` when your project has multiple top-level directories and you only want to lint a specific one. + +--- + +### Only `exclude` + +When only `exclude` is specified, all `.py` files under the project root are scanned **except** those matching the excluded paths. + +```yaml +exclude: + - tests/** + - scripts/** + +rules: + - name: bool-method-prefix + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_, should_] } + +apply: + - name: all + rules: [bool-method-prefix] + modules: "**" +``` + +All files are scanned by default, but `tests/` and `scripts/` are skipped. This is useful when you want broad coverage but need to exclude generated code, fixtures, or tooling directories. + +--- + +### Both `include` and `exclude` + +When both keys are present, `include` is applied first and `exclude` is applied to that result. + +```yaml +include: + - src + +exclude: + - src/generated/** + +rules: + - name: bool-method-prefix + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_, should_] } + +apply: + - name: all + rules: [bool-method-prefix] + modules: "**" +``` + +Step-by-step: + +1. Start with all `.py` files under the project root. +2. Keep only files under `src/` (apply `include`). +3. Remove files under `src/generated/` (apply `exclude`). + +The result is all files under `src/` except those in `src/generated/`. + +--- + +## Summary + +| `include` | `exclude` | Files scanned | +|-----------|-----------|--------------| +| Not set | Not set | All `.py` files under project root | +| Set | Not set | Only files matching `include` paths | +| Not set | Set | All files **except** those matching `exclude` paths | +| Set | Set | Files matching `include`, then filtered by `exclude` | diff --git a/docs/guide/inline-ignore.md b/docs/guide/inline-ignore.md new file mode 100644 index 0000000..ddb267a --- /dev/null +++ b/docs/guide/inline-ignore.md @@ -0,0 +1,85 @@ +# Inline Ignore + +Inline ignore comments let you suppress specific violations on individual lines using `# pnl: ignore`. + +--- + +## Ignore All Rules on a Line + +Add `# pnl: ignore` at the end of a line to suppress all `pnl` violations reported for that line: + +```python +x: int = 1 # pnl: ignore +``` + +Any rule that would have flagged the name on this line is silenced. This is the broadest form of suppression — use it when multiple rules apply and you want to silence all of them at once. + +--- + +## Ignore a Specific Rule on a Line + +To suppress only one rule, specify the rule name after `=`: + +```python +x: int = 1 # pnl: ignore=attribute-matches-type +``` + +Only the `attribute-matches-type` rule is suppressed on this line. Any other rules that match this line will still report violations. + +The rule name must exactly match the `name` field defined in your config: + +```yaml +rules: + - name: attribute-matches-type # This is the name to use in ignore comments + type: variable + filter: { target: attribute } + naming: { source: type_annotation, transform: snake_case } +``` + +--- + +## Ignore Multiple Specific Rules on a Line + +To suppress more than one rule on the same line, list rule names separated by commas: + +```python +x: int = 1 # pnl: ignore=attribute-matches-type,constant-upper-case +``` + +Both `attribute-matches-type` and `constant-upper-case` are suppressed on this line. There is no space around the commas. + +--- + +## Practical Examples + +**Suppressing a legacy attribute name that doesn't match its type:** + +```python +class UserService: + repo: UserRepository # pnl: ignore=attribute-matches-type +``` + +**Suppressing a constant that follows a third-party naming convention:** + +```python +# Required by the framework to be this exact name +default_app_config = "myapp.apps.MyAppConfig" # pnl: ignore=constant-upper-case +``` + +**Suppressing all rules on a generated or protocol-required name:** + +```python +def __repr__(self) -> str: # pnl: ignore + ... +``` + +--- + +## Summary + +| Topic | Detail | +|---|---| +| Scope | Comments apply only to the line they appear on; other lines are unaffected. | +| Case sensitivity | Rule names are case-sensitive and must match exactly. | +| Unknown rule names | If a rule name does not exist in your config, the comment is silently ignored — no error is raised. | +| Prefer targeted suppression | Use `# pnl: ignore=rule-name` over `# pnl: ignore` so that future rules are not accidentally silenced. | diff --git a/docs/guide/rules.md b/docs/guide/rules.md new file mode 100644 index 0000000..029cf45 --- /dev/null +++ b/docs/guide/rules.md @@ -0,0 +1,950 @@ +# Rules + +Rules are the core building blocks of `pnl`. Each rule targets a specific kind of Python name, optionally narrows its scope with filters, and then enforces a naming constraint. + +## Structure + +Every rule has three required fields and two optional ones: + +```yaml +rules: + - name: my-rule # Unique identifier for this rule + type: variable # What kind of name to lint + filter: { ... } # (optional) Narrow which names are checked + naming: { ... } # How the name must be formed +``` + +The `name` is used to reference the rule in `apply` blocks and in `# pnl: ignore` comments. + +### Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Unique identifier, referenced in `apply` and `# pnl: ignore` | +| `type` | Yes | What kind of name to lint (`variable`, `function`, `class`, `module`, `package`) | +| `filter` | No | Narrow which names are checked (see [Filters](#filters) below) | +| `naming` | Yes | How the name must be formed (see [Naming Constraints](#naming-constraints) below) | + +--- + +## Types + +### `variable` + +Targets variable names — any assignment that introduces a name into a scope. + +**Sub-targets** (set via `filter.target`): + +| Value | What it covers | +|-------|---------------| +| `attribute` | Class-level attributes (`self.x`, `x: int = ...`) | +| `parameter` | Function/method parameters | +| `local_variable` | Variables declared inside a function body | +| `constant` | Module-level constants (typically `ALL_CAPS`) | + +**Supported filter fields:** `target` + +**Supported naming fields:** `prefix`, `suffix`, `regex`, `source` + `transform`, `case` + +**Example — enforce UPPER_CASE for module-level constants:** + +```yaml +rules: + - name: constant-upper-case + type: variable + filter: { target: constant } + naming: { case: UPPER_CASE } + +apply: + - name: all + rules: [constant-upper-case] + modules: "**" +``` + +**Example — enforce attribute names match their type annotation:** + +```yaml +rules: + - name: attribute-matches-type + type: variable + filter: { target: attribute } + naming: { source: type_annotation, transform: snake_case } + +apply: + - name: domain-layer + rules: [attribute-matches-type] + modules: contexts.*.domain +``` + +--- + +### `function` + +Targets function and method definitions — any `def` statement at any scope level. + +**Supported filter fields:** `target`, `return_type`, `decorator` + +**Supported naming fields:** `prefix`, `suffix`, `regex`, `case` + +**Example — require `is_` / `has_` / `should_` prefix on boolean-returning methods:** + +```yaml +rules: + - name: bool-method-prefix + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_, should_] } + +apply: + - name: all + rules: [bool-method-prefix] + modules: "**" +``` + +**Example — require `_impl` suffix on `@staticmethod` functions:** + +```yaml +rules: + - name: static-impl-suffix + type: function + filter: { decorator: staticmethod } + naming: { suffix: [_impl] } + +apply: + - name: all + rules: [static-impl-suffix] + modules: "**" +``` + +--- + +### `class` + +Targets class definitions — any `class` statement. + +**Supported filter fields:** `base_class`, `decorator` + +**Supported naming fields:** `prefix`, `suffix`, `regex`, `case` + +**Example — enforce a specific pattern for exception classes:** + +```yaml +rules: + - name: exception-naming + type: class + filter: { base_class: Exception } + naming: { regex: "^[A-Z][a-zA-Z]+(NotFound|Invalid|Denied|Conflict|Failed)Error$" } + +apply: + - name: all + rules: [exception-naming] + modules: "**" +``` + +**Example — require `DTO` suffix on dataclasses:** + +```yaml +rules: + - name: dataclass-dto-suffix + type: class + filter: { decorator: dataclass } + naming: { suffix: [DTO] } + +apply: + - name: all + rules: [dataclass-dto-suffix] + modules: "**" +``` + +--- + +### `module` + +Targets the filename of each `.py` file (without the `.py` extension). Useful for enforcing that module names reflect their contents. + +**Supported filter fields:** none + +**Supported naming fields:** `prefix`, `suffix`, `regex`, `source` + `transform`, `case` + +**Example — enforce that a module's filename matches the primary class it contains:** + +```yaml +rules: + - name: domain-module-naming + type: module + naming: { source: class_name, transform: snake_case } + +apply: + - name: domain-layer + rules: [domain-module-naming] + modules: contexts.*.domain +``` + +A file `custom.py` that contains only `class CustomObject` is a violation — the file should be named `custom_object.py`. + +--- + +### `package` + +Targets the directory name of each Python package (a directory containing `__init__.py`). + +**Supported filter fields:** none + +**Supported naming fields:** `prefix`, `suffix`, `regex`, `case` + +**Example — require all package names to be lowercase:** + +```yaml +rules: + - name: package-snake-case + type: package + naming: { case: snake_case } + +apply: + - name: all + rules: [package-snake-case] + modules: "**" +``` + +--- + +## Filters + +Filters are specified in the `filter` block of a rule: + +```yaml +rules: + - name: my-rule + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_] } +``` + +Multiple filter fields can be combined — a name must satisfy **all** of them to be checked. + +--- + +### `target` + +Narrows which names within the rule type are checked based on their role in the code. + +#### For `variable` rules + +| Value | Matches | +|-------|---------| +| `attribute` | Class-level attribute assignments, including annotated attributes (`x: int = 1`) | +| `parameter` | Function or method parameters | +| `local_variable` | Variables assigned inside a function body | +| `constant` | Module-level assignments (typically treated as constants) | + +**Supported rule types:** `variable` + +**Example — lint only class attributes:** + +Matches names that are assigned at the class body level, including annotated attributes. + +```yaml +rules: + - name: attribute-matches-type + type: variable + filter: { target: attribute } + naming: { source: type_annotation, transform: snake_case } + +apply: + - name: all + rules: [attribute-matches-type] + modules: "**" +``` + +| Name | Context | Result | +|------|---------|--------| +| `user_id: UserId = ...` | class body | Pass — name matches type annotation in snake_case | +| `userId: UserId = ...` | class body | **Violation** — name does not match `user_id` | +| `user_id = 1` | function body | Not checked — local variables are ignored | + +--- + +**Example — lint only function/method parameters:** + +Matches names declared as function or method parameters (including `self` and `cls` by convention — though you may want to exclude them with additional patterns). + +```yaml +rules: + - name: param-snake-case + type: variable + filter: { target: parameter } + naming: { case: snake_case } + +apply: + - name: all + rules: [param-snake-case] + modules: "**" +``` + +| Name | Context | Result | +|------|---------|--------| +| `user_id` | function parameter | Pass — snake_case | +| `userId` | function parameter | **Violation** — camelCase not allowed | +| `MAX_RETRIES` | module level | Not checked — constants are ignored | + +--- + +**Example — lint only local variables inside functions:** + +Matches names assigned inside a function or method body (not parameters, not class-level attributes). + +```yaml +rules: + - name: local-var-snake-case + type: variable + filter: { target: local_variable } + naming: { case: snake_case } + +apply: + - name: all + rules: [local-var-snake-case] + modules: "**" +``` + +| Name | Context | Result | +|------|---------|--------| +| `result` | inside function body | Pass — snake_case | +| `tmpVal` | inside function body | **Violation** — camelCase not allowed | +| `MAX_SIZE` | module level | Not checked — constants are ignored | + +--- + +**Example — lint only module-level constants:** + +Matches names assigned at module (top-level) scope. + +```yaml +rules: + - name: constant-upper-case + type: variable + filter: { target: constant } + naming: { case: UPPER_CASE } + +apply: + - name: all + rules: [constant-upper-case] + modules: "**" +``` + +| Name | Context | Result | +|------|---------|--------| +| `MAX_RETRIES` | module level | Pass — UPPER_CASE | +| `defaultTimeout` | module level | **Violation** — not UPPER_CASE | +| `count` | function body | Not checked — local variables are ignored | + +--- + +#### For `function` rules + +| Value | Matches | +|-------|---------| +| `method` | Functions defined inside a class body | +| `function` | Functions defined at module level or inside other functions | + +**Supported rule types:** `function` + +**Example — lint only module-level functions (not methods):** + +Matches `def` statements at module scope or nested inside other functions, but not methods defined inside a class. + +```yaml +rules: + - name: function-snake-case + type: function + filter: { target: function } + naming: { case: snake_case } + +apply: + - name: all + rules: [function-snake-case] + modules: "**" +``` + +| Name | Context | Result | +|------|---------|--------| +| `process_order` | module-level `def` | Pass — snake_case | +| `processOrder` | module-level `def` | **Violation** — camelCase not allowed | +| `processOrder` | inside a class | Not checked — methods are ignored | + +--- + +**Example — lint only class methods:** + +Matches `def` statements inside a class body. + +```yaml +rules: + - name: method-snake-case + type: function + filter: { target: method } + naming: { case: snake_case } + +apply: + - name: all + rules: [method-snake-case] + modules: "**" +``` + +| Name | Context | Result | +|------|---------|--------| +| `get_user` | inside a class | Pass — snake_case | +| `getUser` | inside a class | **Violation** — camelCase not allowed | +| `getUser` | module-level `def` | Not checked — functions are ignored | + +--- + +### `return_type` + +Matches functions whose return type annotation equals the specified type name. + +**Supported rule types:** `function` + +**Accepted values:** any Python type name as a string, e.g. `bool`, `str`, `int`, `None` + +The filter matches functions with the given `-> ` annotation. Functions without a return type annotation, or with a different annotation, are not checked. + +**Example — require a boolean-indicating prefix on `bool`-returning functions:** + +```yaml +rules: + - name: bool-method-prefix + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_, should_] } + +apply: + - name: all + rules: [bool-method-prefix] + modules: "**" +``` + +| Signature | Result | +|-----------|--------| +| `def is_active(self) -> bool:` | Pass — starts with `is_` | +| `def has_permission(self) -> bool:` | Pass — starts with `has_` | +| `def validate(self) -> bool:` | **Violation** — no matching prefix | +| `def process(self) -> str:` | Not checked — return type is `str`, not `bool` | +| `def run(self):` | Not checked — no return type annotation | + +--- + +**Example — require a descriptive prefix on `str`-returning functions:** + +```yaml +rules: + - name: str-getter-prefix + type: function + filter: { return_type: str } + naming: { prefix: [get_, format_, build_, to_] } + +apply: + - name: all + rules: [str-getter-prefix] + modules: "**" +``` + +| Signature | Result | +|-----------|--------| +| `def get_name(self) -> str:` | Pass — starts with `get_` | +| `def format_label(self) -> str:` | Pass — starts with `format_` | +| `def name(self) -> str:` | **Violation** — no matching prefix | +| `def is_active(self) -> bool:` | Not checked — return type is `bool`, not `str` | + +--- + +**Example — require a `_or_none` suffix on `None`-returning functions:** + +```yaml +rules: + - name: none-returning-suffix + type: function + filter: { return_type: None } + naming: { suffix: [_or_none] } + +apply: + - name: all + rules: [none-returning-suffix] + modules: "**" +``` + +| Signature | Result | +|-----------|--------| +| `def find_user_or_none(self) -> None:` | Pass — ends with `_or_none` | +| `def find_user(self) -> None:` | **Violation** — missing `_or_none` suffix | +| `def find_user(self) -> User:` | Not checked — return type is `User`, not `None` | + +--- + +### `decorator` + +Matches functions or classes that are decorated with the specified decorator name. + +**Supported rule types:** `function`, `class` + +**Accepted values:** any decorator name as a string (without `@`), e.g. `staticmethod`, `classmethod`, `property`, `dataclass`, `abstractmethod` + +The filter matches the decorator by its bare name. Both `@dataclass` and `@dataclasses.dataclass` are matched by the value `dataclass`. + +**Example — require a suffix on static methods:** + +```yaml +rules: + - name: static-method-suffix + type: function + filter: { decorator: staticmethod } + naming: { suffix: [_impl] } + +apply: + - name: all + rules: [static-method-suffix] + modules: "**" +``` + +| Definition | Result | +|------------|--------| +| `@staticmethod` / `def compute_impl(cls):` | Pass — ends with `_impl` | +| `@staticmethod` / `def compute(cls):` | **Violation** — missing `_impl` suffix | +| `def compute(self):` | Not checked — not a static method | + +--- + +**Example — require a `DTO` suffix on dataclasses:** + +```yaml +rules: + - name: dataclass-dto-suffix + type: class + filter: { decorator: dataclass } + naming: { suffix: [DTO] } + +apply: + - name: all + rules: [dataclass-dto-suffix] + modules: "**" +``` + +| Definition | Result | +|------------|--------| +| `@dataclass` / `class UserDTO:` | Pass — ends with `DTO` | +| `@dataclass` / `class User:` | **Violation** — missing `DTO` suffix | +| `class User:` | Not checked — not a dataclass | + +--- + +### `base_class` + +Matches classes that inherit from the specified base class. + +**Supported rule types:** `class` + +**Accepted values:** any class name as a string, e.g. `Exception`, `BaseModel`, `ABC` + +The filter matches the direct base class name. `class MyError(Exception)` matches the value `Exception`. + +**Example — enforce a naming pattern for all exception classes:** + +```yaml +rules: + - name: exception-naming + type: class + filter: { base_class: Exception } + naming: { regex: "^[A-Z][a-zA-Z]+(NotFound|Invalid|Denied|Conflict|Failed)Error$" } + +apply: + - name: all + rules: [exception-naming] + modules: "**" +``` + +| Definition | Result | +|------------|--------| +| `class UserNotFoundError(Exception):` | Pass — matches the regex | +| `class InvalidInputError(Exception):` | Pass — matches the regex | +| `class UserException(Exception):` | **Violation** — does not match the regex | +| `class User:` | Not checked — does not inherit from `Exception` | + +--- + +**Example — require a `Schema` suffix on Pydantic models:** + +Matches classes that inherit from `BaseModel` (e.g. Pydantic models). + +```yaml +rules: + - name: pydantic-schema-suffix + type: class + filter: { base_class: BaseModel } + naming: { suffix: [Schema] } + +apply: + - name: all + rules: [pydantic-schema-suffix] + modules: "**" +``` + +| Definition | Result | +|------------|--------| +| `class UserSchema(BaseModel):` | Pass — ends with `Schema` | +| `class CreateUserSchema(BaseModel):` | Pass — ends with `Schema` | +| `class User(BaseModel):` | **Violation** — missing `Schema` suffix | +| `class User:` | Not checked — does not inherit from `BaseModel` | + +--- + +## Naming Constraints + +Naming constraints are specified in the `naming` block of a rule: + +```yaml +rules: + - name: my-rule + type: function + naming: { prefix: [is_, has_] } +apply: + - name: all + rules: [my-rule] + modules: "**" +``` + +Each rule must have exactly one naming constraint (or one `source` + `transform` pair). The constraint is evaluated against every name that passes the rule's type and filter checks. + +--- + +### `prefix` + +The name must start with one of the listed prefixes. + +**Accepted values:** a list of one or more prefix strings. + +**Example — bool-returning methods must use a semantic prefix:** + +```yaml +rules: + - name: bool-method-prefix + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_, should_] } +apply: + - name: all + rules: [bool-method-prefix] + modules: "**" +``` + +| Name | Result | +|------|--------| +| `is_active` | Pass — starts with `is_` | +| `has_permission` | Pass — starts with `has_` | +| `should_retry` | Pass — starts with `should_` | +| `validate` | **Violation** — no matching prefix | +| `check_active` | **Violation** — `check_` is not in the list | + +**Example — test functions must start with `test_`:** + +```yaml +rules: + - name: test-function-prefix + type: function + filter: { decorator: pytest.mark } + naming: { prefix: [test_] } +apply: + - name: all + rules: [test-function-prefix] + modules: "**" +``` + +| Name | Result | +|------|--------| +| `test_login_succeeds` | Pass — starts with `test_` | +| `test_invalid_token` | Pass — starts with `test_` | +| `login_succeeds` | **Violation** — missing `test_` prefix | +| `check_login` | **Violation** — `check_` is not in the list | + +--- + +### `suffix` + +The name must end with one of the listed suffixes. + +**Accepted values:** a list of one or more suffix strings. + +**Example — data-access classes must end with `Repository` or `Service`:** + +```yaml +rules: + - name: repository-suffix + type: class + naming: { suffix: [Repository, Service] } +apply: + - name: all + rules: [repository-suffix] + modules: "**" +``` + +| Name | Result | +|------|--------| +| `UserRepository` | Pass — ends with `Repository` | +| `OrderService` | Pass — ends with `Service` | +| `UserManager` | **Violation** — no matching suffix | +| `User` | **Violation** — no matching suffix | + +**Example — exception classes must end with `Error`:** + +```yaml +rules: + - name: exception-suffix + type: class + filter: { base_class: Exception } + naming: { suffix: [Error] } +apply: + - name: all + rules: [exception-suffix] + modules: "**" +``` + +| Name | Result | +|------|--------| +| `ValidationError` | Pass — ends with `Error` | +| `NotFoundError` | Pass — ends with `Error` | +| `InvalidInput` | **Violation** — does not end with `Error` | +| `NotFoundException` | **Violation** — ends with `Exception`, not `Error` | + +--- + +### `regex` + +The name must match a regular expression. + +**Accepted values:** a string containing a valid Python regular expression. + +This is the most expressive constraint — use it when `prefix`, `suffix`, or `case` are not specific enough. + +**Example — exception class names must follow a structured pattern:** + +```yaml +rules: + - name: exception-naming + type: class + filter: { base_class: Exception } + naming: { regex: "^[A-Z][a-zA-Z]+(NotFound|Invalid|Denied|Conflict|Failed)Error$" } +apply: + - name: all + rules: [exception-naming] + modules: "**" +``` + +| Name | Result | +|------|--------| +| `UserNotFoundError` | Pass — matches the pattern | +| `OrderInvalidError` | Pass — matches the pattern | +| `FilterError` | **Violation** — does not end with the required suffix group | +| `userNotFoundError` | **Violation** — does not start with an uppercase letter | + +**Example — module-level constants must be all-uppercase with underscores:** + +```yaml +rules: + - name: constant-regex + type: variable + filter: { target: constant } + naming: { regex: "^[A-Z][A-Z0-9_]*$" } +apply: + - name: all + rules: [constant-regex] + modules: "**" +``` + +| Name | Result | +|------|--------| +| `MAX_RETRIES` | Pass — all uppercase with underscores | +| `DEFAULT_TIMEOUT` | Pass — all uppercase with underscores | +| `API_V2_URL` | Pass — uppercase with digits and underscores | +| `max_retries` | **Violation** — lowercase | +| `MaxRetries` | **Violation** — mixed case | +| `_PRIVATE` | **Violation** — starts with underscore, not matched by `^[A-Z]` | + +--- + +### `source` + `transform` + +The name must be derived from another element in the code, after applying a transformation. This is used for relational naming — where the name of one thing must reflect another. + +Both fields must be specified together. + +#### `source` values + +| Value | What it reads | +|-------|--------------| +| `type_annotation` | The type annotation of the variable (e.g. `SubscriptionRepository` from `x: SubscriptionRepository`) | +| `class_name` | The name of a class defined in the module (used with `type: module`) | + +#### `transform` values + +| Value | What it does | +|-------|-------------| +| `snake_case` | Converts PascalCase or camelCase to snake_case (e.g. `SubscriptionRepository` → `subscription_repository`) | + +**Example — variable name must match its type annotation:** + +```yaml +rules: + - name: attribute-matches-type + type: variable + filter: { target: attribute } + naming: { source: type_annotation, transform: snake_case } +apply: + - name: all + rules: [attribute-matches-type] + modules: "**" +``` + +| Declaration | Result | +|-------------|--------| +| `subscription_repository: SubscriptionRepository` | Pass — name matches transformed type | +| `order_service: OrderService` | Pass — name matches transformed type | +| `repo: SubscriptionRepository` | **Violation** — `repo` does not match `subscription_repository` | +| `svc: OrderService` | **Violation** — `svc` does not match `order_service` | +| `source_object_context: ObjectContext` | Pass — name ends with `_object_context` (prefix + expected form is allowed) | + +The `{prefix}_{expected}` form is accepted. If the expected derived name is `object_context`, then `source_object_context` passes because it ends with `_object_context`. + +**Example — module filename must match the class it contains:** + +```yaml +rules: + - name: domain-module-naming + type: module + naming: { source: class_name, transform: snake_case } +apply: + - name: all + rules: [domain-module-naming] + modules: "**" +``` + +| File | Class | Result | +|------|-------|--------| +| `custom_object.py` | `CustomObject` | Pass — filename matches transformed class name | +| `order_service.py` | `OrderService` | Pass — filename matches transformed class name | +| `custom.py` | `CustomObject` | **Violation** — `custom` does not match `custom_object` | +| `service.py` | `OrderService` | **Violation** — `service` does not match `order_service` | + +--- + +### `case` + +The name must follow a specific casing convention. + +**Accepted values:** + +| Value | Pattern | Example | +|-------|---------|---------| +| `snake_case` | all lowercase, words separated by underscores | `my_variable_name` | +| `PascalCase` | each word starts with uppercase, no separators | `MyClassName` | +| `UPPER_CASE` | all uppercase, words separated by underscores | `MAX_RETRIES` | + +**Example — enforce `snake_case` for function names:** + +```yaml +rules: + - name: function-snake-case + type: function + naming: { case: snake_case } +apply: + - name: all + rules: [function-snake-case] + modules: "**" +``` + +| Name | Result | +|------|--------| +| `get_user` | Pass | +| `calculate_total_price` | Pass | +| `getUserById` | **Violation** — camelCase | +| `GetUser` | **Violation** — PascalCase | +| `GETUSER` | **Violation** — all uppercase | + +**Example — enforce UPPER_CASE for constants:** + +```yaml +rules: + - name: constant-upper-case + type: variable + filter: { target: constant } + naming: { case: UPPER_CASE } +apply: + - name: all + rules: [constant-upper-case] + modules: "**" +``` + +| Name | Result | +|------|--------| +| `MAX_RETRIES` | Pass | +| `DEFAULT_TIMEOUT` | Pass | +| `max_retries` | **Violation** — lowercase | +| `maxRetries` | **Violation** — camelCase | + +**Example — enforce PascalCase for classes:** + +```yaml +rules: + - name: class-pascal-case + type: class + naming: { case: PascalCase } +apply: + - name: all + rules: [class-pascal-case] + modules: "**" +``` + +| Name | Result | +|------|--------| +| `MyService` | Pass | +| `my_service` | **Violation** | +| `myService` | **Violation** | + +--- + +## Summary + +### Rule fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | Yes | Unique identifier, referenced in `apply` and `# pnl: ignore` | +| `type` | Yes | What kind of name to lint (`variable`, `function`, `class`, `module`, `package`) | +| `filter` | No | Narrow which names are checked | +| `naming` | Yes | How the name must be formed | + +### Rule types + +| Type | What it targets | Supported filters | Notes | +|------|----------------|-------------------|-------| +| `variable` | Variables by scope/role | `target` | Use `target` to narrow to attributes, parameters, etc. | +| `function` | Function and method definitions | `target`, `return_type`, `decorator` | | +| `class` | Class definitions | `base_class`, `decorator` | | +| `module` | Module (file) names | none | Supports `source` + `transform` | +| `package` | Package (directory) names | none | | + +### Filters + +| Filter | `variable` | `function` | `class` | `module` | `package` | +|--------|-----------|-----------|---------|---------|---------| +| `target` | `attribute`, `parameter`, `local_variable`, `constant` | `method`, `function` | — | — | — | +| `return_type` | — | any type string | — | — | — | +| `decorator` | — | any decorator name | any decorator name | — | — | +| `base_class` | — | — | any class name | — | — | + +### Naming constraints + +| Constraint | Value type | Use when | +|-----------|-----------|---------| +| `prefix` | list of strings | Names must start with one of several prefixes | +| `suffix` | list of strings | Names must end with one of several suffixes | +| `regex` | string (regex) | Names must match a complex pattern | +| `source` + `transform` | string + string | Names must be derived from another code element | +| `case` | `snake_case`, `PascalCase`, or `UPPER_CASE` | Names must follow a casing convention | diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ca3fe50 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,114 @@ +# python-naming-linter + +A naming convention linter for Python projects. Define custom naming rules and enforce them with a single CLI command. + +## What It Does + +- Define naming rules for variables, functions, classes, modules, and packages +- Apply rules to specific modules using pattern matching +- Integrate into CI or pre-commit to keep your naming conventions consistent + +For Python developers who want to enforce team-specific naming conventions beyond what PEP 8 and ruff cover. + +## Key Features + +| Feature | Description | +|---------|-------------| +| **Rule Types** | Variable, function, class, module, and package naming rules | +| **Filters** | Narrow rules by return type, base class, decorator, and more | +| **Naming Constraints** | Prefix, suffix, regex, case convention, or derived from another element | +| **Module Targeting** | Apply rules to specific parts of your codebase using glob-style patterns | +| **Inline Ignore** | Suppress violations on specific lines with `# pnl: ignore` | +| **Pre-commit** | Drop-in integration with pre-commit hooks | + +## Quick Start + +**Install:** + +```bash +pip install python-naming-linter +``` + +**Create `.python-naming-linter.yaml` in your project root:** + +```yaml +rules: + - name: bool-method-prefix + type: function + filter: { return_type: bool } + naming: { prefix: [is_, has_, should_] } + + - name: exception-naming + type: class + filter: { base_class: Exception } + naming: { regex: "^[A-Z][a-zA-Z]+(NotFound|Invalid|Denied|Conflict|Failed)Error$" } + +apply: + - name: all + rules: [bool-method-prefix, exception-naming] + modules: "**" +``` + +**Run:** + +```bash +pnl check +``` + +**Output:** + +``` +src/domain/service.py:12 + [bool-method-prefix] validate (expected prefix: is_ | has_ | should_) + +src/domain/exceptions.py:8 + [exception-naming] FilterError (expected pattern: ^[A-Z][a-zA-Z]+(NotFound|Invalid|...)Error$) + +Found 2 violation(s). +``` + +## More Examples + +### Variable Naming — Match Type Annotation + +Enforce that variable names match their type annotation in snake_case: + +```yaml +rules: + - name: attribute-matches-type + type: variable + filter: { target: attribute } + naming: { source: type_annotation, transform: snake_case } + +apply: + - name: domain-layer + rules: [attribute-matches-type] + modules: contexts.*.domain +``` + +This catches `repo: SubscriptionRepository` — the name should be `subscription_repository`. + +### Combining Rules Per Layer + +Apply different rules to different parts of your codebase: + +```yaml +apply: + - name: domain-layer + rules: + - attribute-matches-type + - bool-method-prefix + - domain-module-naming + modules: contexts.*.domain + + - name: global-exceptions + rules: [exception-naming] + modules: "**" +``` + +## Next Steps + +- [Installation](getting-started/installation.md) — detailed install instructions +- [Quick Start](getting-started/quick-start.md) — step-by-step setup guide +- [Configuration](getting-started/configuration.md) — full configuration reference +- [Cookbook](cookbook/index.md) — real-world usage patterns diff --git a/docs/pre-commit.md b/docs/pre-commit.md new file mode 100644 index 0000000..aee218d --- /dev/null +++ b/docs/pre-commit.md @@ -0,0 +1,26 @@ +# Pre-commit + +`pnl` can be used as a [pre-commit](https://pre-commit.com/) hook to enforce naming conventions before every commit. + +## Setup + +Add the following to your `.pre-commit-config.yaml`: + +```yaml +- repo: https://github.com/heumsi/python-naming-linter + rev: '' # Use the tag you want to point at (e.g., v0.1.0) + hooks: + - id: python-naming-linter +``` + +## Custom Options + +To pass custom options (e.g., a specific config file path), use `args`: + +```yaml +- repo: https://github.com/heumsi/python-naming-linter + rev: '' + hooks: + - id: python-naming-linter + args: [--config, custom-config.yaml] +``` diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..dbcff37 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,47 @@ +site_name: Python Naming Linter +site_url: https://heumsi.github.io/python-naming-linter/ +repo_url: https://github.com/heumsi/python-naming-linter +repo_name: heumsi/python-naming-linter + +theme: + name: shadcn + show_title: true + show_stargazers: true + pygments_style: + light: shadcn-light + dark: github-dark + +markdown_extensions: + - admonition + - codehilite + - fenced_code + - footnotes + - attr_list + - toc: + permalink: true + +nav: + - Home: index.md + - Getting Started: + - Installation: getting-started/installation.md + - Quick Start: getting-started/quick-start.md + - Configuration: getting-started/configuration.md + - Guide: + - Rules: guide/rules.md + - Apply & Modules: guide/apply-and-modules.md + - Include / Exclude: guide/include-exclude.md + - Inline Ignore: guide/inline-ignore.md + - Cookbook: + - Overview: cookbook/index.md + - Bool Method Prefix: cookbook/bool-method-prefix.md + - Exception Naming: cookbook/exception-naming.md + - Attribute Matches Type: cookbook/attribute-matches-type.md + - Module Matches Class: cookbook/module-matches-class.md + - Layer-Based Rules: cookbook/layer-based-rules.md + - Constant Upper Case: cookbook/constant-upper-case.md + - Decorator Filtering: cookbook/decorator-filtering.md + - Reference: + - CLI: cli.md + - Pre-commit: pre-commit.md + - Contributing: contributing.md + - Changelog: changelog.md diff --git a/pyproject.toml b/pyproject.toml index 56e3c48..d355fcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,3 +100,9 @@ commit_parsers = [ { message = "^.*ci", group = "CI/CD" }, ] tag_pattern = "v[0-9].*" + +[dependency-groups] +dev = [ + "mkdocs>=1.6.1", + "mkdocs-shadcn>=0.10.2", +] diff --git a/uv.lock b/uv.lock index 7b70ab8..851999a 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,15 @@ version = 1 revision = 3 requires-python = ">=3.10" +[[package]] +name = "bottle" +version = "0.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -62,6 +71,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, ] +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/b5/59d16470a1f0dfe8c793f9ef56fd3826093fc52b3bd96d6b9d6c26c7e27b/gitpython-3.1.46.tar.gz", hash = "sha256:400124c7d0ef4ea03f7310ac2fbf7151e09ff97f2a3288d64a440c584a29c37f", size = 215371, upload-time = "2026-01-01T15:37:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/09/e21df6aef1e1ffc0c816f0522ddc3f6dcded766c3261813131c78a704470/gitpython-3.1.46-py3-none-any.whl", hash = "sha256:79812ed143d9d25b6d176a10bb511de0f9c67b1fa641d82097b0ab90398a2058", size = 208620, upload-time = "2026-01-01T15:37:30.574Z" }, +] + [[package]] name = "identify" version = "2.6.18" @@ -80,6 +125,174 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markdown" +version = "3.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-shadcn" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bottle" }, + { name = "gitpython" }, + { name = "mkdocs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/c6/d86f2494c1e1bd80d07d4ccd83862e6b4306dd8a39a97e22c0d15e54156c/mkdocs_shadcn-0.10.2.tar.gz", hash = "sha256:cc37a5a2d998dfec2fbfa24c6b67d20c5bd8b53ad36a17e51ef6e1b865be08a3", size = 3161105, upload-time = "2026-03-19T10:12:32.422Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/73/ce6ebaaec752d90e774997e890b7a7cff6a0642c8dec9afd7ee8e047c9e0/mkdocs_shadcn-0.10.2-py3-none-any.whl", hash = "sha256:f3c4c5f7f4bf80506d6cf834c6a4201d0e447980020ad8fca145bdb46c20ede1", size = 1410494, upload-time = "2026-03-19T10:12:30.421Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -98,6 +311,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + [[package]] name = "platformdirs" version = "4.9.4" @@ -141,6 +363,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pymdown-extensions" +version = "10.21.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -159,6 +394,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + [[package]] name = "python-discovery" version = "1.2.1" @@ -189,6 +436,12 @@ dev = [ { name = "ty" }, ] +[package.dev-dependencies] +dev = [ + { name = "mkdocs" }, + { name = "mkdocs-shadcn" }, +] + [package.metadata] requires-dist = [ { name = "click", specifier = ">=8.0" }, @@ -201,6 +454,12 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [ + { name = "mkdocs", specifier = ">=1.6.1" }, + { name = "mkdocs-shadcn", specifier = ">=0.10.2" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -265,6 +524,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + [[package]] name = "ruff" version = "0.15.8" @@ -290,6 +561,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -392,3 +681,35 @@ sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703 wheels = [ { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, ] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +]