licenseclassifier
Pure-Python SPDX license identification. Give it a blob of license text, get back the SPDX license IDs it contains, with the character offsets of each match.
from licenseclassifier import identify_license
identify_license(open("LICENSE").read())
# [LicenseIdentificationResult(id='Apache-2.0', start=0, end=11324)]
- No dependencies. Nothing but the standard library.
- No native code, no network. Pure Python, fully offline. The license corpus ships in the wheel.
- Fast. ~2 ms to classify a typical license file, after a ~30 ms one-time load.
- 693 licenses — every non-deprecated SPDX identifier the matching templates support, plus license-URL recognition.
- Designed not to guess. A coverage threshold means it reports nothing rather than something wrong.
Install
pip install licenseclassifier
Python 3.10+.
Identifying a single license
The common case: you have a LICENSE file and you want to know what it is.
from pathlib import Path
from licenseclassifier import identify_license
text = Path("LICENSE").read_text()
for match in identify_license(text):
print(match.id, match.start, match.end)
For a stock Apache 2.0 file that prints:
Apache-2.0 0 11324
One result, spanning the whole file. Each result is a frozen dataclass:
LicenseIdentificationResult(id='Apache-2.0', start=0, end=11324)
start and end are character offsets into the string you passed in, so you can always slice the
matched region back out:
(match,) = identify_license(text)
print(text[match.start : match.end].strip()[:14])
# Apache License
If you only care about the identifier, and only expect one:
matches = identify_license(text)
license_id = matches[0].id if matches else None
Identifying multiple licenses
Real projects bundle licenses. A vendored-dependency file, a THIRD_PARTY_LICENSES, or a project
that is dual-licensed will contain several license texts one after another. identify_license
returns one result per matched region, in the order the regions appear:
from pathlib import Path
from licenseclassifier import identify_license
text = Path("COPYING").read_text() # a 23 KB bundled-licenses file
for match in identify_license(text):
print(f"{match.id:<14} {match.start:>6} – {match.end}")
MIT 678 – 1764
NCSA 1845 – 3383
MIT 3628 – 4852
Apache-2.0 4941 – 16298
Zlib 16404 – 17310
Unlicense 17417 – 18627
BSD-2-Clause 18828 – 20214
BSD-3-Clause 20356 – 21868
BSD-2-Clause 21949 – 23251
Two things to note.
Duplicates are real, not a bug. MIT and BSD-2-Clause each appear twice because that file
genuinely contains two copies of each — different vendored components under the same license. The
results are regions, not a set. Deduplicate yourself if that's what you want:
distinct = sorted({m.id for m in identify_license(text)})
# ['Apache-2.0', 'BSD-2-Clause', 'BSD-3-Clause', 'MIT', 'NCSA', 'Unlicense', 'Zlib']
The offsets let you pull each license out on its own. The regions are ordered and non-overlapping, so you get back the individual license texts rather than an unordered bag of IDs — enough to attribute each one to the component it came from, or to re-emit them separately:
for match in identify_license(text):
region = text[match.start : match.end]
print(f"{match.id:<14} {len(region):>6} chars")
MIT 1086 chars
NCSA 1538 chars
MIT 1224 chars
Apache-2.0 11357 chars
Zlib 906 chars
Unlicense 1210 chars
BSD-2-Clause 1386 chars
BSD-3-Clause 1512 chars
BSD-2-Clause 1302 chars
The coverage threshold
The scanner only reports a license when the matched regions together cover enough of the input. This is what stops it from claiming your README is MIT-licensed just because it mentions MIT.
identify_license("MIT")
# []
identify_license("This project is released under the MIT license. See LICENSE.")
# []
Neither is a license text, so neither gets classified. The default threshold is 75% (the same
default licensecheck uses), exposed as COVERAGE_THRESHOLD. Override it per call:
from licenseclassifier import identify_license
# Accept files that embed a license alongside a lot of other prose.
identify_license(text, coverage_threshold=40.0)
# Demand a near-verbatim license file and nothing else.
identify_license(text, coverage_threshold=98.0)
Lowering the threshold trades precision for recall. The default is deliberately strict: this
library is meant to be trusted, so it prefers returning [] over returning a guess.
Putting it together
Classifying a tree of license files, the way an SBOM or compliance tool would:
from pathlib import Path
from licenseclassifier import identify_license
CANDIDATES = ("LICENSE*", "LICENCE*", "COPYING*", "NOTICE*")
for pattern in CANDIDATES:
for path in Path("vendor").rglob(pattern):
if not path.is_file():
continue
ids = sorted({m.id for m in identify_license(path.read_text(errors="replace"))})
print(f"{path}: {', '.join(ids) or 'unidentified'}")
API
The public API is three names, all importable from the top-level package.
identify_license(license_text, coverage_threshold=COVERAGE_THRESHOLD)
Returns list[LicenseIdentificationResult] — one entry per matched region, in document order.
Returns [] if total coverage falls below coverage_threshold.
LicenseIdentificationResult
Frozen dataclass with id (SPDX identifier, str), start and end (character offsets, int;
end is exclusive).
COVERAGE_THRESHOLD
75.0. The default minimum percentage of the input that must be recognised license text.
The package ships a py.typed marker, so type checkers see the annotations.
licenseclassifier.__version__ is also available, though it is not part of the three-name contract.
Versioning
CalVer: YYYY.MM.MICRO, where MICRO counts releases within a month from 0. So 2026.7.0
is the first July 2026 release and 2026.7.1 the second. Most of what changes between releases is
the vendored SPDX license data, whose value depends on how recent it is — a date conveys that, a
MAJOR.MINOR.PATCH number does not.
Because the number carries no compatibility signal, the guarantees are written down instead:
breaking changes to the three public names are flagged BREAKING in
CHANGELOG.md, removals are preceded by at least two months of
DeprecationWarning, and everything under _engine/ is private and may change at any time. Note
that identification results are not part of the contract: refreshed license data can change which
IDs a given text matches, and that ships as an ordinary release.
If you need to pin, pin an exact version or an upper bound on the year-month — a ~= or ^
constraint does not mean anything useful here.
Performance
Measured on an Apple M-series laptop, classifying an 11 KB Apache 2.0 file:
import licenseclassifier |
~7 ms |
| First call (deserializes the compiled scanner) | ~30 ms |
| Subsequent calls, median | ~2 ms |
The scanner is built once and cached for the life of the process, so batch workloads pay the startup cost a single time. The expensive part — compiling ~700 license patterns into a matcher, about 1.4 s of work — is done ahead of time at build time and shipped as a serialized artifact in the wheel, which is why the first call is 30 ms rather than 1.4 s.
Memory
Resident set size of the whole process, same machine, python -m tools.benchmark:
| Bare interpreter | 24 MiB |
import licenseclassifier |
+0.3 MiB |
| Matcher deserialized (first call) | +18 MiB |
| First scan | +34 MiB |
| 200 more scans of the same file | +0.1 MiB |
| 1000 files across 7 common licenses | +6 MiB |
| Every one of 708 distinct licenses | +324 MiB |
| A second pass over all 708 | +1 MiB |
Memory tracks how varied your input is, not how much of it there is. Matching runs a DFA that is built lazily and memoized, one entry per state reached, and the cache has no eviction — so scanning the same license a thousand times costs nothing after the first, while scanning a thousand different licenses keeps allocating. A scanner that has seen every license in the corpus holds about 600,000 memoized states and 400 MiB.
For the common case — a repository scan, or a service classifying files that are mostly MIT and Apache-2.0 — that settles around 80 MiB and stays there. If you are scanning genuinely diverse license text in a long-lived process and 400 MiB is too much, the only lever today is process recycling: the cache is internal to the scanner object and there is no public way to clear it. That is a gap, and it is on the roadmap.
This is inherited behaviour, not something introduced here: google/licensecheck builds its DFA the
same way, for the same reason — a fully built DFA over 700 word-level patterns would be far larger
than the part of it any real input touches.
How it works
Four stages, all ported from google/licensecheck:
- Tokenization (
_engine/dictionary.py) — the text is split into words and canonicalized: case folding, accent stripping,(c)/©/copyrightnormalization,https→http, and skipping HTML and Markdown markup. Words are interned to integer IDs, so everything downstream operates on ints rather than strings. - Pattern parsing (
_engine/resyntax.py) — the built-in licenses are written in LRE, a small regexp-like DSL over words, which is parsed into a syntax tree. - Matching (
_engine/matcher.py) — the trees are compiled into a word-level regexp bytecode, combined into one program, and run as a Thompson NFA with a lazily built, memoized DFA. Matching is leftmost-longest and non-overlapping, and includes context-sensitive spell checking so that real-world files with typos still match. - Cover/scan (
_engine/scan.py) — turns raw word matches into character offsets, back-fills preceding copyright lines into each region, detects license URLs between matches, and computes the coverage percentage.
Everything under _engine/ is private. Treat only the three names above as the supported API.
The license corpus, and regenerating the artifacts
Both files under _engine/ are build artifacts. The reviewable sources live in
data/ as one plain-text LRE pattern per license, plus an order.txt that
records which patterns exist and — because the matcher reports the lowest-numbered pattern that
matches a span — in what priority order:
python -m tools.corpus build # data/ -> licenses.json.gz
python -m licenseclassifier._engine._build # licenses.json.gz -> scanner.bin.gz
Tests assert that the committed artifacts are the build of the committed sources, so CI will tell
you if you forgot either step. At runtime, a missing, stale or unreadable scanner.bin.gz is not
fatal — the scanner falls back to compiling from licenses.json.gz, just more slowly.
Refreshing the SPDX data
The corpus tracks a pinned SPDX License List release, recorded in data/spdx-version.json. To move
to a newer one:
python -m tools.refresh_spdx # or --release v3.29.0
That converts every license SPDX has added, adds a second pattern for any license whose canonical text SPDX has reworded since the existing pattern was written, rebuilds both artifacts and runs the gate. A scheduled workflow does the same monthly and opens a pull request; nothing is merged or released automatically, because a machine-converted pattern is a proposal, not a result.
Accuracy
The engine is a faithful port, not an approximation. It was validated to full parity with
google/licensecheck v0.3.1 — identical matched license IDs and identical coverage percentages —
across all 672 fixtures in that project's testdata.
That parity harness is not currently vendored into this repository, since it needs licensecheck's
Go testdata tree. The in-tree test suite covers the public API, the multi-license case, the
coverage threshold and its boundaries, character-offset correctness on non-ASCII input, and the
integrity of the prebuilt artifact — the last of these on every supported interpreter, because the
artifact is marshal-serialized and marshal is not guaranteed portable across Python versions.
Vendoring the full parity suite is on the roadmap.
The corpus gate
Patterns are also checked against the licenses they claim to identify. tests/test_license_gates.py
scans the canonical text of all 708 SPDX licenses the corpus covers and asserts each one is
identified as itself and nothing else, with every deliberate deviation recorded in
data/expected-ids.tsv alongside its reason.
That catches the two ways a corpus change goes wrong, both of which are otherwise silent. A pattern
can stop matching its own license — which is what happened to nine patterns inherited from
licensecheck when SPDX reworded those licenses after v3.10, so that a file carrying today's
Apache-1.0 or PSF-2.0 text came back unidentified. Or a pattern can be loose enough to claim a
different license's text, which is worse, because it is a confident wrong answer. Changing what
any license text classifies as means changing a line in expected-ids.tsv, which puts the effect
of a corpus change in the diff instead of in a test summary.
Fifteen SPDX licenses have no pattern: the conversion of their template could not match even their
own canonical text, so it was dropped rather than shipped as dead weight. python -m tools.refresh_spdx retries them on every run. Eleven more are excluded deliberately, because SPDX
distinguishes them by something the license text does not state — see data/excluded.txt.
Prior art and inspiration
This project would not exist without the work below. Credit where it is due:
- google/licensecheck (BSD-3-Clause) — the direct
ancestor.
licenseclassifieris a port of its license-identification algorithm, and it vendors its LRE license-pattern corpus. If you are working in Go, use licensecheck; this project exists so that Python callers don't have to shell out to it or bind to it through cgo. Not affiliated with or endorsed by Google or the Go Authors. - The SPDX License List (data dedicated to the public domain under CC0-1.0) — the underlying source of the license identifiers, and of the matching templates both licensecheck's patterns and this project's generated ones were derived from.
- google/licenseclassifier (Apache-2.0) — a separate Go project that shares this project's name but no code or data. Worth knowing about if you got here by searching for the name.
- licensee (MIT) — GitHub's Ruby license detector, the thing that puts the license label on a repository page.
- askalono (Apache-2.0) — a Rust detector taking a different approach, based on text similarity rather than pattern matching.
- scancode-toolkit (code Apache-2.0, data CC-BY-4.0) — the most thorough license and origin scanner in the Python ecosystem, and much broader in scope than this library. If you need full provenance scanning rather than "what is this license text", use ScanCode.
- go-license-detector (Apache-2.0) — another well-known detector in the Go ecosystem.
A note on how this was written
This library was written by a large language model. The port from Go to Python — the tokenizer, the LRE parser, the NFA/DFA matcher, the cover layer — was LLM-generated, then verified against the reference implementation's own test corpus rather than by line-by-line human review.
We think that verification is what makes it trustworthy, and the parity result is the evidence. But you should know how the code came to be, so you can calibrate accordingly: read it before you depend on it for anything where a wrong answer is expensive, and please report anything that looks off.
Contributing
Issues and pull requests are welcome. The test suite is pytest, and it runs against every supported interpreter:
uvx nox # the whole matrix: 3.10 through the 3.15 prerelease
uvx nox -s tests-3.10 # one version
uvx nox -- -k artifact # arguments after -- go to pytest
nox provides the version matrix — neither pytest nor uv has one built in. uv provides the
interpreters, so a version you do not have installed is downloaded on first use, prereleases
included; there is nothing to set up by hand.
Each interpreter writes coverage to .coverage.<version>, and a final session merges them into one
report — a branch only reachable on one version would otherwise look uncovered. The suite is at
100% line and branch coverage and CI enforces that; the handful of provably unreachable defensive
branches are excluded by name in [tool.coverage.report], with the reasoning recorded there.
For a quick inner loop against your own interpreter:
uv run pytest
The set of supported versions is spelled out in SUPPORTED in noxfile.py, in the CI matrix, in
the release workflow's smoke-test matrix, and in the Python classifiers in pyproject.toml.
tests/test_supported_versions.py fails if those ever disagree, so add a version in all four
places at once.
Releasing
The full runbook is in RELEASING.md — versioning rules, the one-time PyPI Trusted Publisher setup, what CI verifies before it uploads, and what to do when a release goes wrong.
The short version: bump __version__ in src/licenseclassifier/__init__.py (the only place the
version is written), add the matching section to CHANGELOG.md, then tag it.
git tag v2026.7.0 && git push origin v2026.7.0
The library itself has no dependencies; only the test suite does. If you touch anything under
_engine/, regenerate the prebuilt scanner (see above) and include the regenerated artifact in
your PR.
License
licenseclassifier is released under the BSD 3-Clause License. See LICENSE.
The license patterns and the algorithm are derived from google/licensecheck, which is also
BSD-3-Clause, Copyright (c) 2019 The Go Authors. Its license text is preserved verbatim at
third_party/licensecheck/LICENSE, and the derivation is
described in NOTICE. The whole distribution is therefore uniformly BSD-3-Clause; only the
copyright holders differ between parts.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file licenseclassifier-2026.8.0.tar.gz.
File metadata
- Download URL: licenseclassifier-2026.8.0.tar.gz
- Upload date:
- Size: 3.6 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ecd0015677b3d6cd4f3b1475a50acca57e63e2366af01c3b291a06e76b7f0d8c
|
|
| MD5 |
6361dad00c31d263e991ab426c345486
|
|
| BLAKE2b-256 |
ec82598e7cf0feb934d9a07ec427f351a26634a62d5f161090d67c9e9ecefd98
|
Provenance
The following attestation bundles were made for licenseclassifier-2026.8.0.tar.gz:
Publisher:
release.yml on Fencer-Security/licenseclassifier
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
licenseclassifier-2026.8.0.tar.gz -
Subject digest:
ecd0015677b3d6cd4f3b1475a50acca57e63e2366af01c3b291a06e76b7f0d8c - Sigstore transparency entry: 2498590354
- Sigstore integration time:
-
Permalink:
Fencer-Security/licenseclassifier@6a4207b420a9b2aa18f2d4d353c6545068ab6491 -
Branch / Tag:
refs/tags/v2026.8.0 - Owner: https://github.com/Fencer-Security
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@6a4207b420a9b2aa18f2d4d353c6545068ab6491 -
Trigger Event:
push
-
Statement type:
File details
Details for the file licenseclassifier-2026.8.0-py3-none-any.whl.
File metadata
- Download URL: licenseclassifier-2026.8.0-py3-none-any.whl
- Upload date:
- Size: 1.6 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5d40743ab7b748935b29e13c08cfca4d0631f6b91b355041e1b914278636cffe
|
|
| MD5 |
6d268eeea14fb79209e23e8ac95a1f3c
|
|
| BLAKE2b-256 |
b34f8e4061dddd291960a2edc27f492a9b0d48169e981f09ea6e5aec74d30573
|
Provenance
The following attestation bundles were made for licenseclassifier-2026.8.0-py3-none-any.whl:
Publisher:
release.yml on Fencer-Security/licenseclassifier
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
licenseclassifier-2026.8.0-py3-none-any.whl -
Subject digest:
5d40743ab7b748935b29e13c08cfca4d0631f6b91b355041e1b914278636cffe - Sigstore transparency entry: 2498590357
- Sigstore integration time:
-
Permalink:
Fencer-Security/licenseclassifier@6a4207b420a9b2aa18f2d4d353c6545068ab6491 -
Branch / Tag:
refs/tags/v2026.8.0 - Owner: https://github.com/Fencer-Security
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@6a4207b420a9b2aa18f2d4d353c6545068ab6491 -
Trigger Event:
push
-
Statement type: