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
29 changes: 29 additions & 0 deletions .github/workflows/license-headers.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Copyright 2026 The PyAthena authors
#
# Licensed under the MIT License.
# See LICENSE or https://opensource.org/licenses/MIT.
#
# SPDX-License-Identifier: MIT

name: License Headers

on:
pull_request:

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true
- uses: taiki-e/install-action@7a79fe8c3a13344501c80d99cae481c1c9085912 # v2.81.10
with:
tool: just
- run: just license-headers
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ Edit the canonical files when maintaining these skills so both agents use the sa

```bash
just format # Auto-fix formatting and imports
just lint # Python lint, format check, mypy, and CloudFormation validation
just lint # Python lint, format check, mypy, CloudFormation validation, and license headers
```

### Testing
Expand Down
5 changes: 4 additions & 1 deletion docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,11 +118,14 @@ For a new Python file first published in 2026, use:
# SPDX-License-Identifier: MIT
```

Place the header near the start of the file, before imports or module documentation, while keeping required shebangs, encoding declarations, and front matter in their required positions.
Place the header at the start of the file, before imports or module documentation, while keeping required shebangs, encoding declarations, and front matter in their required positions.
Use the equivalent comment syntax for other formats, such as an HTML comment in Markdown.
For files with YAML front matter, the header may be written as YAML comments inside that front matter.
Use this placement for GitHub issue templates so the notice belongs to the template metadata rather than the issue body.
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.
`just lint` and the License Headers workflow check the header with `scripts/check_license_headers.py`.
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.
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.

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.

Expand Down
8 changes: 6 additions & 2 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,17 @@ format:
uvx ruff@{{RUFF_VERSION}} check --select I --fix .
uvx ruff@{{RUFF_VERSION}} format .

# Lint, format check, mypy, and CloudFormation validation
lint:
# Lint, format check, mypy, CloudFormation validation, and license headers
lint: license-headers
uvx ruff@{{RUFF_VERSION}} check .
uvx ruff@{{RUFF_VERSION}} format --check .
uv run mypy .
uv run cfn-lint cloudformation/*.yaml

# Check license headers
license-headers:
uv run python scripts/check_license_headers.py

# Run tests: just test (pyathena|sqla|sqla-async)
test target="help":
@just _test-{{ if target =~ "^-" { "help" } else { target } }}
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ dev = [
"sphinx-design",
"types-python-dateutil",
"cfn-lint>=1",
"tomli>=2.0.0; python_version<'3.11'",
]

[build-system]
Expand Down
4 changes: 2 additions & 2 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ AWS test infrastructure templates are in [cloudformation/](../cloudformation/).

## Validation

Run the Python and CloudFormation checks, ShellCheck, actionlint, and offline script tests from the repository root:
Run the Python and CloudFormation checks, the license header check, ShellCheck, actionlint, and offline script tests from the repository root:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Self-review round two (claims and operational behavior): FINDINGS, repaired in eaaab65

Base 82e88f4ebbd89a4dad8b3b57ac412012601f0c45, head f6db31e0f953a7ae178113e4348b5c3d65d6f95b.

Claims checked:

  • The Test workflow skips fork PRs and docs/**.md-only PRs: confirmed from test-suite.yaml (if: on the head repo) and test.yaml (paths-ignore).
  • 77 listed files: confirmed with len(UNHEADED_FILES).
  • 39 new tests and 68 script tests: confirmed from the pytest output.
  • The contributing guide's placement wording matches the checker.

Findings and repairs:

  1. This README said all script tests use botocore stubs and did not mention the new check. Both are reworded here.
  2. The PR body said the tox runs execute just lint. Only the pyathena env does (just test pyathena depends on lint; sqla does not). The PR body is corrected.
  3. Operational effect: locally, untracked, non-ignored files are now checked by just lint. This is stated in the PR body.
  4. A new format with a different comment syntax needs a HEADER_BLOCKS entry. A comment was added above HEADER_BLOCKS.

Revalidated at eaaab65 with just scripts (68 passed) and markdownlint.


```bash
mise install shellcheck actionlint
Expand All @@ -24,4 +24,4 @@ just scripts

ShellCheck is pinned in `.mise.toml` and checks `scripts/*.sh`.
The pinned actionlint checks GitHub Actions workflows, including embedded shell commands.
The script tests under `scripts/tests/` use botocore stubs and require no AWS credentials.
The script tests under `scripts/tests/` require no AWS credentials; the database sweep tests use botocore stubs.
202 changes: 202 additions & 0 deletions scripts/check_license_headers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
# Copyright 2026 The PyAthena authors
#
# Licensed under the MIT License.
# See LICENSE or https://opensource.org/licenses/MIT.
#
# SPDX-License-Identifier: MIT

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

# Usage: just license-headers (also run by just lint)
#
# Checks tracked and untracked, non-ignored files in the working tree against
# the header described in docs/contributing.md, with the exemptions in
# scripts/config/license_headers.toml. Reports missing headers and stale
# unheaded-files entries; never modifies files.

import os
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path

if sys.version_info >= (3, 11):
import tomllib
else:
import tomli as tomllib

HEADER_LINES = (
r"Copyright \d{4} The PyAthena authors",
"",
re.escape("Licensed under the MIT License."),
re.escape("See LICENSE or https://opensource.org/licenses/MIT."),
"",
re.escape("SPDX-License-Identifier: MIT"),
)


def _block(opening: str | None, prefix: str, closing: str | None) -> re.Pattern[str]:
blank = re.escape(prefix.rstrip())
lines = [f"{re.escape(prefix)}{line}" if line else blank for line in HEADER_LINES]
if opening is not None:
lines.insert(0, re.escape(opening))
if closing is not None:
lines.append(re.escape(closing))
return re.compile("\n".join(lines) + "(\n|$)")


HASH = (_block(None, "# ", None),)
HTML = _block("<!--", "", "-->")
JINJA = _block("{#", "", "-#}")

# Comment syntaxes by file suffix; other files use HASH. Add an entry for a new
# file format with another comment syntax.
SUFFIX_BLOCKS = {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Independent review (relayed): FINDINGS, repaired in 4bbc563

  • Reviewer: OpenAI Codex CLI 0.156.0 (codex exec --sandbox read-only, reasoning effort high), session 01a0d0de-9035-71d3-a719-00186dc07d5e.
  • Scope: static review of 82e88f4ebbd89a4dad8b3b57ac412012601f0c45..eaaab658ee265a4f92084ca0c9310b8421c141e8 in a detached snapshot, without the PR number, description, or prior findings. The reviewer confirmed that the 77-entry UNHEADED_FILES baseline matches the current non-exempt unheaded files.

Findings and dispositions:

  1. There was no permitted path for a new third-party/generated file that cannot carry the header. Fixed: the UNHEADED_FILES comment and docs/contributing.md now say an entry is added only as agreed in the issue.
  2. Header syntax was accepted regardless of format, so six # lines in Markdown passed while rendering as headings. Fixed: SUFFIX_BLOCKS selects each format's syntax, and tests reject # in .md, # in .rst, and HTML in .py.
  3. A YAML-comment header after keys inside front matter was rejected, contrary to docs/contributing.md. Fixed: _front_matter_header accepts it anywhere inside the front matter, or in the file's syntax directly after the closing ---.
  4. ASCII-only binary formats (for example PDF) weren't detected as binary. Rejected: no such file exists; a new binary format gets its suffix added when the file is proposed.
  5. stdout.strip() broke repository paths ending in whitespace, and strict UTF-8 decoding broke non-UTF-8 file names. Fixed: rstrip("\n") and os.fsdecode, with a test for a non-ASCII name with a trailing space.

Both self-review perspectives were applied to the repair:

  • just scripts: 78 passed.
  • The checker exits 0 on the tree.
  • The PR body is updated.

An independent follow-up review of eaaab65..4bbc563 is pending.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Independent follow-up reviews (relayed)

Follow-up 1 — Codex CLI 0.156.0, read-only, effort high, session 01a0d0eb-0ee8-7101-b9a8-7a2d1c58dd73. Static review of eaaab65..4bbc563 at the snapshot 4bbc563ed4526a249e435c41d910d61233e7d59e. Result: FINDINGS, all fixed in fa99986594d7f00fbc82a7738880f1e171c5fc8d.

  1. .html accepted only Jinja comments. .html now also accepts HTML comments.
  2. In multi-document YAML, the second --- was treated as the end of front matter, so a header only in a later document passed. Front-matter handling is now limited to .md; other files accept the header only directly after a leading document marker. Tests were added.
  3. The root path was decoded as text with rstrip("\n"). It is now decoded from bytes with removesuffix(b"\n") and os.fsdecode.
  4. docs/contributing.md conflicted with the automatic exemptions. The guide now names the exemptions and says only non-exempt unheaded files are listed.

Repair validation: just scripts (83 passed), the checker exits 0 on the tree, and markdownlint passes. The PR body is updated.

Follow-up 2 — same reviewer configuration, session 01a0d0f4-6a44-7d81-aeb4-2440328fc7ae, static review of 4bbc563..fa99986 at fa99986594d7f00fbc82a7738880f1e171c5fc8d. Result: CLEAN. All four findings are resolved, with no regression for current-tree formats or tests, and the docs match the behavior.

The two non-blocking suggestions are deferred:

  • A test for repository roots containing a newline or non-UTF-8 bytes (the fix is one expression, and such roots are impractical).
  • Stating that Markdown front matter needs a closing --- (front matter without one isn't front matter).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Maintainer-requested change and independent follow-up 3 (relayed)

At the maintainer's request, 9315fb63f05c74431c5fa793176fe8cb80d6bd47:

  • moves the exempt suffixes and the unheaded file list to scripts/config/license_headers.toml, read with tomllib (tomli on Python 3.10, now an explicit dev dependency);
  • runs the checker through a just license-headers recipe that lint depends on. The License Headers workflow now sets up uv and just and runs the recipe.

Validation:

  • just scripts: 88 passed.
  • Python 3.10: the checker exits 0 and the 59 checker tests pass.
  • Injected failures report the new messages.

Both self-review perspectives were applied, and the PR body and docs/contributing.md are updated.

Follow-up 3 — Codex CLI 0.156.0, read-only, effort high, session 01a0d374-3fd9-7531-8f64-dfd46ccebff3. Static review of fa99986..9315fb6 at 9315fb6. Result: CLEAN.

  • The migrated entries and suffixes match the prior constants.
  • Invalid, malformed, or missing config fails explicitly.
  • The Python 3.10 path is covered by the marked tomli dependency.
  • just lint reaches the checker first.
  • The workflow actions are SHA-pinned with read-only permissions.

Non-blocking suggestion, deferred: pin an interpreter in the License Headers workflow. uv currently selects the runner's Python, which satisfies requires-python, and uv run installs the default dev dependencies.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

CI repair and independent follow-up 4 (relayed)

  • CI failure at 9315fb6: the Benchmark tooling offline job failed in uv sync --project benchmarks --locked. benchmarks/uv.lock also records the root project's dev group, so it needed the new tomli entry.
  • Repair: 1e9787a7210c455891013e3e182789c2b5f6e0e1 regenerates it with just benchmark lock. just benchmark test passes (80 passed, 1 skipped), and the Benchmark tooling workflow now passes.

Follow-up 4 — Codex CLI 0.156.0, read-only, effort high, session 01a0d377-d150-79f3-9475-f7e15b1259ee. Static review of 9315fb6..1e9787a. Result: CLEAN.

  • The change adds only the root dev dependency's metadata entry, matching pyproject.toml and the root lock.
  • The benchmark project targets Python 3.12, so no resolved tomli package entry is needed.
  • No other lock file, workflow, or recipe needs a matching change.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Rebase onto master after #811 and #813

026587bc98289fdd66ade30d40104102c780066f, rebased from 1e9787a.

git range-diff 82e88f4..1e9787a origin/master..026587b:

Upstream effects checked:

Validation:

  • uv lock --check passes.
  • just license-headers exits 0 on the rebased tree.
  • just scripts: 88 passed.
  • just benchmark test: 80 passed, 1 skipped.

No new independent review: the patches are unchanged.

".css": (_block("/*", " * ", " */"),),
".html": (JINJA, HTML),
".jinja2": (JINJA,),
".jsonc": (_block(None, "// ", None),),
".md": (HTML,),
".rst": (_block("..", " ", None),),
}

SHEBANG = re.compile(r"#!.*\n")
ENCODING = re.compile(r"#.*coding[:=][ \t]*[-\w.]+.*\n")
# YAML front matter in Markdown files; elsewhere, a leading YAML document marker.
FRONT_MATTER = "---\n"
FRONT_MATTER_END = re.compile(r"\n---[ \t]*(\n|$)")

CONFIG = "scripts/config/license_headers.toml"


@dataclass(frozen=True)
class Config:
"""Exemptions read from CONFIG."""

exempt_suffixes: frozenset[str]
unheaded_files: frozenset[str]


def load_config(file: Path) -> Config:
"""Read the exemptions, rejecting unknown keys and duplicate entries."""
with file.open("rb") as f:
data = tomllib.load(f)
keys = {"exempt-suffixes", "unheaded-files"}
if set(data) != keys:
raise ValueError(f"{file}: expected keys {sorted(keys)}, found {sorted(data)}")
values = {}
for key in sorted(keys):
items = data[key]
if not isinstance(items, list) or not all(isinstance(item, str) for item in items):
raise ValueError(f"{file}: {key} must be a list of strings")
if len(set(items)) != len(items):
raise ValueError(f"{file}: {key} has duplicate entries")
values[key] = frozenset(items)
return Config(values["exempt-suffixes"], values["unheaded-files"])


def _matches(blocks: tuple[re.Pattern[str], ...], text: str, pos: int, end: int) -> bool:
return any((match := block.match(text, pos)) and match.end() <= end for block in blocks)


def _front_matter_header(text: str, suffix: str, blocks: tuple[re.Pattern[str], ...]) -> bool:
if not text.startswith(FRONT_MATTER):
return False
pos = len(FRONT_MATTER)
if suffix != ".md":
return _matches(HASH, text, pos, len(text))
if not (end := FRONT_MATTER_END.search(text, pos - 1)):
return False
while pos <= end.start():
if _matches(HASH, text, pos, end.start() + 1):
return True
pos = text.index("\n", pos) + 1
return _matches(blocks, text, end.end(), len(text))


def has_license_header(text: str, suffix: str) -> bool:
"""Return whether the header starts a file with the given suffix.

The header may follow a shebang, an encoding declaration, or a leading YAML
document marker. In a Markdown file with YAML front matter, it may be
written as YAML comments inside the front matter or follow it.
"""
pos = 0
if match := SHEBANG.match(text, pos):
pos = match.end()
if match := ENCODING.match(text, pos):
pos = match.end()
blocks = SUFFIX_BLOCKS.get(suffix, HASH)
return _matches(blocks, text, pos, len(text)) or _front_matter_header(text, suffix, blocks)


def exemption_reason(root: Path, path: str, exempt_suffixes: frozenset[str]) -> str | None:
"""Return why a file needs no header, or None when it needs one."""
file = root / path
if file.is_symlink():
return "symbolic link"
if Path(path).suffix in exempt_suffixes:
return "data or generated file"
data = file.read_bytes()
if b"\0" in data:
return "binary file"
try:
text = data.decode("utf-8")
except UnicodeDecodeError:
return "binary file"
if not text.strip():
return "empty file"
return None


def check(root: Path, paths: list[str], config: Config) -> list[str]:
"""Return problems for the given repository-relative paths."""
problems = []
existing = {path for path in paths if (root / path).is_symlink() or (root / path).is_file()}
for path in sorted(existing):
file = root / path
reason = exemption_reason(root, path, config.exempt_suffixes)
headed = reason is None and has_license_header(
file.read_text(encoding="utf-8"), Path(path).suffix
)
if path in config.unheaded_files:
if reason is not None:
problems.append(f"{path}: listed as unheaded in {CONFIG} but exempt as {reason}")
elif headed:
problems.append(f"{path}: listed as unheaded in {CONFIG} but has the header")
elif reason is None and not headed:
problems.append(f"{path}: missing license header")
problems.extend(
f"{path}: listed as unheaded in {CONFIG} but not found"
for path in sorted(config.unheaded_files - existing)
)
return problems


def repository_files(root: Path) -> list[str]:
"""Return tracked and untracked, non-ignored files."""
output = subprocess.run(
["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"],
cwd=root,
check=True,
capture_output=True,
).stdout
return [path for path in os.fsdecode(output).split("\0") if path]


def main() -> int:
output = subprocess.run(
["git", "rev-parse", "--show-toplevel"], check=True, capture_output=True
).stdout
root = Path(os.fsdecode(output.removesuffix(b"\n")))
problems = check(root, repository_files(root), load_config(root / CONFIG))
if not problems:
return 0
sys.stderr.write("".join(f"{problem}\n" for problem in problems))
sys.stderr.write("See docs/contributing.md for the header of new original files.\n")
return 1


if __name__ == "__main__":
sys.exit(main())
96 changes: 96 additions & 0 deletions scripts/config/license_headers.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# Copyright 2026 The PyAthena authors
#
# Licensed under the MIT License.
# See LICENSE or https://opensource.org/licenses/MIT.
#
# SPDX-License-Identifier: MIT

# Configuration for scripts/check_license_headers.py.

# Formats without comment syntax or with generated content.
exempt-suffixes = [".csv", ".gz", ".json", ".lock", ".png", ".tsv"]

# Existing files without the header, classified in
# https://github.com/pyathena-dev/PyAthena/issues/790 and described in NOTICE.
# New files carry the header; add an entry only as agreed in the issue that
# proposes the file, and remove entries whose files gain the header or are
# deleted.
unheaded-files = [
".github/PULL_REQUEST_TEMPLATE.md",
"LICENSE",
"NOTICE",
"cloudformation/github_actions_oidc.yaml",
"docs/aio.md",
"docs/arrow.md",
"docs/conf.py",
"docs/cursor.md",
"docs/pandas.md",
"docs/polars.md",
"docs/s3fs.md",
"docs/sqlalchemy.md",
"docs/usage.md",
"pyathena/__init__.py",
"pyathena/aio/arrow/cursor.py",
"pyathena/aio/common.py",
"pyathena/aio/pandas/cursor.py",
"pyathena/aio/polars/cursor.py",
"pyathena/aio/result_set.py",
"pyathena/arrow/async_cursor.py",
"pyathena/arrow/converter.py",
"pyathena/arrow/cursor.py",
"pyathena/arrow/result_set.py",
"pyathena/arrow/util.py",
"pyathena/async_cursor.py",
"pyathena/common.py",
"pyathena/connection.py",
"pyathena/converter.py",
"pyathena/cursor.py",
"pyathena/filesystem/s3.py",
"pyathena/filesystem/s3_object.py",
"pyathena/formatter.py",
"pyathena/model.py",
"pyathena/pandas/__init__.py",
"pyathena/pandas/async_cursor.py",
"pyathena/pandas/converter.py",
"pyathena/pandas/cursor.py",
"pyathena/pandas/result_set.py",
"pyathena/pandas/util.py",
"pyathena/parser.py",
"pyathena/polars/__init__.py",
"pyathena/polars/async_cursor.py",
"pyathena/polars/cursor.py",
"pyathena/result_set.py",
"pyathena/s3fs/async_cursor.py",
"pyathena/s3fs/cursor.py",
"pyathena/sqlalchemy/array.py",
"pyathena/sqlalchemy/base.py",
"pyathena/sqlalchemy/compiler.py",
"pyathena/sqlalchemy/constants.py",
"pyathena/sqlalchemy/temporal.py",
"pyathena/sqlalchemy/types.py",
"pyathena/util.py",
"pyproject.toml",
"tests/__init__.py",
"tests/pyathena/aio/sqlalchemy/test_base.py",
"tests/pyathena/aio/test_cursor.py",
"tests/pyathena/arrow/test_async_cursor.py",
"tests/pyathena/conftest.py",
"tests/pyathena/filesystem/test_s3.py",
"tests/pyathena/filesystem/test_s3_async.py",
"tests/pyathena/pandas/test_async_cursor.py",
"tests/pyathena/pandas/test_cursor.py",
"tests/pyathena/pandas/test_util.py",
"tests/pyathena/polars/test_async_cursor.py",
"tests/pyathena/s3fs/test_cursor.py",
"tests/pyathena/sqlalchemy/test_array.py",
"tests/pyathena/sqlalchemy/test_base.py",
"tests/pyathena/sqlalchemy/test_temporal.py",
"tests/pyathena/sqlalchemy/test_types.py",
"tests/pyathena/test_async_cursor.py",
"tests/pyathena/test_converter.py",
"tests/pyathena/test_cursor.py",
"tests/pyathena/test_model.py",
"tests/pyathena/test_util.py",
"tests/resources/queries/create_table.sql.jinja2",
"tests/sqlalchemy/test_suite.py",
]
Loading
Loading