Skip to content

Commit 026587b

Browse files
Move license header exemptions to a TOML config and run the check via just
Read the exempt suffixes and the files without the header from scripts/config/license_headers.toml, rejecting unknown keys, non-string lists, and duplicate entries. Python 3.10 reads it with tomli, now an explicit dev dependency. Add a license-headers recipe, run by lint and by the License Headers workflow, so that both use the project's Python through uv. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 42a55c0 commit 026587b

8 files changed

Lines changed: 199 additions & 121 deletions

File tree

‎.github/workflows/license-headers.yaml‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,10 @@ jobs:
2020
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
2121
with:
2222
persist-credentials: false
23-
- run: python3 scripts/check_license_headers.py
23+
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
24+
with:
25+
enable-cache: true
26+
- uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2.81.10
27+
with:
28+
tool: just
29+
- run: just license-headers

‎docs/contributing.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ For files with YAML front matter, the header may be written as YAML comments ins
124124
Use this placement for GitHub issue templates so the notice belongs to the template metadata rather than the issue body.
125125
For files that cannot contain comments, and for generated build artifacts or third-party assets, agree on an appropriate attribution location in the issue rather than inserting an invalid header.
126126
`just lint` and the License Headers workflow check the header with `scripts/check_license_headers.py`.
127-
The checker exempts symbolic links and empty, binary, data, and lock files, and lists the other files without the header.
127+
The checker exempts symbolic links and empty and binary files, and `scripts/config/license_headers.toml` lists the exempt file suffixes, such as data and lock files, and the other files without the header.
128128
A new file that is not exempt and has no header, such as third-party material, is added to that list as agreed in the issue.
129129

130130
The short notice and license reference follow [Google's MIT header example](https://opensource.google/documentation/reference/releasing/licenses#mit-header), with an [SPDX identifier](https://spdx.org/licenses/MIT.html) added.

‎justfile‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,14 @@ format:
2525
uvx ruff@{{RUFF_VERSION}} format .
2626

2727
# Lint, format check, mypy, CloudFormation validation, and license headers
28-
lint:
28+
lint: license-headers
2929
uvx ruff@{{RUFF_VERSION}} check .
3030
uvx ruff@{{RUFF_VERSION}} format --check .
3131
uv run mypy .
3232
uv run cfn-lint cloudformation/*.yaml
33+
34+
# Check license headers
35+
license-headers:
3336
uv run python scripts/check_license_headers.py
3437

3538
# Run tests: just test (pyathena|sqla|sqla-async)

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ dev = [
9191
"sphinx-design",
9292
"types-python-dateutil",
9393
"cfn-lint>=1",
94+
"tomli>=2.0.0; python_version<'3.11'",
9495
]
9596

9697
[build-system]

‎scripts/check_license_headers.py‎

Lines changed: 47 additions & 103 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,25 @@
77

88
"""Check that repository files carry the PyAthena license header."""
99

10-
# Usage (from the repository root):
11-
# uv run python scripts/check_license_headers.py
10+
# Usage: just license-headers (also run by just lint)
1211
#
1312
# Checks tracked and untracked, non-ignored files in the working tree against
14-
# the header described in docs/contributing.md. Reports missing headers and
15-
# stale UNHEADED_FILES entries; never modifies files.
13+
# the header described in docs/contributing.md, with the exemptions in
14+
# scripts/config/license_headers.toml. Reports missing headers and stale
15+
# unheaded-files entries; never modifies files.
1616

1717
import os
1818
import re
1919
import subprocess
2020
import sys
21+
from dataclasses import dataclass
2122
from pathlib import Path
2223

24+
if sys.version_info >= (3, 11):
25+
import tomllib
26+
else:
27+
import tomli as tomllib
28+
2329
HEADER_LINES = (
2430
r"Copyright \d{4} The PyAthena authors",
2531
"",
@@ -61,95 +67,33 @@ def _block(opening: str | None, prefix: str, closing: str | None) -> re.Pattern[
6167
FRONT_MATTER = "---\n"
6268
FRONT_MATTER_END = re.compile(r"\n---[ \t]*(\n|$)")
6369

64-
# Formats without comment syntax or with generated content.
65-
EXEMPT_SUFFIXES = frozenset({".csv", ".gz", ".json", ".lock", ".png", ".tsv"})
66-
67-
# Existing files without the header, classified in
68-
# https://github.com/pyathena-dev/PyAthena/issues/790 and described in NOTICE.
69-
# New files carry the header; add an entry only as agreed in the issue that
70-
# proposes the file, and remove entries whose files gain the header or are
71-
# deleted.
72-
UNHEADED_FILES = frozenset(
73-
{
74-
".github/PULL_REQUEST_TEMPLATE.md",
75-
"LICENSE",
76-
"NOTICE",
77-
"cloudformation/github_actions_oidc.yaml",
78-
"docs/aio.md",
79-
"docs/arrow.md",
80-
"docs/conf.py",
81-
"docs/cursor.md",
82-
"docs/pandas.md",
83-
"docs/polars.md",
84-
"docs/s3fs.md",
85-
"docs/sqlalchemy.md",
86-
"docs/usage.md",
87-
"pyathena/__init__.py",
88-
"pyathena/aio/arrow/cursor.py",
89-
"pyathena/aio/common.py",
90-
"pyathena/aio/pandas/cursor.py",
91-
"pyathena/aio/polars/cursor.py",
92-
"pyathena/aio/result_set.py",
93-
"pyathena/arrow/async_cursor.py",
94-
"pyathena/arrow/converter.py",
95-
"pyathena/arrow/cursor.py",
96-
"pyathena/arrow/result_set.py",
97-
"pyathena/arrow/util.py",
98-
"pyathena/async_cursor.py",
99-
"pyathena/common.py",
100-
"pyathena/connection.py",
101-
"pyathena/converter.py",
102-
"pyathena/cursor.py",
103-
"pyathena/filesystem/s3.py",
104-
"pyathena/filesystem/s3_object.py",
105-
"pyathena/formatter.py",
106-
"pyathena/model.py",
107-
"pyathena/pandas/__init__.py",
108-
"pyathena/pandas/async_cursor.py",
109-
"pyathena/pandas/converter.py",
110-
"pyathena/pandas/cursor.py",
111-
"pyathena/pandas/result_set.py",
112-
"pyathena/pandas/util.py",
113-
"pyathena/parser.py",
114-
"pyathena/polars/__init__.py",
115-
"pyathena/polars/async_cursor.py",
116-
"pyathena/polars/cursor.py",
117-
"pyathena/result_set.py",
118-
"pyathena/s3fs/async_cursor.py",
119-
"pyathena/s3fs/cursor.py",
120-
"pyathena/sqlalchemy/array.py",
121-
"pyathena/sqlalchemy/base.py",
122-
"pyathena/sqlalchemy/compiler.py",
123-
"pyathena/sqlalchemy/constants.py",
124-
"pyathena/sqlalchemy/temporal.py",
125-
"pyathena/sqlalchemy/types.py",
126-
"pyathena/util.py",
127-
"pyproject.toml",
128-
"tests/__init__.py",
129-
"tests/pyathena/aio/sqlalchemy/test_base.py",
130-
"tests/pyathena/aio/test_cursor.py",
131-
"tests/pyathena/arrow/test_async_cursor.py",
132-
"tests/pyathena/conftest.py",
133-
"tests/pyathena/filesystem/test_s3.py",
134-
"tests/pyathena/filesystem/test_s3_async.py",
135-
"tests/pyathena/pandas/test_async_cursor.py",
136-
"tests/pyathena/pandas/test_cursor.py",
137-
"tests/pyathena/pandas/test_util.py",
138-
"tests/pyathena/polars/test_async_cursor.py",
139-
"tests/pyathena/s3fs/test_cursor.py",
140-
"tests/pyathena/sqlalchemy/test_array.py",
141-
"tests/pyathena/sqlalchemy/test_base.py",
142-
"tests/pyathena/sqlalchemy/test_temporal.py",
143-
"tests/pyathena/sqlalchemy/test_types.py",
144-
"tests/pyathena/test_async_cursor.py",
145-
"tests/pyathena/test_converter.py",
146-
"tests/pyathena/test_cursor.py",
147-
"tests/pyathena/test_model.py",
148-
"tests/pyathena/test_util.py",
149-
"tests/resources/queries/create_table.sql.jinja2",
150-
"tests/sqlalchemy/test_suite.py",
151-
}
152-
)
70+
CONFIG = "scripts/config/license_headers.toml"
71+
72+
73+
@dataclass(frozen=True)
74+
class Config:
75+
"""Exemptions read from CONFIG."""
76+
77+
exempt_suffixes: frozenset[str]
78+
unheaded_files: frozenset[str]
79+
80+
81+
def load_config(file: Path) -> Config:
82+
"""Read the exemptions, rejecting unknown keys and duplicate entries."""
83+
with file.open("rb") as f:
84+
data = tomllib.load(f)
85+
keys = {"exempt-suffixes", "unheaded-files"}
86+
if set(data) != keys:
87+
raise ValueError(f"{file}: expected keys {sorted(keys)}, found {sorted(data)}")
88+
values = {}
89+
for key in sorted(keys):
90+
items = data[key]
91+
if not isinstance(items, list) or not all(isinstance(item, str) for item in items):
92+
raise ValueError(f"{file}: {key} must be a list of strings")
93+
if len(set(items)) != len(items):
94+
raise ValueError(f"{file}: {key} has duplicate entries")
95+
values[key] = frozenset(items)
96+
return Config(values["exempt-suffixes"], values["unheaded-files"])
15397

15498

15599
def _matches(blocks: tuple[re.Pattern[str], ...], text: str, pos: int, end: int) -> bool:
@@ -187,12 +131,12 @@ def has_license_header(text: str, suffix: str) -> bool:
187131
return _matches(blocks, text, pos, len(text)) or _front_matter_header(text, suffix, blocks)
188132

189133

190-
def exemption_reason(root: Path, path: str) -> str | None:
134+
def exemption_reason(root: Path, path: str, exempt_suffixes: frozenset[str]) -> str | None:
191135
"""Return why a file needs no header, or None when it needs one."""
192136
file = root / path
193137
if file.is_symlink():
194138
return "symbolic link"
195-
if Path(path).suffix in EXEMPT_SUFFIXES:
139+
if Path(path).suffix in exempt_suffixes:
196140
return "data or generated file"
197141
data = file.read_bytes()
198142
if b"\0" in data:
@@ -206,26 +150,26 @@ def exemption_reason(root: Path, path: str) -> str | None:
206150
return None
207151

208152

209-
def check(root: Path, paths: list[str]) -> list[str]:
153+
def check(root: Path, paths: list[str], config: Config) -> list[str]:
210154
"""Return problems for the given repository-relative paths."""
211155
problems = []
212156
existing = {path for path in paths if (root / path).is_symlink() or (root / path).is_file()}
213157
for path in sorted(existing):
214158
file = root / path
215-
reason = exemption_reason(root, path)
159+
reason = exemption_reason(root, path, config.exempt_suffixes)
216160
headed = reason is None and has_license_header(
217161
file.read_text(encoding="utf-8"), Path(path).suffix
218162
)
219-
if path in UNHEADED_FILES:
163+
if path in config.unheaded_files:
220164
if reason is not None:
221-
problems.append(f"{path}: listed in UNHEADED_FILES but exempt as {reason}")
165+
problems.append(f"{path}: listed as unheaded in {CONFIG} but exempt as {reason}")
222166
elif headed:
223-
problems.append(f"{path}: listed in UNHEADED_FILES but has the header")
167+
problems.append(f"{path}: listed as unheaded in {CONFIG} but has the header")
224168
elif reason is None and not headed:
225169
problems.append(f"{path}: missing license header")
226170
problems.extend(
227-
f"{path}: listed in UNHEADED_FILES but not found"
228-
for path in sorted(UNHEADED_FILES - existing)
171+
f"{path}: listed as unheaded in {CONFIG} but not found"
172+
for path in sorted(config.unheaded_files - existing)
229173
)
230174
return problems
231175

@@ -246,7 +190,7 @@ def main() -> int:
246190
["git", "rev-parse", "--show-toplevel"], check=True, capture_output=True
247191
).stdout
248192
root = Path(os.fsdecode(output.removesuffix(b"\n")))
249-
problems = check(root, repository_files(root))
193+
problems = check(root, repository_files(root), load_config(root / CONFIG))
250194
if not problems:
251195
return 0
252196
sys.stderr.write("".join(f"{problem}\n" for problem in problems))
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# Copyright 2026 The PyAthena authors
2+
#
3+
# Licensed under the MIT License.
4+
# See LICENSE or https://opensource.org/licenses/MIT.
5+
#
6+
# SPDX-License-Identifier: MIT
7+
8+
# Configuration for scripts/check_license_headers.py.
9+
10+
# Formats without comment syntax or with generated content.
11+
exempt-suffixes = [".csv", ".gz", ".json", ".lock", ".png", ".tsv"]
12+
13+
# Existing files without the header, classified in
14+
# https://github.com/pyathena-dev/PyAthena/issues/790 and described in NOTICE.
15+
# New files carry the header; add an entry only as agreed in the issue that
16+
# proposes the file, and remove entries whose files gain the header or are
17+
# deleted.
18+
unheaded-files = [
19+
".github/PULL_REQUEST_TEMPLATE.md",
20+
"LICENSE",
21+
"NOTICE",
22+
"cloudformation/github_actions_oidc.yaml",
23+
"docs/aio.md",
24+
"docs/arrow.md",
25+
"docs/conf.py",
26+
"docs/cursor.md",
27+
"docs/pandas.md",
28+
"docs/polars.md",
29+
"docs/s3fs.md",
30+
"docs/sqlalchemy.md",
31+
"docs/usage.md",
32+
"pyathena/__init__.py",
33+
"pyathena/aio/arrow/cursor.py",
34+
"pyathena/aio/common.py",
35+
"pyathena/aio/pandas/cursor.py",
36+
"pyathena/aio/polars/cursor.py",
37+
"pyathena/aio/result_set.py",
38+
"pyathena/arrow/async_cursor.py",
39+
"pyathena/arrow/converter.py",
40+
"pyathena/arrow/cursor.py",
41+
"pyathena/arrow/result_set.py",
42+
"pyathena/arrow/util.py",
43+
"pyathena/async_cursor.py",
44+
"pyathena/common.py",
45+
"pyathena/connection.py",
46+
"pyathena/converter.py",
47+
"pyathena/cursor.py",
48+
"pyathena/filesystem/s3.py",
49+
"pyathena/filesystem/s3_object.py",
50+
"pyathena/formatter.py",
51+
"pyathena/model.py",
52+
"pyathena/pandas/__init__.py",
53+
"pyathena/pandas/async_cursor.py",
54+
"pyathena/pandas/converter.py",
55+
"pyathena/pandas/cursor.py",
56+
"pyathena/pandas/result_set.py",
57+
"pyathena/pandas/util.py",
58+
"pyathena/parser.py",
59+
"pyathena/polars/__init__.py",
60+
"pyathena/polars/async_cursor.py",
61+
"pyathena/polars/cursor.py",
62+
"pyathena/result_set.py",
63+
"pyathena/s3fs/async_cursor.py",
64+
"pyathena/s3fs/cursor.py",
65+
"pyathena/sqlalchemy/array.py",
66+
"pyathena/sqlalchemy/base.py",
67+
"pyathena/sqlalchemy/compiler.py",
68+
"pyathena/sqlalchemy/constants.py",
69+
"pyathena/sqlalchemy/temporal.py",
70+
"pyathena/sqlalchemy/types.py",
71+
"pyathena/util.py",
72+
"pyproject.toml",
73+
"tests/__init__.py",
74+
"tests/pyathena/aio/sqlalchemy/test_base.py",
75+
"tests/pyathena/aio/test_cursor.py",
76+
"tests/pyathena/arrow/test_async_cursor.py",
77+
"tests/pyathena/conftest.py",
78+
"tests/pyathena/filesystem/test_s3.py",
79+
"tests/pyathena/filesystem/test_s3_async.py",
80+
"tests/pyathena/pandas/test_async_cursor.py",
81+
"tests/pyathena/pandas/test_cursor.py",
82+
"tests/pyathena/pandas/test_util.py",
83+
"tests/pyathena/polars/test_async_cursor.py",
84+
"tests/pyathena/s3fs/test_cursor.py",
85+
"tests/pyathena/sqlalchemy/test_array.py",
86+
"tests/pyathena/sqlalchemy/test_base.py",
87+
"tests/pyathena/sqlalchemy/test_temporal.py",
88+
"tests/pyathena/sqlalchemy/test_types.py",
89+
"tests/pyathena/test_async_cursor.py",
90+
"tests/pyathena/test_converter.py",
91+
"tests/pyathena/test_cursor.py",
92+
"tests/pyathena/test_model.py",
93+
"tests/pyathena/test_util.py",
94+
"tests/resources/queries/create_table.sql.jinja2",
95+
"tests/sqlalchemy/test_suite.py",
96+
]

0 commit comments

Comments
 (0)