Metadata-Version: 2.3
Name: ps-tooling
Version: 0.5.0
Summary: Shared Python tooling baseline for Property Shield repos (ruff, mypy, pre-commit)
License: UNLICENSED
Author: Property Shield
Author-email: engineering@propertyshield.co
Requires-Python: >=3.11,<4.0
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Requires-Dist: pyyaml (>=6.0)
Requires-Dist: tomlkit (>=0.13)
Project-URL: Repository, https://github.com/Property-Shield/ps_tooling
Description-Content-Type: text/markdown

# ps_tooling

`ps_tooling` holds the canonical Python tooling baseline for Property Shield (ruff, mypy, pre-commit) and ships a checker that fails a repo's build when its config drifts from that baseline. It is the Python counterpart to [`ps-tooling`](https://github.com/Property-Shield/ps-tooling), which does the same job for Node.

## Notion

- [Quality Assurance - Repository Standards](https://linear.app/property-shield/project/quality-assurance-repository-standards-19ef5a29614e/overview) - section 8 is what this implements
- [PS-260](https://linear.app/property-shield/issue/PS-260) - the design decisions and why they were made

## Why this verifies config instead of providing it

`ps-tooling` works by having each Node repo `extends` a published config. That is not possible in Python:

- ruff's `extend` takes a **file path**, not a package name
- mypy has no mechanism to inherit config from an installed package

So this package inverts the relationship. Each repo keeps its own `[tool.ruff]` and `[tool.mypy]` in `pyproject.toml`, exactly where editors, `ruff` on the CLI, and pre-commit already look for it, and this package **checks** those blocks against the baseline. Nothing about your local workflow changes; drift just stops being invisible.

## Architecture

Three modules, no runtime service:

| Module | Role |
|---|---|
| `ps_tooling/baseline.py` | The single source of truth: the enforced values, plus `PER_REPO_KEYS` - keys deliberately left alone, each with a written reason |
| `ps_tooling/check.py` | Compares a repo's `pyproject.toml` and `.pre-commit-config.yaml` against the baseline, returning `Violation` records |
| `ps_tooling/cli.py` | The `ps-tooling-check` entrypoint CI runs |

Consumers reach it three ways, none of which require a package registry:

1. **Reusable GitHub Actions workflow** (primary) - `.github/workflows/python-config-check.yml`
2. **Remote pre-commit hook** - `.pre-commit-hooks.yaml`
3. **Direct install** - `pip install git+https://github.com/Property-Shield/ps_tooling@v0`

## What is enforced

Every value was set by majority rule across the three repos that carry Python lint/type config today (`ps_ml_models`, `ps_image_process`, `ps-bot-accounts`).

### ruff

| Setting | Value |
|---|---|
| `line-length` | `100` |
| `lint.select` | `["E","F","I","W","UP","B","SIM","RUF"]` (order-insensitive) |
| `lint.ignore` | `[]` |
| `format.quote-style` | `"double"` |
| `format.indent-style` | `"space"` |

### mypy

| Setting | Value |
|---|---|
| `warn_unused_configs` | `true` |
| `warn_return_any` | `true` |
| `disallow_untyped_defs` | `true` |
| `explicit_package_bases` | `true` |

### pre-commit

| Repo / hook | Pinned to |
|---|---|
| `astral-sh/ruff-pre-commit` | `v0.14.14` |
| `commitizen-tools/commitizen` | `v4.17.0` |
| Required hook ids | `ruff`, `ruff-format`, `mypy`, `commitizen` |

`commitizen` is present in only one of the three repos today, so a headcount would drop it. Standards item 9 mandates conventional commits, and a standard outranks a count.

## What is deliberately not enforced

Listed in code as `PER_REPO_KEYS`, each with a reason, so "why isn't this checked?" is answerable in review:

| Key | Why |
|---|---|
| `tool.ruff.target-version`, `tool.mypy.python_version` | Follow each repo's own Python. `ps_ml_models` is pinned to 3.11 by TensorFlow 2.16.1 (no cp313 wheels); `ps-bot-accounts` is on 3.13 |
| `tool.mypy.plugins` | Framework-specific. All three service repos use pydantic, but mandating its plugin would force a pydantic dependency on repos that don't - including this one |
| `tool.mypy.files`, `tool.mypy.exclude`, `tool.mypy.overrides` | Source layouts and third-party stub gaps differ |
| `tool.ruff.lint.per-file-ignores` | Framework escapes, e.g. FastAPI's `B008` |

A repo may also **add** keys the baseline does not mention. Only baseline keys are compared, so local additions never fail the check.

## Adopting the baseline in a repo

### 1. Add the check to CI

```yaml
# .github/workflows/build_check.yml
jobs:
  tooling-baseline:
    uses: Property-Shield/ps_tooling/.github/workflows/python-config-check.yml@v0
```

### 2. Optionally add the pre-commit hook

```yaml
repos:
  - repo: https://github.com/Property-Shield/ps_tooling
    rev: v0
    hooks:
      - id: ps-tooling-check
```

pre-commit clones this repo into its own isolated venv, so nothing lands in your lockfile.

### 3. Fix whatever it reports

```bash
pip install git+https://github.com/Property-Shield/ps_tooling@v0
ps-tooling-check .
ps-tooling-check --show-baseline   # the full baseline, with exemption reasons
```

## Local setup

**Prerequisites:** Python 3.11+, Poetry 2.1.1.

```bash
poetry env use 3.11
poetry install --with dev
poetry run pre-commit install --hook-type pre-commit --hook-type commit-msg
```

## Environment variables

None. This package has no runtime configuration, no secrets, and no network access - hence no `.env.example`.

## Scripts

| Command | What it does |
|---|---|
| `poetry run ps-tooling-check [path]` | Check a repo against the baseline. Exit 1 on drift |
| `poetry run ps-tooling-check --show-baseline` | Print the baseline and every exemption reason |
| `poetry run pytest` | Test suite (80% coverage gate) |
| `poetry run ruff format .` / `ruff format --check .` | Apply / verify formatting |
| `poetry run ruff check .` / `ruff check . --fix` | Lint |
| `poetry run mypy` | Typecheck |
| `poetry run pre-commit run --all-files` | Every hook against the whole repo |

## Changing the baseline

The baseline is deliberately hard to change by accident and easy to change on purpose:

1. Edit `src/ps_tooling/baseline.py`
2. Update this README's tables
3. `poetry run pytest` - `tests/test_self_compliance.py` asserts this repo satisfies its own baseline, so a change here forces a matching change to `pyproject.toml`
4. Bump the version, tag it, move the `v1` tag
5. Every consuming repo goes red until it adopts the change - which is the point

## Versioning and release

Merge to `main` publishes. Same flow as [`ps-tooling`](https://github.com/Property-Shield/ps-tooling):

1. `.github/workflows/publish.yml` fires on push to `main`
2. `cz bump` derives the increment from the conventional commits since the last release and writes it into `[tool.poetry] version`
3. The bump commit and tag are pushed, and the floating `v1` tag is moved to match
4. The package is built and published to PyPI

Nothing to run by hand. A merge containing only `docs:` / `chore:` commits produces no release - commitizen exits 21 (`NoneIncrementExit`) and the publish job is skipped.

To force a specific increment, run the workflow manually (**Actions -> Build and Publish -> Run workflow**) and pick `PATCH` / `MINOR` / `MAJOR`. Manual runs post to the deploy Slack channel, since they bypass the normal merge path.

### Authentication

Publishing uses [PyPI Trusted Publishing](https://docs.pypi.org/trusted-publishers/) - there is **no PyPI token in GitHub secrets**. PyPI verifies the workflow's OIDC claim and mints a short-lived credential for a single upload, so there is nothing long-lived to leak or rotate.

One-time setup at <https://pypi.org/manage/account/publishing/>:

| Field | Value |
|---|---|
| PyPI project name | `ps-tooling` |
| Owner | `Property-Shield` |
| Repository name | `ps_tooling` |
| Workflow name | `publish.yml` |
| Environment name | `pypi` |

Renaming `publish.yml` or the `pypi` environment breaks the trust relationship until PyPI is updated to match.

The version bump pushes to `main`, which the org protect-main ruleset blocks for the default `GITHUB_TOKEN`. It uses the PS CICD App instead (`PS_CICD_APP_ID` / `PS_CICD_APP_PRIVATE_KEY` org secrets); that app must be on the ruleset's bypass list.

### Why the loop guard exists

Pushes made with the default `GITHUB_TOKEN` never re-trigger workflows, but pushes made with a GitHub App token do. Since the bump job pushes with the app token, `publish.yml` skips any push whose head commit starts with `bump:`. Removing that guard causes an infinite publish loop.

## Quality gates

`PR Build Check` runs format → lint → typecheck → tests (80% coverage gate) → self-compliance on every PR to `main`. Direct pushes to `main` are disabled. Commits follow [Conventional Commits](https://www.conventionalcommits.org/), enforced by a commitizen `commit-msg` hook and re-checked in CI.

