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
88 changes: 88 additions & 0 deletions .github/workflows/pypi-license-header.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: CI

on:
push:
branches:
- main
paths:
- "pypi-packages/license-header/**"
- ".github/workflows/pypi-license-header.yaml"
pull_request:
paths:
- "pypi-packages/license-header/**"
- ".github/workflows/pypi-license-header.yaml"
workflow_dispatch:

permissions:
contents: read

defaults:
run:
working-directory: pypi-packages/license-header

jobs:
test:
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
runner:
- ubuntu-24.04
- macos-15
- windows-2025

python-version:
- "3.10"
- "3.11"
- "3.12"
- "3.13"
- "3.14"
- "3.14t"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Install uv with Python ${{ matrix.python-version }}
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: "0.11.23"
python-version: ${{ matrix.python-version }}

- run: uv sync --locked

- run: uv run poe test

lint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: "0.11.23"
python-version: "3.10"

- run: uv sync --locked

- run: uv run poe lint

release:
runs-on: ubuntu-24.04
needs: [test, lint]
environment: ${{ github.event_name != 'pull_request' && 'pypi-license-header' || null }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: "0.11.23"
python-version: "3.10"

- run: uv sync --frozen

- run: uv build

- name: publish
if: github.event_name != 'pull_request'
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@
*.svg
cover.out
node_modules
__pycache__
.venv
.ruff_cache
.pytest_cache
1 change: 1 addition & 0 deletions pypi-packages/license-header/LICENSE
28 changes: 28 additions & 0 deletions pypi-packages/license-header/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# license-header

A tool to add and update license headers in source code.

## Usage

Add `license-header` as a dev dependency.

```shellsession
uv add --dev license-header
```

Configure your license settings in `pyproject.toml`.

```toml
[tool.licenseheader]
copyright-holder = "Buf Technologies, Inc."
year-range = "2025-2026"
```

and run!

```shellsession
uv run license-header
```

This will add license headers to any files missing them. To fail on missing headers instead,
run with `--check`.
76 changes: 76 additions & 0 deletions pypi-packages/license-header/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
[project]
name = "license-header"
version = "0.0.1"
description = "Handle license headers"
requires-python = ">=3.10"
license = "Apache-2.0"
license-files = ["LICENSE"]
dependencies = ["tomli==2.4.1"]

[project.scripts]
license-header = "license_header:main"

[dependency-groups]
dev = [
"poethepoet==0.46.0",
"pytest==9.1.1",
"pytest-cov==7.1.0",
"ruff==0.15.18",
"tombi==1.1.4",
"ty==0.0.51",
]

[build-system]
requires = ["uv_build>=0.11.2,<0.12"]
build-backend = "uv_build"

[tool.licenseheader]
copyright-holder = "Buf Technologies, Inc."
year-range = "2025-2026"

## Format
[tool.poe.tasks.format]
help = "Apply all autoformatting"
sequence = ["format-license-header", "format-python", "format-toml"]

[tool.poe.tasks.format-python]
help = "Format Python files with ruff"
sequence = [
{ cmd = "ruff check --fix --unsafe-fixes --exit-zero ." },
{ cmd = "ruff format" }
]

[tool.poe.tasks.format-license-header]
help = "Add/update license headers in source files"
cmd = "license-header"

[tool.poe.tasks.format-toml]
help = "Format TOML files with tombi"
cmd = "tombi format"

[tool.poe.tasks.lint]
help = "Apply all lints"
sequence = ["lint-license-header", "lint-python", "lint-toml"]

[tool.poe.tasks.lint-license-header]
help = "Lint license headers"
cmd = "license-header --check"

[tool.poe.tasks.lint-python]
help = "Lint Python files"
sequence = [
{ cmd = "ruff check ." },
{ cmd = "ruff format --check ." },
{ cmd = "ty check" },
]

[tool.poe.tasks.lint-toml]
help = "Lint TOML files"
cmd = "tombi lint"

[tool.poe.tasks.test]
help = "Run tests"
cmd = "pytest"

[tool.pytest]
filterwarnings = ["error"]
20 changes: 20 additions & 0 deletions pypi-packages/license-header/src/license_header/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright (c) 2025-2026 Buf Technologies, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""A tool to add and check license headers in Python files."""

from __future__ import annotations

from license_header._api import apply_license_headers, check_license_headers, main

__all__ = ["apply_license_headers", "check_license_headers", "main"]
20 changes: 20 additions & 0 deletions pypi-packages/license-header/src/license_header/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright (c) 2025-2026 Buf Technologies, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Entry point for python -m license_header."""

from __future__ import annotations

from license_header._api import main

main()
112 changes: 112 additions & 0 deletions pypi-packages/license-header/src/license_header/_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Copyright (c) 2025-2026 Buf Technologies, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Public API and CLI entry point."""

from __future__ import annotations

import argparse
import sys
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from collections.abc import Iterator

from license_header._config import Config, load_config
from license_header._files import list_files
from license_header._header import STYLES, make_header, modify


def _iter_modifications(
dirs: list[Path] | None, config: Config | None
) -> Iterator[tuple[Path, str]]:
"""Yield ``(path, new_content)`` for every file that needs a header update."""
if config is None:
config = load_config()
headers = {
ext: (make_header(config.copyright_holder, config.year_range, style), style)
for ext, style in STYLES.items()
}
for path in list_files(dirs or [], config.ignore_patterns, frozenset(headers)):
header, style = headers[path.suffix]
content = path.read_text(encoding="utf-8")
result = modify(content, header, style)
if result != content:
yield path, result


def apply_license_headers(
dirs: list[Path] | None = None, config: Config | None = None
) -> list[Path]:
"""Apply license headers to files.

Args:
dirs: Directories to process. Defaults to the pyproject.toml directory.
config: Configuration. Loaded from pyproject.toml if not provided.

Returns:
List of files that were modified.
"""
modified: list[Path] = []
for path, result in _iter_modifications(dirs, config):
path.write_text(result, encoding="utf-8")
modified.append(path)
return modified


def check_license_headers(
dirs: list[Path] | None = None, config: Config | None = None
) -> list[Path]:
"""Check that files have correct license headers.

Args:
dirs: Directories to process. Defaults to the pyproject.toml directory.
config: Configuration. Loaded from pyproject.toml if not provided.

Returns:
List of files with missing or incorrect headers.
"""
return [path for path, _ in _iter_modifications(dirs, config)]


def main() -> None:
"""CLI entry point."""
parser = argparse.ArgumentParser(
prog="license-header",
description="Add or check license headers in source files.",
)
parser.add_argument(
"dirs",
nargs="*",
type=Path,
help="Directories to process (default: project root)",
)
parser.add_argument(
"--check", action="store_true", help="Check mode: exit 1 if headers are missing"
)
args = parser.parse_args()

dirs: list[Path] = args.dirs or []

if args.check:
missing = check_license_headers(dirs=dirs)
if missing:
sys.stderr.write(f"missing license header in {len(missing)} files:\n")
for path in missing:
sys.stderr.write(f" {path}\n")
sys.exit(1)
sys.exit(0)

modified = apply_license_headers(dirs=dirs)
print(f"updated {len(modified)} license headers.") # noqa: T201
Loading
Loading