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
39 changes: 34 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Python
uses: actions/setup-python@v5
Expand All @@ -58,17 +60,44 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install ruff black mypy
pip install -e ".[dev]"

- name: Run ruff (linting)
- name: Run full-package Ruff fatal checks
run: |
ruff check src/ --select E9,F63,F7,F82 --output-format=github

- name: Resolve quality diff base
id: quality-base
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BASE_SHA: ${{ github.event.before }}
run: |
case "$EVENT_NAME" in
pull_request) base_sha="$PR_BASE_SHA" ;;
push) base_sha="$PUSH_BASE_SHA" ;;
*) base_sha="" ;;
esac
if [[ -z "$base_sha" || "$base_sha" =~ ^0+$ ]] ||
! git cat-file -e "${base_sha}^{commit}"; then
base_sha="$(git rev-parse HEAD^)"
fi
echo "sha=$base_sha" >> "$GITHUB_OUTPUT"

- name: Reject Ruff diagnostics introduced on added lines
run: |
ruff check src/ --output-format=github
python scripts/check_ruff_added_lines.py \
--base "${{ steps.quality-base.outputs.sha }}" \
--head "$GITHUB_SHA"

- name: Run black (formatting check)
- name: Report repository-wide Black baseline (non-blocking)
continue-on-error: true
run: |
black --check src/

- name: Run mypy (type checking)
- name: Report repository-wide Mypy baseline (non-blocking)
continue-on-error: true
run: |
mypy src/ --ignore-missing-imports

Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
copyright = "2024, Tristan Simas"
author = "Tristan Simas"
version = "1.0"
release = "1.0.20"
release = "1.0.21"

# General configuration
extensions = [
Expand Down
10 changes: 6 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "objectstate"
version = "1.0.20"
version = "1.0.21"
description = "Generic lazy dataclass configuration framework with dual-axis inheritance and contextvars-based resolution"
authors = [{name = "Tristan Simas", email = "tristan.simas@mail.mcgill.ca"}]
license = {text = "MIT"}
Expand All @@ -31,9 +31,9 @@ dependencies = [
dev = [
"pytest>=7.0",
"pytest-cov>=4.0",
"black>=23.0",
"ruff>=0.1.0",
"mypy>=1.0",
"black==26.5.1",
"ruff==0.16.0",
"mypy==2.3.0",
]
docs = [
"sphinx>=7.0",
Expand Down Expand Up @@ -64,6 +64,8 @@ target-version = ["py311", "py312", "py313"]
[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP"]

[tool.mypy]
Expand Down
161 changes: 161 additions & 0 deletions scripts/check_ruff_added_lines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
#!/usr/bin/env python3
"""Fail when configured Ruff diagnostics touch lines added by a Git diff."""

from __future__ import annotations

import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path

HUNK_HEADER = re.compile(
r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@"
)


def _git_output(*arguments: str, binary: bool = False) -> str | bytes:
result = subprocess.run(
("git", *arguments),
check=True,
capture_output=True,
text=not binary,
)
return result.stdout


def _changed_python_files(base: str, head: str | None) -> tuple[Path, ...]:
revision_arguments = (base, head) if head is not None else (base,)
output = _git_output(
"diff",
"--name-only",
"--diff-filter=ACMR",
"-z",
*revision_arguments,
"--",
"*.py",
binary=True,
)
assert isinstance(output, bytes)
return tuple(
Path(os.fsdecode(path))
for path in output.split(b"\0")
if path
)


def _added_lines(
path: Path,
base: str,
head: str | None,
) -> frozenset[int]:
revision_arguments = (base, head) if head is not None else (base,)
output = _git_output(
"diff",
"--unified=0",
"--no-ext-diff",
"--no-color",
*revision_arguments,
"--",
os.fspath(path),
)
assert isinstance(output, str)
added: set[int] = set()
for line in output.splitlines():
match = HUNK_HEADER.match(line)
if match is None:
continue
first_line = int(match.group(1))
line_count = int(match.group(2) or "1")
added.update(range(first_line, first_line + line_count))
return frozenset(added)


def _ruff_diagnostics(paths: tuple[Path, ...]) -> list[dict[str, object]]:
result = subprocess.run(
("ruff", "check", "--output-format=json", *(os.fspath(path) for path in paths)),
check=False,
capture_output=True,
text=True,
)
try:
diagnostics = json.loads(result.stdout)
except json.JSONDecodeError as error:
raise RuntimeError(
"Ruff did not return JSON diagnostics.\n"
f"stdout:\n{result.stdout}\n"
f"stderr:\n{result.stderr}"
) from error
if result.returncode not in {0, 1}:
raise RuntimeError(
f"Ruff failed with exit code {result.returncode}:\n{result.stderr}"
)
return diagnostics


def _github_escape(value: object) -> str:
return (
str(value)
.replace("%", "%25")
.replace("\r", "%0D")
.replace("\n", "%0A")
)


def _relative_diagnostic_path(filename: object) -> Path:
path = Path(str(filename))
if path.is_absolute():
return path.relative_to(Path.cwd())
return path


def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base", required=True, help="Git revision before the change")
parser.add_argument(
"--head",
help="Git revision after the change; omit to inspect the current worktree",
)
arguments = parser.parse_args()

changed_paths = _changed_python_files(arguments.base, arguments.head)
if not changed_paths:
print("No changed Python files.")
return 0

line_index = {
path: _added_lines(path, arguments.base, arguments.head)
for path in changed_paths
}
introduced = []
for diagnostic in _ruff_diagnostics(changed_paths):
path = _relative_diagnostic_path(diagnostic["filename"])
location = diagnostic["location"]
assert isinstance(location, dict)
row = int(location["row"])
if row in line_index.get(path, frozenset()):
introduced.append((path, row, location, diagnostic))

if not introduced:
print(
"Configured Ruff rules report no diagnostics on added Python lines "
f"across {len(changed_paths)} changed files."
)
return 0

for path, row, location, diagnostic in introduced:
code = diagnostic["code"]
message = diagnostic["message"]
print(
f"::error file={_github_escape(path)},line={row},"
f"col={location['column']},title=Ruff {code}::"
f"{_github_escape(message)}"
)
print(f"{len(introduced)} Ruff diagnostics touch added Python lines.")
return 1


if __name__ == "__main__":
sys.exit(main())
2 changes: 1 addition & 1 deletion src/objectstate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@
'ObjectStateEditSession',
]

__version__ = '1.0.20'
__version__ = '1.0.21'
__author__ = 'OpenHCS Team'
__description__ = 'Generic configuration framework for lazy dataclass resolution'

Expand Down
6 changes: 6 additions & 0 deletions src/objectstate/field_access.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ def parts(self) -> tuple[str, ...]:
return ()
return tuple(part for part in self.value.split(".") if part)

@property
def field_name(self) -> str:
"""Return the declared leaf name represented by this path."""

return self.parts[-1] if self.parts else ""

def child(self, field_name: str) -> "DottedFieldPath":
if self.value == "":
return DottedFieldPath(field_name)
Expand Down
Loading
Loading