Skip to content

Commit c07d899

Browse files
committed
refactor core domain, file operator, DI, CLI, regex, config versioning
1 parent 63a9366 commit c07d899

7 files changed

Lines changed: 878 additions & 14 deletions

File tree

.github/workflows/ci.yml

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,20 @@ name: CI
22

33
on:
44
push:
5-
branches: [main]
5+
branches: [main, develop]
66
pull_request:
77
branches: [main]
8+
schedule:
9+
- cron: '0 0 * * *' # Daily at midnight
810

911
permissions:
1012
contents: read
13+
packages: write
14+
issues: write
15+
pull-requests: write
16+
17+
env:
18+
PYTHON_VERSION: "3.x"
1119

1220
jobs:
1321
test:
@@ -36,4 +44,78 @@ jobs:
3644
run: ruff format --check .
3745

3846
- name: Run tests
39-
run: pytest
47+
run: pytest -v --cov=replace_text --cov-report=term-missing
48+
49+
- name: Upload coverage
50+
uses: actions/upload-artifact@v4
51+
with:
52+
name: coverage-${{ matrix.python-version }}
53+
path: htmlcov/
54+
55+
security:
56+
runs-on: ubuntu-latest
57+
steps:
58+
- uses: actions/checkout@v4
59+
with:
60+
fetch-depth: 0
61+
62+
- name: Set up Python
63+
uses: actions/setup-python@v5
64+
with:
65+
python-version: "3.12"
66+
67+
- name: Run Bandit (Python security)
68+
run: |
69+
python -m pip install --upgrade pip bandit
70+
bandit -r replace_text -ll
71+
72+
- name: Run gitleaks (secret scanning)
73+
uses: gitleaks/gitleaks-action@v2
74+
env:
75+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
76+
77+
- name: Run actionlint (workflow lint)
78+
run: |
79+
bash <(curl -sSf https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)
80+
./actionlint -color
81+
82+
integration:
83+
runs-on: ubuntu-latest
84+
needs: [test, security]
85+
steps:
86+
- uses: actions/checkout@v4
87+
88+
- name: Setup Python
89+
uses: actions/setup-python@v5
90+
with:
91+
python-version: "3.12"
92+
93+
- name: Install dependencies
94+
run: |
95+
python -m pip install --upgrade pip
96+
pip install -e ".[dev]"
97+
98+
- name: Run integration tests
99+
run: |
100+
python -m replace_text.replace_text --help
101+
102+
workdir=$(mktemp -d)
103+
echo "hello foo world" > "$workdir/sample.txt"
104+
cat > "$workdir/config.json" << 'EOF'
105+
{
106+
"version": "1.0",
107+
"dictionaries": {
108+
"dict1": {"foo": "bar"}
109+
},
110+
"ignore_extensions": [],
111+
"ignore_directories": [],
112+
"ignore_file_prefixes": []
113+
}
114+
EOF
115+
python -m replace_text.replace_text \
116+
--config "$workdir/config.json" \
117+
--direction 1 \
118+
--folder "$workdir" \
119+
--dict-name dict1
120+
grep -q "hello bar world" "$workdir/sample.txt" || { echo "replacement failed"; cat "$workdir/sample.txt"; exit 1; }
121+
rm -rf "$workdir"

.pre-commit-config.yaml

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
repos:
2+
- repo: https://github.com/astral-sh/ruff-pre-commit
3+
rev: "v0.12.2"
4+
hooks:
5+
- id: ruff-check
6+
args: ["--fix"]
7+
- id: ruff-format
8+
- id: pyupgrade
9+
args: ["--py39-plus"]
10+
- id: trailing-whitespace
11+
- id: end-of-file-fixer
12+
- id: check-yaml
13+
- id: check-added-large-files
14+
args: ["--maxkb=500"]
15+
- id: check-merge-conflict
16+
- id: check-ast
17+
- id: check-toml
18+
- id: check-json
19+
- id: check-vcs-permanent-fixer
20+
- id: check-xml
21+
- id: mixed-line-ending
22+
args: ["fix=lf"]
23+
24+
- repo: https://github.com/pre-commit/mirrors-pytest
25+
rev: "v2024.2.0"
26+
hooks:
27+
- id: pytest
28+
args: ["--tb=short"]
29+
pass_filenames: false
30+
31+
- repo: https://github.com/pycabook/blacken-docs
32+
rev: "v1.16.0"
33+
hooks:
34+
- id: blacken-docs
35+
36+
- repo: local
37+
hooks:
38+
- id: type-check
39+
name: Type check with mypy
40+
entry: mypy
41+
language: system
42+
files: \.py$
43+
pass_filenames: true
44+
require_serial: true
45+
verbose: true
46+
47+
- id: security-check
48+
name: Security check with bandit
49+
entry: bandit
50+
language: system
51+
files: \.py$
52+
pass_filenames: true
53+
verbose: true
54+
55+
- id: dependency-check
56+
name: Dependency check with pip-audit
57+
entry: pip-audit
58+
language: system
59+
files: pyproject.toml
60+
pass_filenames: false
61+
verbose: true
62+
63+
- id: license-check
64+
name: License header check
65+
entry: bash -c 'grep -q "License" "$1" || exit 1' _
66+
language: system
67+
files: \.(py|md)$
68+
pass_filenames: true
69+
verbose: true
70+
71+
- id: docstring-check
72+
name: Docstring check with pydocstyle
73+
entry: pydocstyle
74+
language: system
75+
files: \.py$
76+
pass_filenames: true
77+
verbose: true
78+
79+
- id: import-order
80+
name: Import order check
81+
entry: isort
82+
language: system
83+
files: \.py$
84+
pass_filenames: true
85+
verbose: true
86+
87+
- id: complexity-check
88+
name: Complexity check with radon
89+
entry: radon cc
90+
language: system
91+
files: \.py$
92+
pass_filenames: true
93+
verbose: true
94+
args: ["-s", "A", "-a", "-nc"]
95+
96+
- id: coverage-check
97+
name: Coverage check
98+
entry: bash -c 'coverage run -m pytest && coverage report --fail-under=80'
99+
language: system
100+
files: \.py$
101+
pass_filenames: false
102+
verbose: true
103+
104+
- id: lint-before-test
105+
name: Lint before running tests
106+
entry: bash -c 'ruff check . && ruff format --check .'
107+
language: system
108+
files: \.py$
109+
pass_filenames: false
110+
verbose: true
111+
always_run: true
112+
113+
- repo: https://github.com/pre-commit/pre-commit-hooks
114+
rev: "v5.0.0"
115+
hooks:
116+
- id: check-case-conflict
117+
- id: check-symlinks
118+
- id: debug-statements
119+
- id: name-tests-test
120+
- id: requirements-txt-fixer
121+
- id: mixed-line-ending
122+
- id: trailing-whitespace
123+
- id: end-of-file-fixer
124+
- id: check-json
125+
- id: check-xml
126+
- id: check-toml
127+
- id: check-yaml
128+
- id: flake8
129+
- id: no-commit-to-branch
130+
args: ["--branch", "main"]

replace_text/core/__init__.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Core domain models for text replacement."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import tempfile
7+
from abc import ABC, abstractmethod
8+
from dataclasses import dataclass, field
9+
from pathlib import Path
10+
from shutil import copy2
11+
12+
13+
@dataclass
14+
class Config:
15+
"""Configuration for text replacement."""
16+
17+
dictionaries: dict[str, dict[str, str]]
18+
ignore_extensions: list[str] = field(default_factory=list)
19+
ignore_directories: list[str] = field(default_factory=list)
20+
ignore_file_prefixes: list[str] = field(default_factory=list)
21+
version: str = "1.0"
22+
23+
def get_dictionary(self, name: str, direction: int) -> dict[str, str]:
24+
"""Get replacement dictionary for a direction."""
25+
raw = self.dictionaries.get(name, {})
26+
if direction == 2:
27+
raw = {v: k for k, v in raw.items()}
28+
return raw
29+
30+
31+
class FileOperator(ABC):
32+
"""Abstract interface for file operations."""
33+
34+
@abstractmethod
35+
def read_text(self, path: Path) -> str:
36+
"""Read text from a file."""
37+
38+
@abstractmethod
39+
def write_text(self, path: Path, content: str) -> None:
40+
"""Write text to a file."""
41+
42+
@abstractmethod
43+
def file_exists(self, path: Path) -> bool:
44+
"""Check if a file exists."""
45+
46+
@abstractmethod
47+
def walk(self, directory: Path):
48+
"""Walk directory tree."""
49+
50+
@abstractmethod
51+
def ensure_dir_exists(self, path: Path) -> None:
52+
"""Ensure directory exists."""
53+
54+
@abstractmethod
55+
def make_backup(self, path: Path, backup_dir: Path) -> Path:
56+
"""Create a backup of a file."""
57+
58+
@abstractmethod
59+
def list_files(self, directory: Path, pattern: str = "*") -> list[Path]:
60+
"""List files matching pattern."""
61+
62+
@abstractmethod
63+
def is_binary(self, path: Path) -> bool:
64+
"""Check if file is binary."""
65+
66+
@abstractmethod
67+
def create_backup(self, path: Path, backup_dir: Path) -> Path:
68+
"""Create a backup of a file."""
69+
70+
71+
class LocalFileOperator(FileOperator):
72+
"""Local filesystem file operator with backup support."""
73+
74+
def __init__(self, backup_dir: Path | None = None):
75+
self.backup_dir = backup_dir
76+
77+
def read_text(self, path: Path) -> str:
78+
with open(path, encoding="utf-8") as f:
79+
return f.read()
80+
81+
def write_text(self, path: Path, content: str) -> None:
82+
parent = path.parent
83+
if parent:
84+
parent.mkdir(parents=True, exist_ok=True)
85+
86+
fd, tmp_path = tempfile.mkstemp(dir=parent, prefix=path.name + ".", text=True)
87+
try:
88+
with os.fdopen(fd, "w", encoding="utf-8") as f:
89+
f.write(content)
90+
os.replace(tmp_path, path)
91+
except Exception:
92+
try:
93+
os.unlink(tmp_path)
94+
except OSError:
95+
pass
96+
raise
97+
98+
def file_exists(self, path: Path) -> bool:
99+
return path.exists()
100+
101+
def ensure_dir_exists(self, path: Path) -> None:
102+
path.mkdir(parents=True, exist_ok=True)
103+
104+
def make_backup(self, path: Path, backup_dir: Path) -> Path:
105+
if not self.backup_dir:
106+
raise ValueError("No backup directory configured")
107+
self.ensure_dir_exists(self.backup_dir)
108+
rel_path = path.relative_to(os.getcwd())
109+
backup_path = self.backup_dir / rel_path
110+
backup_path.parent.mkdir(parents=True, exist_ok=True)
111+
copy2(path, backup_path)
112+
return backup_path
113+
114+
def walk(self, directory: Path):
115+
yield from os.walk(directory)
116+
117+
def list_files(self, directory: Path, pattern: str = "*") -> list[Path]:
118+
return list(Path(directory).glob(pattern))
119+
120+
def is_binary(self, path: Path) -> bool:
121+
try:
122+
path.read_text(encoding="utf-8")
123+
return False
124+
except UnicodeDecodeError:
125+
return True
126+
127+
def create_backup(self, path: Path, backup_dir: Path) -> Path:
128+
if not self.backup_dir:
129+
raise ValueError("No backup directory configured")
130+
return self.make_backup(path, backup_dir)

0 commit comments

Comments
 (0)