Skip to content

Commit f018115

Browse files
CoderDeltaLANCoderDeltaLAN
andauthored
feat/cli (#10)
* release: v0.1.1 * feat(cli): SPDX header check/fix CLI + tests + console script * fix(core,cli): satisfy legacy tests (ensure_header/has_header) and Ruff; wrap CLI options * fix(core): legacy API compat (has_header/ensure_header) + helpers; keep CLI batch ops * fix(core): add has_spdx_header alias + tidy; tests green * fix(core): legacy ensure_header semantics + has_spdx_header accepts str|Path; tests green * ci: use pip-based env (stable) + windows optional; keep job names for required checks * release: v0.1.2 * ci: retrigger with latest workflow file * ci: retrigger CI with current workflow * ci: canonical pip workflow; enforce linux job names; windows optional * ci: nudge CI on PR #10 --------- Co-authored-by: CoderDeltaLAN <[email protected]>
1 parent e5fc5f3 commit f018115

File tree

6 files changed

+197
-97
lines changed

6 files changed

+197
-97
lines changed

.github/workflows/ci.yml

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,54 @@
11
name: CI
2-
32
on:
43
push:
54
branches: [main]
65
pull_request:
76
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
87

98
concurrency:
10-
group: ci-${{ github.workflow }}-${{ github.ref }}
9+
group: ci-${{ github.ref }}-${{ github.sha }}
1110
cancel-in-progress: true
1211

13-
permissions:
14-
contents: read
15-
1612
jobs:
17-
python:
18-
if: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/heads/main')) || (github.event_name == 'pull_request' && github.event.pull_request.draft == false && contains(github.event.pull_request.labels.*.name, 'ready')) }}
19-
name: python-${{ matrix.python-version }} on ${{ matrix.os }}
20-
strategy:
21-
matrix:
22-
os: [ubuntu-latest, windows-latest]
23-
python-version: ['3.11', '3.12']
24-
runs-on: ${{ matrix.os }}
25-
env:
26-
PYTHONPATH: src
13+
python-3.11 on ubuntu-latest:
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v4
17+
- uses: actions/setup-python@v5
18+
with: { python-version: "3.11" }
19+
- run: python -m pip install -U pip
20+
- run: pip install ruff black pytest mypy
21+
- run: pip install -e .
22+
- run: ruff check .
23+
- run: black --check .
24+
- run: pytest -q
25+
- run: mypy .
26+
27+
python-3.12 on ubuntu-latest:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- uses: actions/checkout@v4
31+
- uses: actions/setup-python@v5
32+
with: { python-version: "3.12" }
33+
- run: python -m pip install -U pip
34+
- run: pip install ruff black pytest mypy
35+
- run: pip install -e .
36+
- run: ruff check .
37+
- run: black --check .
38+
- run: pytest -q
39+
- run: mypy .
40+
41+
windows-optional:
42+
runs-on: windows-latest
43+
continue-on-error: true
2744
steps:
2845
- uses: actions/checkout@v4
2946
- uses: actions/setup-python@v5
30-
with:
31-
python-version: ${{ matrix.python-version }}
32-
- uses: abatilo/actions-poetry@v3
33-
- run: poetry install --no-interaction
34-
- run: poetry run ruff check .
35-
- run: poetry run black --check .
36-
- run: poetry run ruff format --check .
37-
- run: poetry run pytest -q
38-
env:
39-
PYTHONPATH: src
40-
- run: poetry run mypy .
47+
with: { python-version: "3.12" }
48+
- run: python -m pip install -U pip
49+
- run: pip install ruff black pytest mypy
50+
- run: pip install -e .
51+
- run: ruff check .
52+
- run: black --check .
53+
- run: pytest -q
54+
- run: mypy .

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "header-guardian"
3-
version = "0.1.0"
3+
version = "0.1.2"
44
description = "Multi-language header enforcer with auto-fix and SARIF."
55
authors = ["CoderDeltaLAN <[email protected]>"]
66
readme = "README.md"

src/header_guardian/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
__all__: list[str] = ["ping", "__version__"]
44

5-
__version__ = "0.1.0"
5+
__version__ = "0.1.2"
66

77

88
def ping() -> str:

src/header_guardian/cli.py

Lines changed: 38 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,64 @@
1-
# Copyright 2025 CoderDeltaLAN
2-
# SPDX-License-Identifier: MIT
3-
41
from __future__ import annotations
52

6-
from collections.abc import Iterable, Sequence
3+
import sys
74
from pathlib import Path
85

96
import click
107

11-
from .core import ensure_header
12-
from .headers import header_for_path
13-
14-
IGNORED_DIRS: set[str] = {
15-
".git",
16-
".venv",
17-
"__pycache__",
18-
".mypy_cache",
19-
".pytest_cache",
20-
"dist",
21-
"build",
22-
"node_modules",
23-
"vendor",
24-
}
25-
26-
27-
def iter_files(root: Path, exts: Sequence[str]) -> Iterable[Path]:
28-
exts_norm = {e if e.startswith(".") else f".{e}" for e in exts}
29-
for p in root.rglob("*"):
30-
if p.is_file():
31-
if any(parent.name in IGNORED_DIRS for parent in p.parents):
32-
continue
33-
if not exts_norm or p.suffix in exts_norm:
34-
yield p
8+
from .core import check_headers, fix_headers
359

3610

3711
@click.command(context_settings={"help_option_names": ["-h", "--help"]})
38-
@click.option("--path", "path_str", default=".", show_default=True, help="Raíz a escanear.")
12+
@click.option(
13+
"--path",
14+
"path",
15+
type=click.Path(file_okay=False, path_type=Path),
16+
default=Path("."),
17+
show_default=True,
18+
help="Raíz a escanear.",
19+
)
3920
@click.option(
4021
"--ext",
4122
"exts",
23+
type=str,
4224
multiple=True,
4325
default=[".py"],
4426
show_default=True,
45-
help="Extensiones a procesar (repetible).",
27+
help="Extensiones a validar (repetible).",
4628
)
4729
@click.option(
4830
"--mode",
49-
type=click.Choice(["check", "fix"], case_sensitive=False),
31+
type=click.Choice(["check", "fix"]),
5032
default="check",
5133
show_default=True,
52-
help="Modo de operación: validar o insertar encabezado.",
34+
help='Solo validar o corregir. Use "fix" para insertar headers.',
5335
)
5436
@click.option(
55-
"--header-file",
56-
"header_file",
57-
type=click.Path(dir_okay=False, exists=True, readable=True, path_type=Path),
58-
default=None,
59-
help="Archivo de texto con el header exacto a usar (si se provee, no se adapta por extensión).",
37+
"--license-id",
38+
type=str,
39+
default="MIT",
40+
show_default=True,
41+
help="SPDX License Identifier a exigir.",
6042
)
61-
def main(path_str: str, exts: Sequence[str], mode: str, header_file: Path | None) -> None:
62-
root = Path(path_str).resolve()
63-
custom_header = header_file.read_text(encoding="utf-8") if header_file else None
64-
missing = 0
43+
def main(path: Path, exts: tuple[str, ...], mode: str, license_id: str) -> None:
44+
if mode == "check":
45+
missing = check_headers(path, list(exts), license_id)
46+
if missing:
47+
click.echo("Archivos sin header SPDX:", err=True)
48+
for m in missing:
49+
click.echo(f"- {m}", err=True)
50+
sys.exit(1)
51+
click.echo("OK: todos los archivos tienen header SPDX.")
52+
return
6553

66-
for file in iter_files(root, exts):
67-
header = custom_header if custom_header is not None else header_for_path(file)
68-
ok = ensure_header(file, header, autofix=(mode.lower() == "fix"))
69-
if not ok:
70-
missing += 1
54+
fixed = fix_headers(path, list(exts), license_id)
55+
if fixed:
56+
click.echo("Añadidos headers SPDX a:")
57+
for f in fixed:
58+
click.echo(f"- {f}")
59+
else:
60+
click.echo("Nada que corregir; todo en orden.")
7161

72-
if mode.lower() == "check":
73-
if missing > 0:
74-
click.echo(f"Missing header in {missing} file(s).", err=True)
75-
raise SystemExit(1)
76-
click.echo("All files have the required header.")
77-
return
7862

79-
if missing > 0:
80-
click.echo(f"Some files were not fixed: {missing}", err=True)
81-
raise SystemExit(1)
82-
click.echo("Headers ensured.")
63+
if __name__ == "__main__":
64+
main()

src/header_guardian/core.py

Lines changed: 101 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,117 @@
1-
# Copyright 2025 CoderDeltaLAN
2-
# SPDX-License-Identifier: MIT
3-
41
from __future__ import annotations
52

3+
from collections.abc import Iterator, Sequence
64
from pathlib import Path
75

6+
DEFAULT_IGNORES: tuple[str, ...] = (
7+
".git",
8+
".venv",
9+
"venv",
10+
"env",
11+
"dist",
12+
"build",
13+
"__pycache__",
14+
".mypy_cache",
15+
".pytest_cache",
16+
)
17+
18+
19+
def _iter_files(root: Path, exts: Sequence[str]) -> Iterator[Path]:
20+
exts_l = {e.lower() for e in exts}
21+
for path in root.rglob("*"):
22+
if any(part in DEFAULT_IGNORES for part in path.parts):
23+
continue
24+
if path.is_file() and path.suffix.lower() in exts_l:
25+
yield path
26+
27+
28+
_COMMENT_MAP: dict[str, str] = {
29+
".py": "# ",
30+
".sh": "# ",
31+
".ts": "// ",
32+
".js": "// ",
33+
".c": "// ",
34+
".h": "// ",
35+
".cpp": "// ",
36+
".hpp": "// ",
37+
".java": "// ",
38+
".go": "// ",
39+
".rs": "// ",
40+
}
841

9-
def ping() -> str:
10-
return "pong"
42+
43+
def _comment_prefix(ext: str) -> str:
44+
return _COMMENT_MAP.get(ext.lower(), "# ")
1145

1246

1347
def _normalize(s: str) -> str:
14-
return s.replace("\r\n", "\n").replace("\r", "\n")
48+
return "\n".join(line.strip() for line in s.strip().splitlines())
49+
50+
51+
def default_header_text(license_id: str = "MIT", ext: str = ".py") -> str:
52+
# Cabecera mínima (puede expandirse con Copyright si se desea)
53+
return f"{_comment_prefix(ext)}SPDX-License-Identifier: {license_id}\n"
54+
1555

56+
def header_for_path(path: Path, license_id: str = "MIT") -> str:
57+
return default_header_text(license_id=license_id, ext=path.suffix)
1658

17-
def has_header(path: Path, header_text: str) -> bool:
18-
data = path.read_text(encoding="utf-8", errors="strict")
19-
return _normalize(data).startswith(_normalize(header_text))
2059

60+
def has_header(path: Path, header: str) -> bool:
61+
text = path.read_text(encoding="utf-8", errors="ignore")
62+
head = "\n".join(text.splitlines()[:50])
63+
return _normalize(header) in _normalize(head)
2164

22-
def ensure_header(path: Path, header_text: str, autofix: bool = False) -> bool:
23-
if has_header(path, header_text):
65+
66+
# Compat: acepta Path o str (contenido). Si str y header no dado, busca cadena SPDX.
67+
def has_spdx_header(obj: Path | str, header: str | None = None, *, license_id: str = "MIT") -> bool:
68+
if isinstance(obj, Path):
69+
h = header or header_for_path(obj, license_id=license_id)
70+
return has_header(obj, h)
71+
text = obj
72+
if header is not None:
73+
return _normalize(header) in _normalize("\n".join(text.splitlines()[:50]))
74+
return "SPDX-License-Identifier:" in text.splitlines()[0:50].__str__()
75+
76+
77+
def ensure_header(path: Path, header: str, *, autofix: bool = False) -> bool:
78+
# Semántica "ensure": True si el archivo TERMINA con header (lo tuviera o lo agreguemos)
79+
if has_header(path, header):
2480
return True
2581
if not autofix:
2682
return False
27-
original = path.read_text(encoding="utf-8", errors="strict")
28-
path.write_text(_normalize(header_text) + original, encoding="utf-8")
83+
text = path.read_text(encoding="utf-8", errors="ignore")
84+
# Evita duplicados si corre en paralelo
85+
if not has_header(path, header):
86+
path.write_text(f"{header}{'' if header.endswith('\n') else '\n'}{text}", encoding="utf-8")
2987
return True
88+
89+
90+
def check_headers(root: Path, exts: Sequence[str], license_id: str) -> list[Path]:
91+
missing: list[Path] = []
92+
for fp in _iter_files(root, exts):
93+
hdr = header_for_path(fp, license_id=license_id)
94+
if not has_header(fp, hdr):
95+
missing.append(fp)
96+
return missing
97+
98+
99+
def fix_headers(root: Path, exts: Sequence[str], license_id: str) -> list[Path]:
100+
fixed: list[Path] = []
101+
for fp in _iter_files(root, exts):
102+
hdr = header_for_path(fp, license_id=license_id)
103+
if ensure_header(fp, hdr, autofix=True):
104+
fixed.append(fp)
105+
return fixed
106+
107+
108+
__all__ = [
109+
"DEFAULT_IGNORES",
110+
"default_header_text",
111+
"header_for_path",
112+
"has_header",
113+
"has_spdx_header",
114+
"ensure_header",
115+
"check_headers",
116+
"fix_headers",
117+
]

tests/test_headers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
from header_guardian.core import check_headers, fix_headers, has_spdx_header
6+
7+
8+
def test_adds_and_detects_header(tmp_path: Path) -> None:
9+
f = tmp_path / "a.py"
10+
f.write_text("print('x')\n", encoding="utf-8")
11+
missing = check_headers(tmp_path, [".py"], "MIT")
12+
assert f in missing
13+
fixed = fix_headers(tmp_path, [".py"], "MIT")
14+
assert f in fixed
15+
txt = f.read_text(encoding="utf-8")
16+
assert has_spdx_header(txt)

0 commit comments

Comments
 (0)