Metadata-Version: 2.4
Name: sincewhen
Version: 0.4.0
Summary: Find out which Python version added each feature your code uses
Keywords: python-version,compatibility,ast,whatsnew,history
Author: Trey Hunner
License-Expression: MIT
License-File: LICENSE.txt
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Programming Language :: Python :: Implementation :: PyPy
Classifier: Topic :: Software Development :: Quality Assurance
Requires-Python: >=3.14
Project-URL: Documentation, https://github.com/treyhunner/sincewhen#readme
Project-URL: Changelog, https://github.com/treyhunner/sincewhen/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/treyhunner/sincewhen/issues
Project-URL: Source, https://github.com/treyhunner/sincewhen
Description-Content-Type: text/markdown

# sincewhen

[![PyPI - Version](https://img.shields.io/pypi/v/sincewhen.svg)](https://pypi.org/project/sincewhen)
[![PyPI - Python Version](https://img.shields.io/pypi/pyversions/sincewhen.svg)](https://pypi.org/project/sincewhen)

Find out which Python version added each feature your code uses.

Point `sincewhen` at a file and it will tell you what's in there and how long each piece of it has been in Python, back to the first public release in 1991.


## Installation

Installing with [`uv tool`](https://docs.astral.sh/uv/concepts/tools/):

```console
uv tool install sincewhen
```

Installing with [`pipx`](https://pipx.pypa.io):

```console
pipx install sincewhen
```

You can also install `sincewhen` globally with `pip`, but I usually recommend installing command-line tools in their own separate environment.


## Usage

Give `sincewhen` a file to see every dated feature it uses:

```console
$ sincewhen example.py
example.py:1  tomllib module                  3.11                        2022-10-24
example.py:3  positional-only parameters (/)  3.8                         2019-10-14
example.py:5  with statement                  2.5                         2006-09-19
example.py:5  open()                          0.9 (first public release)
```

The last column is the day that release shipped, so a version number reads as an age.

Only the first use of each feature is shown by default.
Pass `--all` to see every occurrence.

The dataset reaches back to Python 0.9.1, so a file will report things like `str()` and `open()` that have been there since 1991.
That is usually the point, but pass `--since` when you only want the recent arrivals:

```console
$ sincewhen --since 3.0 example.py
```

Read from standard input with `-`:

```console
$ echo 'x: int = 1' | sincewhen -
<stdin>:1  variable annotation  3.6                         2016-12-23
<stdin>:1  int()                0.9 (first public release)
```

Look a single feature up by name instead of analyzing code:

```console
$ sincewhen --search walrus
walrus operator (:=) - Python 3.8 (released 2019-10-14)
  PEP: https://peps.python.org/pep-0572/
  Docs: https://docs.python.org/3/whatsnew/3.8.html
```

A method of a builtin type answers to its own name, without the type in front of it:

```console
$ sincewhen --search removeprefix
removeprefix() on str, bytes and bytearray - Python 3.9 (released 2020-10-05)
  Docs: https://docs.python.org/3/whatsnew/3.9.html
```

Pass `--json` to either mode for machine-readable output.


## Library

```python
>>> import sincewhen
>>> sincewhen.minimum_version("import tomllib")
Version(major=3, minor=11)
>>> [d.feature.name for d in sincewhen.detect("if (n := 1): pass")]
['walrus operator (:=)']
>>> sincewhen.lookup("tomllib")[0].added
Version(major=3, minor=11)
```


## Why it needs Python 3.14

`sincewhen` parses code with the standard library's `ast` module, which can only understand syntax that the running interpreter understands.
A Python 3.9 interpreter cannot parse a `match` statement, so it could not report one either.
Requiring the newest Python is what lets `sincewhen` recognize the newest syntax.

The Python version you *run* `sincewhen` on has nothing to do with the versions it *reports* on, which reach back to Python 0.9.1.


## Known limits

- Some features are genuinely ambiguous in an AST.
  `a | b` could be a Python 3.9 dict merge, a Python 3.10 union type, or an integer bitwise-or that has worked since forever, and the AST alone cannot tell you which.
  Ambiguous features are left out rather than guessed at.
- Detection is syntactic.
  `sincewhen` sees that you called something named `math.isclose`, not that you called the real one.
  Shadowed builtins are skipped, but a shadowed module attribute is not.
- The dataset is curated and incomplete.
  A feature that isn't in it won't be reported, so the minimum version is a lower bound on the true answer.
- `added` is the oldest release from which a feature has been available *ever since*, ignoring Python 3.0 and 3.1.
  Nobody shipped code on those two, so a gap there is not a gap anyone lived through: `argparse` shipped in 2.7 and again in 3.2, and is dated 2.7.
  A feature missing from 3.2 as well has a real gap and takes the later date.
- Some features cannot be dated, only bounded, and those are reported as "1.5 or earlier".
  There are four of them, each a module needing something the oldest interpreters could not be built with.
- Anything present in Python 0.9.1 reads as "0.9 (first public release)" rather than "0.9 or earlier".
  Python began before it was published, so a few of those are genuinely older, but there is no earlier release to reach for: nothing has been in Python longer than Python has been public.
- Release dates come from python.org's downloads database back to 2.2, and from CPython's release tags before that.
  Python 0.9 and 1.6 have no release tag, so they show no date.
- Searching for a module member that has no entry of its own falls back to the module it lives in, since a member cannot be older than its module.
- A method of a builtin type is dated for searching but is mostly not detected, because `value.removeprefix(...)` says nothing about what `value` is.
  Only a receiver whose type is certain reports one: a literal, as in `"Mr. Smith".removeprefix("Mr. ")`, or the type's own name, as in `dict.fromkeys(keys)`.


## Development

This project uses [uv](https://docs.astral.sh/uv/) and [just](https://just.systems).
Run `just` to see every available task.

```console
$ just test     # run the test suite
$ just check    # format, lint, typecheck, and test
```

No setup step is needed: `uv` creates the virtual environment and installs dependencies on the first `uv run`.

If you would rather not install `just`, every task is a short `uv` command that you can run directly (check the `justfile` for the commands):

```console
uv run pytest
```


### Adding a feature

Features live in `src/sincewhen/features.toml`, one `[[features]]` table each.
Give the feature an id, a human-readable name, the version that added it, a category, exactly one matcher, and the evidence for the version:

```toml
[[features]]
id = "walrus"
name = "walrus operator (:=)"
added = "3.8"
category = "syntax"
pep = 572
nodes = ["NamedExpr"]

[features.evidence]
method = "pep"
pep = 572
python_version = "3.8"
checked = "2026-07-28"
```

The matcher kinds are `nodes` (AST node class names), `builtins`, `modules`, `attributes` (dotted `module.name` paths), and `methods` (dotted `type.method` paths for the builtin types).
Node matchers can be narrowed with `requires` (a node attribute that must be truthy) or `check` (a predicate registered in `detect.py`).

Documentation links are generated from `added` and `pep`, so only set `docs` when you have a better link than the "What's New" page.

Nobody should be typing version numbers from memory.
For anything in the standard library, let the archived documentation say what the version is:

```console
$ just fetch-docs                       # one-time, ~500 MB into a gitignored .cache/
$ just whenadded math.lcm               # what each source says, and whether they agree
$ just propose math.lcm math.isqrt      # entries with evidence, ready to paste
$ just typemethods --compare            # what the builtin types' method tables date
$ just verify-dataset                   # re-derive every claim in the dataset
```

`just verify-dataset` also runs in CI, so a pull request that edits a version without editing its evidence fails.

Evidence has eight `method` values, seven of which a machine can recheck:

| method        | what it means                                                                                                                               |
|---------------|---------------------------------------------------------------------------------------------------------------------------------------------|
| `objects.inv` | the symbol is absent from one release's Sphinx inventory and present in the next                                                            |
| `archive`     | the same diff over the module lists and built-in function pages in the pre-Sphinx doc builds, back to the 0.9.1 LaTeX                       |
| `source`      | the name is absent from one release's own C or Python implementation and present in the next, which reaches back further than any doc build. For a method of a builtin type this is the type's own method table, which is the only thing that can date `dict.setdefault` or `str.split` at all |
| `interpreter` | that release's own interpreter, built from its tarball, was asked whether the name resolves                                                 |
| `annotation`  | the documentation dates it itself, in an "Added in version" marker quoted in the entry                                                      |
| `grammar`     | the token is absent from one release's grammar and present in the next, which is what shipped rather than what a PEP intended               |
| `pep`         | the feature's PEP carries a `Python-Version` header                                                                                         |
| `manual`      | a human read the archives and wrote down what they found, and why the other seven do not settle it                                          |

`interpreter` is the only method that reads Python rather than a description of it, and it outranks the rest for the era it covers, 0.9.1 to 2.5.
It is what settles a name no text can account for: `re.finditer` is defined in Python 2.2's `sre.py` and left out of its `__all__`, so `from sre import *` never bound it and the answer is 2.3, not the 2.2 the docs claim.
Building the interpreters needs Docker and about ten minutes, so the result is committed as `scripts/interpreters.json` and nothing downstream needs a compiler:

```console
$ just build-pythons                    # build 0.9.1 through 2.5 (slow)
$ just probe-pythons                    # ask them all, and record it
$ just interpreters-vs-dataset          # where they disagree with the dataset
```

`manual` is for the cases where the sources genuinely disagree, and every one of them is printed on every `verify-dataset` run so the override stays visible.

A new entry, or a corrected version on an existing one, also gets a line under `Unreleased` in [`CHANGELOG.md`](CHANGELOG.md).
Dataset changes are the ones that alter what `sincewhen` reports about code that did not change, so they are worth spelling out.


## Releasing

Move the `Unreleased` notes in [`CHANGELOG.md`](CHANGELOG.md) under a heading for the new version, then bump, tag, and push:

```console
$ just bump patch
$ just release
```

`just release` refuses to tag a version that the changelog has nothing to say about, so the notes have to be written before the release goes out rather than after.

Pushing a `v*` tag runs the release workflow, which publishes to PyPI with trusted publishing and creates a GitHub release whose notes are that version's changelog section.


## License

`sincewhen` is distributed under the terms of the [MIT license](LICENSE.txt).
