Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12"]
python-version: ["3.11", "3.12", "3.13"]
steps:
- name: Checkout
uses: actions/checkout@v4
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

## Unreleased

### Changed — the minimum Python is now 3.11

**`requires-python` moves from `>=3.10` to `>=3.11`.** Install 0.15.0 on 3.10
and pip will refuse it. Every consumer repo in the org runs 3.14, so nothing
here needs a change. Python 3.10 reaches end of life in October 2026, and
SPEC 0 dropped it in 2024.

The floor bought a real deletion. `doctor` read `pyproject.toml` with a
hand-written scanner, because `tomllib` arrived in 3.11. Seven review findings
in three rounds were all the same defect: escaped quotes, quoted keys, dotted
keys, brackets inside markers, comments after punctuation, and a tool's own
tables read as project metadata. The scanner is gone and `tomllib` reads the
file. `doctor.py` loses 97 lines and gains 64.

`source_version()` uses the parser too. The regex it replaced took the first
`version = "…"` in the file, which is the project's version only while no other
table declares one above it.


**Take this one if you enumerate Lok Sabha sessions, or read any Sansad answer.**
The degrading paginator that 0.14.8 and 0.14.9 built had no production caller,
and an answer can be a different question's document.
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ python -m twine check dist/*
2. Add or update tests for behavior changes.
3. Update docs (`README.md`, `docs/SCHEMAS.md`) if user-facing behavior changes.
4. Reference related issues in the PR description.
5. Ensure CI passes on Python 3.10, 3.11, and 3.12.
5. Ensure CI passes on Python 3.11, 3.12, and 3.13.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ provenance-rich, schema-validated JSONL and source files.

## Install

Requires Python 3.10+. Released on [PyPI](https://pypi.org/project/commoner-probe/).
Requires Python 3.11+. Released on [PyPI](https://pypi.org/project/commoner-probe/).

```bash
pip install "commoner-probe[all]" # everything needed for acquisition + extraction
Expand Down
3 changes: 2 additions & 1 deletion commoner_probe/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2199,7 +2199,8 @@ def build_parser() -> argparse.ArgumentParser:
"doctor",
help=(
"Compare the source version, the installed metadata and any declared "
"pin. Exits 1 when two KNOWN numbers disagree."
"pin. Exits 1 when two KNOWN numbers disagree, and when a "
"--requirements file declares this package without an exact pin."
),
epilog=(
"Examples:\n"
Expand Down
196 changes: 183 additions & 13 deletions commoner_probe/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,138 @@

import re
from pathlib import Path
from typing import Any

__all__ = ["VersionReport", "declared_pins", "installed_version", "source_version",
"version_report"]

#: Requirement lines that pin this package, in the two forms the org uses: an
#: exact `==` pin from PyPI, and a git URL pinned to a tag.
#: Every form the org's consumers actually use, measured against the seven live
#: pin files on 2026-08-17. The requirement does not have to start the line,
#: because a `pyproject.toml` writes it quoted inside a dependency list, and
#: extras are optional, because four of the seven carry them
#: (`commoner-probe[http,pdf]==0.14.3`). The first reader required both and found
#: one pin where three existed.
#: It must still open a requirement token. `my-commoner-probe==9.9.9` is a
#: different package, and `description = "built for commoner-probe==9.9.9"` is
#: prose; each was read as this package's pin, and `doctor` then failed a
#: consumer that never depended on it.
_EXTRAS = r"(?:\[[^\]]*\])?"
#: PEP 503 normalises `-`, `_` and `.` to one name, so `commoner.probe` is
#: this package and pip installs it as such.
_NAME = rf"commoner[-_.]probe{_EXTRAS}"
#: A requirement token opens a line, or opens a quoted element of an array.
#: Prose names the package mid-sentence, and TOML names it on both sides of a
#: scalar assignment — `name = "commoner-probe"` and the console-script key —
#: so an opening quote alone is not enough. Only `[` and `,` open a dependency
#: list.
_TOKEN = r"(?:^|[\[,]\s*[\"'])\s*"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restrict quoted-list tokens to dependency arrays

When a supplied pyproject.toml contains non-dependency list metadata such as keywords = ["commoner-probe==9.9.9"], this token accepts the opening [ and declared_pins() reports 9.9.9, producing a false mismatch for a project that does not depend on the package. Fresh evidence beyond the prior prose report is that the current narrowing excludes scalar prose but still treats every quoted TOML array element as a dependency; the TOML form needs to be tied to dependency or optional-dependency declarations.

Useful? React with 👍 / 👎.

#: The git form carries the name inside a URL, so a `/` opens it too.
_URL_TOKEN = r"(?:^|[\[,]\s*[\"']|/)\s*"
_PIN_PATTERNS = (
re.compile(r"^\s*commoner[-_]probe\s*==\s*([0-9][^\s;#]*)", re.I | re.MULTILINE),
re.compile(r"commoner-probe(?:\.git)?@v?([0-9][^\s;#\"']*)", re.I),
re.compile(rf"{_TOKEN}{_NAME}\s*==\s*([0-9][^\s;#,\"']*)", re.I | re.MULTILINE),
re.compile(rf"{_URL_TOKEN}commoner-probe(?:\.git)?{_EXTRAS}@v?([0-9][^\s;#\"']*)",
re.I | re.MULTILINE),
)

#: The package named as a requirement with no exact version. The org requires an
#: exact pin, so this is a finding rather than an absence — reporting nothing
#: filed it beside the files that never mention the package at all. Unanchored,
#: like the pin patterns, because the compact TOML form
#: `dependencies = ["commoner-probe>=0.14"]` is valid and a start-of-line test
#: could not reach it: a violated pin policy then exited successfully.
#:
#: Three shapes, and no fourth. A range operator follows the name, or the name
#: is the whole line, or the name is a whole quoted element of a list. Accepting
#: any closing quote made `description = "built on commoner-probe"` a dependency
#: and `commoner-probe = "commoner_probe.cli:main"` a dependency, and neither
#: prose nor an entry-point key declares one.
_UNPINNED_PATTERNS = (
re.compile(rf"{_TOKEN}{_NAME}\s*(?:[<>~!]=|[<>@])", re.I | re.MULTILINE),
re.compile(rf"^\s*{_NAME}\s*(?:;|$)", re.I | re.MULTILINE),
re.compile(rf"[\[,]\s*[\"']\s*{_NAME}\s*(?:;[^\"']*)?[\"']", re.I | re.MULTILINE),
)

#: The tables a `pyproject.toml` declares installable requirements in. A tool's
#: own `[tool.x]` table may hold a `dependencies` array; that configures the
#: tool and installs nothing, so a version in it is not a pin on this package.
#: The root entries are not PEP 621, but a file that writes `dependencies` with
#: no table above it is still declaring one, and reading it costs nothing.
_DEP_PATHS = (
("project", "dependencies"),
("build-system", "requires"),
("dependencies",),
("requires",),
)
_DEP_GROUP_PATHS = (
("project", "optional-dependencies"),
("dependency-groups",),
("optional-dependencies",),
)


def _toml_requirements(text: str) -> list[str]:
"""Every requirement string a `pyproject.toml` declares, or [] if unreadable.

Parsed by `tomllib`, not scanned. The scan this replaced grew a rule per
round of review — escaped quotes, quoted keys, dotted keys, brackets inside
markers, comments after punctuation — because each was another thing real
TOML does. Reading a format needs a reader for that format.

A file that does not parse yields nothing rather than raising. `doctor`
reports what it can read and calls the rest unknown; a broken consumer file
is not this repo's version question.
"""
import tomllib

try:
data = tomllib.loads(text)
except (tomllib.TOMLDecodeError, ValueError):
return []
out: list[str] = []
for path in _DEP_PATHS:
node: Any = data
for key in path:
node = node.get(key) if isinstance(node, dict) else None
if isinstance(node, list):
out.extend(x for x in node if isinstance(x, str))
for path in _DEP_GROUP_PATHS:
node: Any = data
for key in path:
node = node.get(key) if isinstance(node, dict) else None
if isinstance(node, dict):
for group in node.values():
if isinstance(group, list):
out.extend(x for x in group if isinstance(x, str))
return out


def _uncommented(text: str) -> str:
"""The text with comment tails removed.

`search()` returns the FIRST occurrence, so a commented old pin above an
active one won: `doctor` reported a mismatch that did not exist and exited 1.
A `#` counts as a comment when it opens a line or follows whitespace, which
is how both requirements files and TOML write one.
"""
return re.sub(r"(?m)(?:^|(?<=\s))#.*$", "", text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize TOML comments without preceding whitespace

In TOML, # starts a comment outside a string even when it immediately follows punctuation. With dependencies = [#"commoner-probe==9.9.9"] followed by a real unrelated entry on the next line, this substitution leaves the comment intact, so the array scanner reads the commented string and reports a nonexistent 9.9.9 pin; a comment containing ] can likewise terminate scanning before a real dependency. Comment removal must recognize these starts while still preserving hashes inside strings.

Useful? React with 👍 / 👎.



#: Every `==` version in one requirement's specifier set. PEP 508 allows a list
#: — `commoner-probe==0.15.0,==9.9.9` is one declaration and cannot be
#: satisfied — and reading only the first specifier reported it as a clean pin.
#: The marker after `;` is not a specifier and is cut before the scan.
_EXACT = re.compile(r"==\s*([0-9][^\s,;#\"']*)")


def _exact_versions(declaration: str, start: int) -> list[str]:
"""The versions the specifier set after *start* pins, in order."""
specifiers = declaration[start:].split(";", 1)[0]
return _EXACT.findall(specifiers)


#: Marks a file that pins this package at two versions at once.
_CONFLICT = "conflict"


class VersionReport:
"""The three versions, and whether they agree.
Expand All @@ -62,6 +183,18 @@ def mismatches(self) -> list[str]:
"this tree; one venv shared across worktrees reports whichever was "
"installed last.")
for where, pin in self.pins.items():
if pin.startswith(_CONFLICT):
out.append(
f"{where} pins this package at two different versions "
f"({pin.split(': ', 1)[-1]}). Which one installs depends on the "
"resolver, so the file does not state what the consumer runs.")
continue
if pin == "unpinned":
out.append(
f"{where} names this package with no exact version. The org pins "
"with == or @vX.Y.Z, because a range moves under the consumer "
"without anyone deciding to move it.")
Comment on lines +192 to +196

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Document the new unpinned-dependency failure

When --requirements contains a bare or ranged dependency, this new branch makes doctor exit 1, but both commoner_probe/cli.py and docs/CLI.md still tell users that it exits 1 only when two known version numbers disagree. An unpinned dependency supplies no known version number, so the documented CLI contract now contradicts the intentional exact-pin validation and should be updated alongside this behavior.

Useful? React with 👍 / 👎.

continue
if self.installed and pin != self.installed:
out.append(
f"{where} pins {pin} and the environment runs {self.installed}. A "
Expand Down Expand Up @@ -94,16 +227,22 @@ def report(self) -> str:
def source_version(pyproject: Path | str) -> str | None:
"""The version in a ``pyproject.toml``, or None when it cannot be read.

Read with a regex rather than a TOML parser on purpose: ``tomllib`` arrived in
Python 3.11 and this package supports 3.10, so a parser import would make the
check unavailable on the oldest version it claims to run on.
Read by ``tomllib``. The regex this replaced took the first ``version = "…"``
in the file, which is the project's version only while no other table
declares one above it.
"""
import tomllib

try:
text = Path(pyproject).read_text(encoding="utf-8")
except OSError:
return None
match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
return match.group(1) if match else None
try:
data = tomllib.loads(text)
except (tomllib.TOMLDecodeError, ValueError):
return None
version = data.get("project", {}).get("version")
return version if isinstance(version, str) else None


def installed_version(package: str = "commoner-probe") -> str | None:
Expand Down Expand Up @@ -131,11 +270,42 @@ def declared_pins(*paths: Path | str) -> dict[str, str]:
text = p.read_text(encoding="utf-8")
except OSError:
continue
for pattern in _PIN_PATTERNS:
match = pattern.search(text)
if p.suffix == ".toml":
# `tomllib` handles TOML comments itself; stripping them first would
# corrupt a `#` that sits inside a string.
declarations = _toml_requirements(text)
else:
declarations = _uncommented(text).splitlines()
# Every declaration is classified, not just the first. One file can name
# the package twice — an exact pin in `dependencies` and a range in an
# optional group — and stopping at the first pin reported a compliant
# file; if that pin matched the environment, `doctor` exited 0 over a
# file that breaks the policy this check exists to enforce.
#
# Classification is per declaration, because the two pattern sets
# overlap inside one requirement. A git pin reads as `commoner-probe @
# git+...@v0.15.0`: the `@` opens an unpinned URL requirement and the
# tag closes an exact one, in the same string. A file-wide "unpinned
# wins" rule therefore called every git tag pin unpinned.
pins: list[str] = []
unpinned = False
for declaration in declarations:
match = next(
(m for m in (pat.search(declaration) for pat in _PIN_PATTERNS) if m), None)
Comment on lines +293 to +294

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Inspect every exact specifier in a declaration

Fresh evidence beyond the earlier two-pin report is that one valid PEP 508 declaration can contain both pins, for example commoner-probe==0.15.0,==9.9.9. Because this takes only the first search() result from the declaration, declared_pins() reports 0.15.0 rather than a conflict; an environment running 0.15.0 therefore makes doctor exit successfully even though the requirement is contradictory. Collect all exact-version matches within each declaration before performing the conflict check.

Useful? React with 👍 / 👎.

if match:
found[str(p)] = match.group(1)
break
found_here = _exact_versions(declaration, match.start()) or [match.group(1)]
pins.extend(v for v in found_here if v not in pins)
Comment on lines +296 to +297

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize exact versions before declaring a conflict

When equivalent PEP 440 spellings occur together, such as commoner-probe==0.15,==0.15.0, both specifiers select the same normalized version, but this raw-string deduplication retains two entries and reports conflict: 0.15, 0.15.0. doctor therefore exits 1 for a valid, satisfiable requirement whose exact pins agree; compare normalized versions before deciding that multiple collected values conflict.

Useful? React with 👍 / 👎.

elif any(pat.search(declaration) for pat in _UNPINNED_PATTERNS):
unpinned = True
if unpinned:
found[str(p)] = "unpinned"
elif len(pins) > 1:
# Keeping the first hid the second. One file pinning two versions
# installs whichever resolver wins, and if the first matched the
# environment `doctor` exited 0 over it.
found[str(p)] = f"{_CONFLICT}: {', '.join(pins)}"
elif pins:
found[str(p)] = pins[0]
return found


Expand Down
5 changes: 5 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -953,6 +953,11 @@ across worktrees reports whichever tree was installed last.
Exits 1 when two KNOWN numbers disagree. A number that cannot be read is reported
as unknown, never as agreement.

It also exits 1 when a `--requirements` file declares this package without an
exact pin. The org requires `==` or `@vX.Y.Z`, so a range, a bare name, or a
marker with no version is a finding rather than an absence. Reporting nothing
filed such a file beside the ones that never mention the package at all.

### `commoner-probe stats` — corpus health

```bash
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ name = "commoner-probe"
version = "0.15.0"
description = "Sousveillance infrastructure for state mandatory-disclosure portals — parliamentary questions, committee reports, budget data, and state assembly records."
readme = "README.md"
requires-python = ">=3.10"
requires-python = ">=3.11"
authors = [{ name = "CommonerLLP" }]
maintainers = [
{ name = "Sreeram N R", email = "sreeram.nr@gmail.com" },
Expand Down Expand Up @@ -35,9 +35,9 @@ classifiers = [
"Intended Audience :: Science/Research",
"Topic :: Sociology",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Operating System :: OS Independent",
]
dependencies = []
Expand Down
Loading
Loading