repolish 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- repolish/__init__.py +0 -0
- repolish/builder.py +45 -0
- repolish/cli.py +123 -0
- repolish/config.py +135 -0
- repolish/cookiecutter.py +253 -0
- repolish/loader.py +341 -0
- repolish/processors.py +146 -0
- repolish/py.typed +0 -0
- repolish/version.py +1 -0
- repolish-0.1.0.dist-info/METADATA +266 -0
- repolish-0.1.0.dist-info/RECORD +13 -0
- repolish-0.1.0.dist-info/WHEEL +4 -0
- repolish-0.1.0.dist-info/entry_points.txt +2 -0
repolish/__init__.py
ADDED
|
File without changes
|
repolish/builder.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def create_cookiecutter_template(
|
|
6
|
+
staging_dir: Path,
|
|
7
|
+
template_directories: list[Path],
|
|
8
|
+
) -> Path:
|
|
9
|
+
"""Create a cookiecutter template in a staging directory.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
staging_dir: Path to the staging directory to create the templates.
|
|
13
|
+
template_directories: List of template directories to copy into the
|
|
14
|
+
staging directory. If multiple directories are provided, later
|
|
15
|
+
directories will overwrite files from earlier ones.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
The Path to the staging directory containing the combined templates.
|
|
19
|
+
"""
|
|
20
|
+
if staging_dir.exists():
|
|
21
|
+
shutil.rmtree(staging_dir)
|
|
22
|
+
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
for template_dir in template_directories:
|
|
24
|
+
_copy_template_dir(template_dir, staging_dir)
|
|
25
|
+
return staging_dir
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _copy_template_dir(template_dir: Path, staging_dir: Path) -> None:
|
|
29
|
+
"""Copy the contents of a template directory into the staging directory.
|
|
30
|
+
|
|
31
|
+
Each provider is expected to have a `repolish/` subdirectory containing
|
|
32
|
+
the project layout files. These will be copied over to the staging dir under
|
|
33
|
+
the special folder `{{cookiecutter._repolish_project}}`.
|
|
34
|
+
"""
|
|
35
|
+
repolish_dir = template_dir / 'repolish'
|
|
36
|
+
if repolish_dir.exists() and repolish_dir.is_dir():
|
|
37
|
+
dest_root = staging_dir / '{{cookiecutter._repolish_project}}'
|
|
38
|
+
for item in repolish_dir.rglob('*'):
|
|
39
|
+
rel = item.relative_to(repolish_dir)
|
|
40
|
+
dest = dest_root / rel
|
|
41
|
+
if item.is_dir():
|
|
42
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
else:
|
|
44
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
shutil.copy2(item, dest)
|
repolish/cli.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from hotlog import (
|
|
6
|
+
add_verbosity_argument,
|
|
7
|
+
configure_logging,
|
|
8
|
+
get_logger,
|
|
9
|
+
resolve_verbosity,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from .config import load_config
|
|
13
|
+
from .cookiecutter import (
|
|
14
|
+
apply_generated_output,
|
|
15
|
+
build_final_providers,
|
|
16
|
+
check_generated_output,
|
|
17
|
+
prepare_staging,
|
|
18
|
+
prepare_template,
|
|
19
|
+
preprocess_templates,
|
|
20
|
+
render_template,
|
|
21
|
+
rich_print_diffs,
|
|
22
|
+
)
|
|
23
|
+
from .version import __version__
|
|
24
|
+
|
|
25
|
+
logger = get_logger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def run(argv: list[str]) -> int:
|
|
29
|
+
"""Run repolish with argv-like list and return an exit code.
|
|
30
|
+
|
|
31
|
+
This is separated from `main()` so we can keep `main()` small and
|
|
32
|
+
maintain a low cyclomatic complexity for the top-level entrypoint.
|
|
33
|
+
"""
|
|
34
|
+
parser = argparse.ArgumentParser(prog='repolish')
|
|
35
|
+
# add standard verbosity switch provided by hotlog
|
|
36
|
+
add_verbosity_argument(parser)
|
|
37
|
+
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
'--check',
|
|
40
|
+
dest='check',
|
|
41
|
+
action='store_true',
|
|
42
|
+
help='Load config and create context (dry-run check)',
|
|
43
|
+
)
|
|
44
|
+
parser.add_argument(
|
|
45
|
+
'--config',
|
|
46
|
+
dest='config',
|
|
47
|
+
type=Path,
|
|
48
|
+
default=Path('repolish.yaml'),
|
|
49
|
+
help='Path to the repolish YAML configuration file',
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument('--version', action='version', version=__version__)
|
|
52
|
+
args = parser.parse_args(argv)
|
|
53
|
+
check_only = args.check
|
|
54
|
+
config_path = args.config
|
|
55
|
+
|
|
56
|
+
# Configure logging using resolved verbosity (supports CI auto-detection)
|
|
57
|
+
verbosity = resolve_verbosity(args)
|
|
58
|
+
configure_logging(verbosity=verbosity)
|
|
59
|
+
|
|
60
|
+
# Log the running version early so CI logs always show which repolish wrote the output
|
|
61
|
+
logger.info('running_repolish', version=__version__)
|
|
62
|
+
|
|
63
|
+
config = load_config(config_path)
|
|
64
|
+
|
|
65
|
+
providers = build_final_providers(config)
|
|
66
|
+
logger.info(
|
|
67
|
+
'final_providers_generated',
|
|
68
|
+
template_directories=config.directories,
|
|
69
|
+
context=providers.context,
|
|
70
|
+
delete_paths=[p.as_posix() for p in providers.delete_files],
|
|
71
|
+
delete_history={
|
|
72
|
+
key: [{'source': d.source, 'action': d.action.value} for d in decisions]
|
|
73
|
+
for key, decisions in providers.delete_history.items()
|
|
74
|
+
},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
# Prepare staging and template
|
|
78
|
+
base_dir, setup_input, setup_output = prepare_staging(config)
|
|
79
|
+
|
|
80
|
+
template_dirs = [Path(p) for p in config.directories]
|
|
81
|
+
prepare_template(setup_input, template_dirs)
|
|
82
|
+
|
|
83
|
+
# Preprocess templates (anchor-driven replacements)
|
|
84
|
+
preprocess_templates(setup_input, providers, config, base_dir)
|
|
85
|
+
|
|
86
|
+
# Render once using cookiecutter
|
|
87
|
+
render_template(setup_input, providers, setup_output)
|
|
88
|
+
|
|
89
|
+
# Decide whether to check or apply generated output
|
|
90
|
+
if check_only:
|
|
91
|
+
diffs = check_generated_output(setup_output, providers, base_dir)
|
|
92
|
+
if diffs:
|
|
93
|
+
logger.error(
|
|
94
|
+
'check_results',
|
|
95
|
+
suggestion='run `repolish` to apply changes',
|
|
96
|
+
)
|
|
97
|
+
rich_print_diffs(diffs)
|
|
98
|
+
return 2 if diffs else 0
|
|
99
|
+
|
|
100
|
+
# apply into project
|
|
101
|
+
apply_generated_output(setup_output, providers, base_dir)
|
|
102
|
+
return 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def main() -> int:
|
|
106
|
+
"""Main entry point for the repolish CLI.
|
|
107
|
+
|
|
108
|
+
This function keeps a very small surface area and delegates the work to
|
|
109
|
+
`run()`. High-level error handling lives here so callers (and tests) get
|
|
110
|
+
stable exit codes.
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
# Forward the real command-line arguments so flags like --version work.
|
|
114
|
+
return run(sys.argv[1:])
|
|
115
|
+
except SystemExit:
|
|
116
|
+
raise
|
|
117
|
+
except Exception: # pragma: no cover - high level CLI error handling
|
|
118
|
+
logger.exception('failed_to_run_repolish')
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == '__main__':
|
|
123
|
+
raise SystemExit(main())
|
repolish/config.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import yaml
|
|
5
|
+
from hotlog import get_logger
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
logger = get_logger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class RepolishConfig(BaseModel):
|
|
12
|
+
"""Configuration for the Repolish tool."""
|
|
13
|
+
|
|
14
|
+
directories: list[str] = Field(
|
|
15
|
+
default=...,
|
|
16
|
+
description='List of paths to template directories',
|
|
17
|
+
min_length=1,
|
|
18
|
+
)
|
|
19
|
+
context: dict[str, Any] = Field(
|
|
20
|
+
default_factory=dict,
|
|
21
|
+
description='Context variables for template rendering',
|
|
22
|
+
)
|
|
23
|
+
anchors: dict[str, str] = Field(
|
|
24
|
+
default_factory=dict,
|
|
25
|
+
description='Anchor content for block replacements',
|
|
26
|
+
)
|
|
27
|
+
post_process: list[str] = Field(
|
|
28
|
+
default_factory=list,
|
|
29
|
+
description='List of shell commands to run after generating files (formatters)',
|
|
30
|
+
)
|
|
31
|
+
delete_files: list[str] = Field(
|
|
32
|
+
default_factory=list,
|
|
33
|
+
description=(
|
|
34
|
+
'List of POSIX-style paths to delete after generation. Use a leading'
|
|
35
|
+
" '!' to negate (keep) a previously-added path."
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
# Path to the YAML configuration file. Set when loading from disk; excluded
|
|
39
|
+
# from model serialization so it doesn't appear in dumped config data.
|
|
40
|
+
config_file: Path | None = Field(
|
|
41
|
+
default=None,
|
|
42
|
+
description='Path to the YAML configuration file (set by loader)',
|
|
43
|
+
exclude=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def _handle_directory_errors(
|
|
47
|
+
self,
|
|
48
|
+
missing_dirs: list[str],
|
|
49
|
+
invalid_dirs: list[str],
|
|
50
|
+
invalid_template: list[str],
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Handle errors related to directory validation."""
|
|
53
|
+
error_messages = []
|
|
54
|
+
if missing_dirs:
|
|
55
|
+
error_messages.append(f'Missing directories: {missing_dirs}')
|
|
56
|
+
if invalid_dirs:
|
|
57
|
+
error_messages.append(
|
|
58
|
+
f'Invalid directories (not a directory): {invalid_dirs}',
|
|
59
|
+
)
|
|
60
|
+
if invalid_template:
|
|
61
|
+
error_messages.append(
|
|
62
|
+
f'Directories missing repolish.py or repolish/ folder: {invalid_template}',
|
|
63
|
+
)
|
|
64
|
+
if error_messages:
|
|
65
|
+
raise ValueError(' ; '.join(error_messages))
|
|
66
|
+
|
|
67
|
+
def validate_directories(self) -> None:
|
|
68
|
+
"""Validate that all directories exist."""
|
|
69
|
+
missing_dirs: list[str] = []
|
|
70
|
+
invalid_dirs: list[str] = []
|
|
71
|
+
invalid_template: list[str] = []
|
|
72
|
+
|
|
73
|
+
for directory in self.get_directories():
|
|
74
|
+
# Keep the user-facing identifier as the original string for
|
|
75
|
+
# clearer error messages; find the matching input string by index.
|
|
76
|
+
idx = self.get_directories().index(directory)
|
|
77
|
+
original = self.directories[idx]
|
|
78
|
+
path = directory
|
|
79
|
+
if not path.exists():
|
|
80
|
+
missing_dirs.append(original)
|
|
81
|
+
elif not path.is_dir():
|
|
82
|
+
invalid_dirs.append(original)
|
|
83
|
+
elif not (path / 'repolish.py').exists() or not (path / 'repolish').exists():
|
|
84
|
+
invalid_template.append(original)
|
|
85
|
+
|
|
86
|
+
if missing_dirs or invalid_dirs or invalid_template:
|
|
87
|
+
self._handle_directory_errors(
|
|
88
|
+
missing_dirs,
|
|
89
|
+
invalid_dirs,
|
|
90
|
+
invalid_template,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def get_directories(self) -> list[Path]:
|
|
94
|
+
"""Return the configured directories as resolved Path objects.
|
|
95
|
+
|
|
96
|
+
The YAML configuration file is expected to use POSIX-style paths (with
|
|
97
|
+
forward slashes). This method interprets each configured string as a
|
|
98
|
+
POSIX path and resolves it relative to the directory containing the
|
|
99
|
+
configuration file (if `config_file` is set). If `config_file` is not
|
|
100
|
+
set, paths are returned as-is (interpreted by the current platform).
|
|
101
|
+
"""
|
|
102
|
+
resolved: list[Path] = []
|
|
103
|
+
base_dir = Path(self.config_file).resolve().parent if self.config_file else None
|
|
104
|
+
|
|
105
|
+
for entry in self.directories:
|
|
106
|
+
# Accept POSIX-style entries (forward slashes) but let the
|
|
107
|
+
# platform-native Path handle parsing so absolute Windows-style
|
|
108
|
+
# entries like 'C:/path' are recognized correctly. If the entry
|
|
109
|
+
# is relative, resolve it against the directory containing the
|
|
110
|
+
# config file (when available).
|
|
111
|
+
p = Path(entry)
|
|
112
|
+
if base_dir and not p.is_absolute():
|
|
113
|
+
p = base_dir / p
|
|
114
|
+
resolved.append(p.resolve())
|
|
115
|
+
|
|
116
|
+
return resolved
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def load_config(yaml_file: Path) -> RepolishConfig:
|
|
120
|
+
"""Find the repolish configuration file in the specified directory.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
yaml_file: Path to the YAML configuration file.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
An instance of RepolishConfig with validated data.
|
|
127
|
+
"""
|
|
128
|
+
with yaml_file.open(encoding='utf-8') as f:
|
|
129
|
+
data = yaml.safe_load(f)
|
|
130
|
+
config = RepolishConfig.model_validate(data)
|
|
131
|
+
# store the location of the config file on the model so relative paths can
|
|
132
|
+
# be resolved later
|
|
133
|
+
config.config_file = yaml_file
|
|
134
|
+
config.validate_directories()
|
|
135
|
+
return config
|
repolish/cookiecutter.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import difflib
|
|
2
|
+
import filecmp
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path, PurePosixPath
|
|
6
|
+
|
|
7
|
+
from cookiecutter.main import cookiecutter
|
|
8
|
+
from hotlog import get_logger
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.syntax import Syntax
|
|
11
|
+
|
|
12
|
+
from .builder import create_cookiecutter_template
|
|
13
|
+
from .config import RepolishConfig
|
|
14
|
+
from .loader import Action, Decision, Providers, create_providers
|
|
15
|
+
from .processors import replace_text, safe_file_read
|
|
16
|
+
|
|
17
|
+
logger = get_logger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_final_providers(config: RepolishConfig) -> Providers:
|
|
21
|
+
"""Build the final Providers object by merging provider contributions.
|
|
22
|
+
|
|
23
|
+
- Loads providers from config.directories
|
|
24
|
+
- Merges config.context over provider.context
|
|
25
|
+
- Applies config.delete_files entries (with '!' negation) on top of
|
|
26
|
+
provider decisions and records provenance Decisions for config entries
|
|
27
|
+
"""
|
|
28
|
+
providers = create_providers(config.directories)
|
|
29
|
+
|
|
30
|
+
# Merge contexts: config wins
|
|
31
|
+
merged_context = {**providers.context, **config.context}
|
|
32
|
+
|
|
33
|
+
# Start from provider delete decisions
|
|
34
|
+
delete_set = set(providers.delete_files)
|
|
35
|
+
|
|
36
|
+
cfg_delete = config.delete_files or []
|
|
37
|
+
for raw in cfg_delete:
|
|
38
|
+
neg = isinstance(raw, str) and raw.startswith('!')
|
|
39
|
+
entry = raw[1:] if neg else raw
|
|
40
|
+
p = Path(*PurePosixPath(entry).parts)
|
|
41
|
+
if neg:
|
|
42
|
+
delete_set.discard(p)
|
|
43
|
+
else:
|
|
44
|
+
delete_set.add(p)
|
|
45
|
+
# provenance source: config file path if set, else 'config'
|
|
46
|
+
cfg_file = config.config_file
|
|
47
|
+
src = cfg_file.as_posix() if isinstance(cfg_file, Path) else 'config'
|
|
48
|
+
providers.delete_history.setdefault(p.as_posix(), []).append(
|
|
49
|
+
Decision(
|
|
50
|
+
source=src,
|
|
51
|
+
action=(Action.keep if neg else Action.delete),
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# produce final Providers-like object
|
|
56
|
+
return Providers(
|
|
57
|
+
context=merged_context,
|
|
58
|
+
anchors=providers.anchors,
|
|
59
|
+
delete_files=list(delete_set),
|
|
60
|
+
delete_history=providers.delete_history,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def prepare_staging(config: RepolishConfig) -> tuple[Path, Path, Path]:
|
|
65
|
+
"""Compute and ensure staging dirs next to the config file.
|
|
66
|
+
|
|
67
|
+
Returns: (base_dir, setup_input_path, setup_output_path)
|
|
68
|
+
"""
|
|
69
|
+
cfg_file = config.config_file
|
|
70
|
+
base_dir = Path(cfg_file).resolve().parent if cfg_file else Path.cwd()
|
|
71
|
+
staging = base_dir / '.repolish'
|
|
72
|
+
setup_input = staging / 'setup-input'
|
|
73
|
+
setup_output = staging / 'setup-output'
|
|
74
|
+
|
|
75
|
+
# clear output dir if present
|
|
76
|
+
shutil.rmtree(setup_input, ignore_errors=True)
|
|
77
|
+
shutil.rmtree(setup_output, ignore_errors=True)
|
|
78
|
+
setup_input.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
setup_output.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
|
|
81
|
+
return base_dir, setup_input, setup_output
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def prepare_template(setup_input: Path, template_dirs: list[Path]) -> None:
|
|
85
|
+
"""Prepare the merged cookiecutter template in setup_input by copying/merging.
|
|
86
|
+
|
|
87
|
+
Provided template directories are merged into `setup_input`.
|
|
88
|
+
"""
|
|
89
|
+
# Delegate to builder helper which knows how to merge provider templates
|
|
90
|
+
create_cookiecutter_template(setup_input, template_dirs)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def preprocess_templates(
|
|
94
|
+
setup_input: Path,
|
|
95
|
+
providers: Providers,
|
|
96
|
+
config: RepolishConfig,
|
|
97
|
+
base_dir: Path,
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Apply anchor-driven replacements to files under setup_input.
|
|
100
|
+
|
|
101
|
+
Local project files used for anchor-driven overrides are resolved relative
|
|
102
|
+
to `base_dir` (usually the directory containing the config file).
|
|
103
|
+
"""
|
|
104
|
+
anchors_mapping = {**providers.anchors, **config.anchors}
|
|
105
|
+
|
|
106
|
+
for tpl in setup_input.rglob('*'):
|
|
107
|
+
if not tpl.is_file():
|
|
108
|
+
continue
|
|
109
|
+
try:
|
|
110
|
+
tpl_text = tpl.read_text(encoding='utf-8', errors='replace')
|
|
111
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
112
|
+
# skip binary or unreadable files but log at debug level
|
|
113
|
+
logger.debug(
|
|
114
|
+
'unreadable_template_file',
|
|
115
|
+
template_file=tpl,
|
|
116
|
+
error=str(exc),
|
|
117
|
+
)
|
|
118
|
+
continue
|
|
119
|
+
rel = tpl.relative_to(
|
|
120
|
+
setup_input / '{{cookiecutter._repolish_project}}',
|
|
121
|
+
)
|
|
122
|
+
local_path = base_dir / rel
|
|
123
|
+
local_text = safe_file_read(local_path)
|
|
124
|
+
# Let replace_text raise if something unexpected happens; caller will log
|
|
125
|
+
new_text = replace_text(
|
|
126
|
+
tpl_text,
|
|
127
|
+
local_text,
|
|
128
|
+
anchors_dictionary=anchors_mapping,
|
|
129
|
+
)
|
|
130
|
+
if new_text != tpl_text:
|
|
131
|
+
tpl.write_text(new_text, encoding='utf-8')
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def render_template(
|
|
135
|
+
setup_input: Path,
|
|
136
|
+
providers: Providers,
|
|
137
|
+
setup_output: Path,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Run cookiecutter once on the merged template (setup_input) into setup_output."""
|
|
140
|
+
# Dump the merged context into the merged template so cookiecutter can
|
|
141
|
+
# read it from disk (avoids requiring each provider to ship cookiecutter.json).
|
|
142
|
+
# Inject a special variable `_repolish_project` used by the staging step
|
|
143
|
+
# so providers can place the project layout under a `repolish/` folder and
|
|
144
|
+
# we copy it to `{{cookiecutter._repolish_project}}` in the staging dir.
|
|
145
|
+
merged_ctx = dict(providers.context)
|
|
146
|
+
# default project folder name used during generation
|
|
147
|
+
merged_ctx.setdefault('_repolish_project', 'repolish')
|
|
148
|
+
|
|
149
|
+
ctx_file = setup_input / 'cookiecutter.json'
|
|
150
|
+
ctx_file.write_text(
|
|
151
|
+
json.dumps(merged_ctx, ensure_ascii=False),
|
|
152
|
+
encoding='utf-8',
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
cookiecutter(str(setup_input), no_input=True, output_dir=str(setup_output))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def collect_output_files(setup_output: Path) -> list[Path]:
|
|
159
|
+
"""Return a list of file Paths under `setup_output`."""
|
|
160
|
+
return [p for p in setup_output.rglob('*') if p.is_file()]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def check_generated_output(
|
|
164
|
+
setup_output: Path,
|
|
165
|
+
providers: Providers,
|
|
166
|
+
base_dir: Path,
|
|
167
|
+
) -> list[tuple[str, str]]:
|
|
168
|
+
"""Compare generated output to project files and report diffs and deletions.
|
|
169
|
+
|
|
170
|
+
Returns a list of (relative_path, message_or_unified_diff). Empty when no diffs found.
|
|
171
|
+
"""
|
|
172
|
+
output_files = collect_output_files(setup_output)
|
|
173
|
+
diffs: list[tuple[str, str]] = []
|
|
174
|
+
for out in output_files:
|
|
175
|
+
rel = out.relative_to(setup_output / 'repolish')
|
|
176
|
+
dest = base_dir / rel
|
|
177
|
+
if not dest.exists():
|
|
178
|
+
diffs.append((str(rel), 'MISSING'))
|
|
179
|
+
continue
|
|
180
|
+
# compare contents
|
|
181
|
+
if not filecmp.cmp(out, dest, shallow=False):
|
|
182
|
+
# produce a small unified diff for logging
|
|
183
|
+
a = out.read_text(encoding='utf-8', errors='replace').splitlines()
|
|
184
|
+
b = dest.read_text(encoding='utf-8', errors='replace').splitlines()
|
|
185
|
+
ud = '\n'.join(
|
|
186
|
+
difflib.unified_diff(
|
|
187
|
+
b,
|
|
188
|
+
a,
|
|
189
|
+
fromfile=str(dest),
|
|
190
|
+
tofile=str(out),
|
|
191
|
+
lineterm='',
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
diffs.append((str(rel), ud))
|
|
195
|
+
|
|
196
|
+
# provider-declared deletions: if a path is expected deleted but exists in
|
|
197
|
+
# the project, surface that so devs know to run repolish
|
|
198
|
+
for rel in providers.delete_files:
|
|
199
|
+
proj_target = base_dir / rel
|
|
200
|
+
if proj_target.exists():
|
|
201
|
+
diffs.append((str(rel), 'PRESENT_BUT_SHOULD_BE_DELETED'))
|
|
202
|
+
|
|
203
|
+
return diffs
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def apply_generated_output(
|
|
207
|
+
setup_output: Path,
|
|
208
|
+
providers: Providers,
|
|
209
|
+
base_dir: Path,
|
|
210
|
+
) -> None:
|
|
211
|
+
"""Copy generated files into the project root and apply deletions.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
setup_output: Path to the cookiecutter output directory.
|
|
215
|
+
providers: Providers object with delete_files list.
|
|
216
|
+
base_dir: Base directory where the project root is located.
|
|
217
|
+
|
|
218
|
+
Returns None. Exceptions during per-file operations are raised to caller.
|
|
219
|
+
"""
|
|
220
|
+
output_files = collect_output_files(setup_output)
|
|
221
|
+
|
|
222
|
+
# copy files into project root (overwrite)
|
|
223
|
+
for out in output_files:
|
|
224
|
+
rel = out.relative_to(setup_output / 'repolish')
|
|
225
|
+
dest = base_dir / rel
|
|
226
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
shutil.copy2(out, dest)
|
|
228
|
+
|
|
229
|
+
# Now apply deletions at the project root as the final step
|
|
230
|
+
for rel in providers.delete_files:
|
|
231
|
+
target = base_dir / rel
|
|
232
|
+
if target.exists():
|
|
233
|
+
if target.is_dir():
|
|
234
|
+
shutil.rmtree(target)
|
|
235
|
+
else:
|
|
236
|
+
target.unlink()
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def rich_print_diffs(diffs: list[tuple[str, str]]) -> None:
|
|
240
|
+
"""Print diffs using rich formatting.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
diffs: List of tuples (relative_path, message_or_unified_diff)
|
|
244
|
+
"""
|
|
245
|
+
console = Console()
|
|
246
|
+
for rel, msg in diffs:
|
|
247
|
+
console.rule(f'[bold]{rel}')
|
|
248
|
+
if msg in ('MISSING', 'PRESENT_BUT_SHOULD_BE_DELETED'):
|
|
249
|
+
console.print(msg)
|
|
250
|
+
else:
|
|
251
|
+
# highlight as a diff
|
|
252
|
+
syntax = Syntax(msg, 'diff', theme='ansi_dark', word_wrap=False)
|
|
253
|
+
console.print(syntax)
|
repolish/loader.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
from collections.abc import Iterable
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from importlib.util import module_from_spec, spec_from_file_location
|
|
4
|
+
from pathlib import Path, PurePosixPath
|
|
5
|
+
|
|
6
|
+
from hotlog import get_logger
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Action(str, Enum):
|
|
13
|
+
"""Enumeration of possible actions for a path."""
|
|
14
|
+
|
|
15
|
+
delete = 'delete'
|
|
16
|
+
keep = 'keep'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Decision(BaseModel):
|
|
20
|
+
"""Typed provenance decision recorded for each path.
|
|
21
|
+
|
|
22
|
+
- source: provider identifier (POSIX string)
|
|
23
|
+
- action: Action enum
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
source: str
|
|
27
|
+
action: Action
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Providers(BaseModel):
|
|
31
|
+
"""Structured provider contributions collected from template modules.
|
|
32
|
+
|
|
33
|
+
- context: merged cookiecutter context
|
|
34
|
+
- anchors: merged anchors mapping
|
|
35
|
+
- delete_files: list of Paths representing files to delete
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
context: dict[str, object] = Field(default_factory=dict)
|
|
39
|
+
anchors: dict[str, str] = Field(default_factory=dict)
|
|
40
|
+
delete_files: list[Path] = Field(default_factory=list)
|
|
41
|
+
# provenance mapping: posix path -> list of Decision instances
|
|
42
|
+
delete_history: dict[str, list[Decision]] = Field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_module(module_path: str) -> dict[str, object]:
|
|
46
|
+
"""Dynamically import a module from a given path."""
|
|
47
|
+
spec = spec_from_file_location('repolish_module', module_path)
|
|
48
|
+
if not spec or not spec.loader: # pragma: no cover
|
|
49
|
+
# We shouldn't reach this point in tests due to other validations
|
|
50
|
+
msg = f'Cannot load module from path: {module_path}'
|
|
51
|
+
raise ImportError(msg)
|
|
52
|
+
module = module_from_spec(spec)
|
|
53
|
+
spec.loader.exec_module(module)
|
|
54
|
+
return module.__dict__
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _normalize_delete_items(items: Iterable[str]) -> list[Path]:
|
|
58
|
+
"""Normalize delete file entries (POSIX strings) to platform-native Paths.
|
|
59
|
+
|
|
60
|
+
The helper `extract_delete_items_from_module` already normalizes provider
|
|
61
|
+
outputs (including Path-like objects) to POSIX strings. This function now
|
|
62
|
+
expects strings and will raise TypeError for any other type (fail-fast).
|
|
63
|
+
"""
|
|
64
|
+
paths: list[Path] = []
|
|
65
|
+
for it in items:
|
|
66
|
+
# Accept strings only; other types are errors in fail-fast mode
|
|
67
|
+
if isinstance(it, str):
|
|
68
|
+
p = Path(*PurePosixPath(it).parts)
|
|
69
|
+
paths.append(p)
|
|
70
|
+
continue
|
|
71
|
+
msg = f'Invalid delete_files entry: {it!r}'
|
|
72
|
+
raise TypeError(msg)
|
|
73
|
+
return paths
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _extract_from_module_dict(
|
|
77
|
+
module_dict: dict[str, object],
|
|
78
|
+
name: str,
|
|
79
|
+
*,
|
|
80
|
+
expected_type: type | tuple[type, ...] | None = None,
|
|
81
|
+
allow_callable: bool = True,
|
|
82
|
+
default: object | None = None,
|
|
83
|
+
) -> object | None:
|
|
84
|
+
"""Generic extractor for attributes or factory callables from a module dict.
|
|
85
|
+
|
|
86
|
+
- If the module defines a callable named `name` and `allow_callable` is True,
|
|
87
|
+
it will be invoked and its return value validated against `expected_type`.
|
|
88
|
+
- Otherwise, if the module has a top-level attribute with `name`, that
|
|
89
|
+
value will be returned if it matches `expected_type` (when provided).
|
|
90
|
+
- On any mismatch or exception the `default` is returned.
|
|
91
|
+
"""
|
|
92
|
+
# Prefer a callable factory when present and allowed
|
|
93
|
+
candidate = module_dict.get(name)
|
|
94
|
+
if allow_callable and callable(candidate):
|
|
95
|
+
# If the factory raises, let the exception propagate (fail-fast)
|
|
96
|
+
val = candidate()
|
|
97
|
+
if expected_type is None or isinstance(val, expected_type):
|
|
98
|
+
return val
|
|
99
|
+
msg = f'{name}() returned wrong type: {type(val)!r}'
|
|
100
|
+
raise TypeError(msg)
|
|
101
|
+
|
|
102
|
+
# Fallback to module-level value
|
|
103
|
+
if candidate is None:
|
|
104
|
+
return default
|
|
105
|
+
if expected_type is None or isinstance(candidate, expected_type):
|
|
106
|
+
return candidate
|
|
107
|
+
msg = f'module attribute {name!r} has wrong type: {type(candidate)!r}'
|
|
108
|
+
raise TypeError(msg)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def extract_context_from_module(
|
|
112
|
+
module: str | dict[str, object],
|
|
113
|
+
) -> dict[str, object] | None:
|
|
114
|
+
"""Extract cookiecutter context from a module (path or dict).
|
|
115
|
+
|
|
116
|
+
Accepts either a module path (str) or a preloaded module dict. Returns a
|
|
117
|
+
dict or None if not present/invalid.
|
|
118
|
+
"""
|
|
119
|
+
module_dict = module if isinstance(module, dict) else get_module(str(module))
|
|
120
|
+
ctx = _extract_from_module_dict(
|
|
121
|
+
module_dict,
|
|
122
|
+
'create_context',
|
|
123
|
+
expected_type=dict,
|
|
124
|
+
)
|
|
125
|
+
if isinstance(ctx, dict):
|
|
126
|
+
return ctx
|
|
127
|
+
# Also accept a module-level `context` variable for compatibility
|
|
128
|
+
ctx2 = _extract_from_module_dict(
|
|
129
|
+
module_dict,
|
|
130
|
+
'context',
|
|
131
|
+
expected_type=dict,
|
|
132
|
+
allow_callable=False,
|
|
133
|
+
)
|
|
134
|
+
if isinstance(ctx2, dict):
|
|
135
|
+
return ctx2
|
|
136
|
+
# Missing context is not an error; return None to indicate absence
|
|
137
|
+
logger.warning(
|
|
138
|
+
'create_context_not_found',
|
|
139
|
+
module=(module if isinstance(module, str) else '<module_dict>'),
|
|
140
|
+
)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def extract_anchors_from_module(
|
|
145
|
+
module: str | dict[str, object],
|
|
146
|
+
) -> dict[str, str]:
|
|
147
|
+
"""Extract anchors mapping from a template module (path or dict).
|
|
148
|
+
|
|
149
|
+
Supports either a callable `create_anchors()` or a module-level `anchors` dict.
|
|
150
|
+
Returns an empty dict on failure.
|
|
151
|
+
"""
|
|
152
|
+
module_dict = module if isinstance(module, dict) else get_module(str(module))
|
|
153
|
+
anchors = _extract_from_module_dict(
|
|
154
|
+
module_dict,
|
|
155
|
+
'create_anchors',
|
|
156
|
+
expected_type=dict,
|
|
157
|
+
)
|
|
158
|
+
if isinstance(anchors, dict):
|
|
159
|
+
return anchors
|
|
160
|
+
a_obj = _extract_from_module_dict(
|
|
161
|
+
module_dict,
|
|
162
|
+
'anchors',
|
|
163
|
+
expected_type=dict,
|
|
164
|
+
allow_callable=False,
|
|
165
|
+
)
|
|
166
|
+
if isinstance(a_obj, dict):
|
|
167
|
+
return a_obj
|
|
168
|
+
# Absence of anchors is fine; return empty mapping
|
|
169
|
+
return {}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _normalize_delete_item(item: object) -> str | None:
|
|
173
|
+
# Accept real Path objects
|
|
174
|
+
if isinstance(item, Path):
|
|
175
|
+
return item.as_posix()
|
|
176
|
+
if isinstance(item, str):
|
|
177
|
+
return item
|
|
178
|
+
# Anything else is an explicit error in fail-fast mode
|
|
179
|
+
msg = f'Invalid delete_files entry: {item!r}'
|
|
180
|
+
raise TypeError(msg)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _normalize_delete_iterable(items: Iterable[object]) -> list[str]:
|
|
184
|
+
"""Normalize an iterable of delete items (Path or str) to POSIX strings.
|
|
185
|
+
|
|
186
|
+
Returns an empty list for non-iterables or when no valid items are found.
|
|
187
|
+
"""
|
|
188
|
+
out: list[str] = []
|
|
189
|
+
if not items:
|
|
190
|
+
return out
|
|
191
|
+
# Iteration errors should propagate (fail-fast)
|
|
192
|
+
for it in items:
|
|
193
|
+
n = _normalize_delete_item(it)
|
|
194
|
+
if n:
|
|
195
|
+
out.append(n)
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def extract_delete_items_from_module(
|
|
200
|
+
module: str | dict[str, object],
|
|
201
|
+
) -> list[str]:
|
|
202
|
+
"""Extract raw delete-file entries (POSIX strings) from a module path or dict.
|
|
203
|
+
|
|
204
|
+
Supports a callable `create_delete_files()` returning a list/tuple or a
|
|
205
|
+
module-level `delete_files`. Returns a list of POSIX-style strings. Exceptions
|
|
206
|
+
are logged and the function returns an empty list on failure.
|
|
207
|
+
"""
|
|
208
|
+
module_dict = module if isinstance(module, dict) else get_module(str(module))
|
|
209
|
+
|
|
210
|
+
df = _extract_from_module_dict(
|
|
211
|
+
module_dict,
|
|
212
|
+
'create_delete_files',
|
|
213
|
+
expected_type=(list, tuple),
|
|
214
|
+
)
|
|
215
|
+
# df may be None or a list/tuple — only treat it as iterable when it's
|
|
216
|
+
# actually a sequence. This narrows the type for the static checker.
|
|
217
|
+
if isinstance(df, (list, tuple)):
|
|
218
|
+
# Normalization raises on bad entries in fail-fast mode
|
|
219
|
+
return _normalize_delete_iterable(df)
|
|
220
|
+
|
|
221
|
+
raw_res = _extract_from_module_dict(
|
|
222
|
+
module_dict,
|
|
223
|
+
'delete_files',
|
|
224
|
+
expected_type=(list, tuple),
|
|
225
|
+
allow_callable=False,
|
|
226
|
+
)
|
|
227
|
+
raw = raw_res if isinstance(raw_res, (list, tuple)) else []
|
|
228
|
+
return _normalize_delete_iterable(raw)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _apply_raw_delete_items(
|
|
232
|
+
delete_set: set[Path],
|
|
233
|
+
raw_items: Iterable[object],
|
|
234
|
+
fallback: list[Path],
|
|
235
|
+
provider_id: str,
|
|
236
|
+
history: dict[str, list[Decision]],
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Apply provider-supplied raw delete items to the delete_set.
|
|
239
|
+
|
|
240
|
+
raw_items: the original module-level `delete_files` value (may contain
|
|
241
|
+
'!' prefixed strings to indicate negation). fallback: normalized Path list
|
|
242
|
+
produced when a provider returned create_delete_files().
|
|
243
|
+
"""
|
|
244
|
+
# Normalize raw_items (they may contain Path objects when defined at
|
|
245
|
+
# module-level). Prefer normalized raw_items; if none, fall back to the
|
|
246
|
+
# normalized fallback produced from create_delete_files().
|
|
247
|
+
# Collect normalized delete-strings from raw_items (fail-fast if a
|
|
248
|
+
# normalizer raises). Use a comprehension to reduce branching.
|
|
249
|
+
items = [n for it in raw_items for n in (_normalize_delete_item(it),) if n] if raw_items else []
|
|
250
|
+
|
|
251
|
+
# If provider didn't supply module-level raw items, fall back to the
|
|
252
|
+
# normalized list produced from create_delete_files().
|
|
253
|
+
if not items:
|
|
254
|
+
items = [p.as_posix() for p in fallback]
|
|
255
|
+
|
|
256
|
+
for raw in items:
|
|
257
|
+
neg = raw.startswith('!')
|
|
258
|
+
entry = raw[1:] if neg else raw
|
|
259
|
+
p = Path(*PurePosixPath(entry).parts)
|
|
260
|
+
key = p.as_posix()
|
|
261
|
+
# record provenance for this provider decision
|
|
262
|
+
history.setdefault(key, []).append(
|
|
263
|
+
Decision(
|
|
264
|
+
source=provider_id,
|
|
265
|
+
action=(Action.keep if neg else Action.delete),
|
|
266
|
+
),
|
|
267
|
+
)
|
|
268
|
+
# single call selected by neg flag (discard is a no-op if missing)
|
|
269
|
+
(delete_set.discard if neg else delete_set.add)(p)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _process_provider_dict( # noqa: PLR0913 - helper function with many args
|
|
273
|
+
module_dict: dict[str, object],
|
|
274
|
+
merged_context: dict[str, object],
|
|
275
|
+
merged_anchors: dict[str, str],
|
|
276
|
+
delete_set: set[Path],
|
|
277
|
+
provider_id: str,
|
|
278
|
+
history: dict[str, list[Decision]],
|
|
279
|
+
) -> None:
|
|
280
|
+
"""Merge a loaded provider module's contributions into the accumulators.
|
|
281
|
+
|
|
282
|
+
This helper operates on a preloaded module dict so callers can handle
|
|
283
|
+
loading and error handling separately.
|
|
284
|
+
"""
|
|
285
|
+
ctx = extract_context_from_module(module_dict) or {}
|
|
286
|
+
anchors = extract_anchors_from_module(module_dict) or {}
|
|
287
|
+
raw_delete_items = extract_delete_items_from_module(module_dict)
|
|
288
|
+
delete_files = _normalize_delete_items(raw_delete_items)
|
|
289
|
+
|
|
290
|
+
if ctx:
|
|
291
|
+
merged_context.update(ctx)
|
|
292
|
+
if anchors:
|
|
293
|
+
merged_anchors.update(anchors)
|
|
294
|
+
|
|
295
|
+
raw_items = module_dict.get('delete_files') or []
|
|
296
|
+
# Ensure raw_items is a concrete iterable (list/tuple) for type checking
|
|
297
|
+
raw_items_seq = raw_items if isinstance(raw_items, (list, tuple)) else [raw_items]
|
|
298
|
+
_apply_raw_delete_items(
|
|
299
|
+
delete_set,
|
|
300
|
+
raw_items_seq,
|
|
301
|
+
delete_files,
|
|
302
|
+
provider_id,
|
|
303
|
+
history,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def create_providers(directories: list[str]) -> Providers:
|
|
308
|
+
"""Load all template providers and merge their contributions.
|
|
309
|
+
|
|
310
|
+
Merging semantics:
|
|
311
|
+
- context: dicts are merged in order; later providers override earlier keys.
|
|
312
|
+
- anchors: dicts are merged in order; later providers override earlier keys.
|
|
313
|
+
- delete_files: providers supply Path entries; an entry prefixed with a
|
|
314
|
+
leading '!' (literal leading char in the original string) will act as an
|
|
315
|
+
undo for that path (i.e., prevent deletion). The loader will apply
|
|
316
|
+
additions/removals in provider order.
|
|
317
|
+
"""
|
|
318
|
+
merged_context: dict[str, object] = {}
|
|
319
|
+
merged_anchors: dict[str, str] = {}
|
|
320
|
+
delete_set: set[Path] = set()
|
|
321
|
+
|
|
322
|
+
# provenance history: posix path -> list of Decision instances
|
|
323
|
+
history: dict[str, list[Decision]] = {}
|
|
324
|
+
for directory in directories:
|
|
325
|
+
module_path = Path(directory) / 'repolish.py'
|
|
326
|
+
module_dict = get_module(str(module_path))
|
|
327
|
+
provider_id = Path(directory).as_posix()
|
|
328
|
+
_process_provider_dict(
|
|
329
|
+
module_dict,
|
|
330
|
+
merged_context,
|
|
331
|
+
merged_anchors,
|
|
332
|
+
delete_set,
|
|
333
|
+
provider_id,
|
|
334
|
+
history,
|
|
335
|
+
)
|
|
336
|
+
return Providers(
|
|
337
|
+
context=merged_context,
|
|
338
|
+
anchors=merged_anchors,
|
|
339
|
+
delete_files=list(delete_set),
|
|
340
|
+
delete_history=history,
|
|
341
|
+
)
|
repolish/processors.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Patterns:
|
|
8
|
+
"""Container for extracted patterns from content."""
|
|
9
|
+
|
|
10
|
+
tag_blocks: dict[str, str]
|
|
11
|
+
regexes: dict[str, str]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def extract_patterns(content: str) -> Patterns:
|
|
15
|
+
"""Extracts text blocks and regex patterns from the given content.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
content: The input string containing text blocks and regex patterns.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A Patterns object containing extracted tag blocks and regexes.
|
|
22
|
+
"""
|
|
23
|
+
# Accept markers with optional prefixes (e.g. "## ", "<!-- ", "/* ") so
|
|
24
|
+
# templates can use comment syntax appropriate to the file type. We match a
|
|
25
|
+
# whole start line that contains `repolish-start[name]`, capture the
|
|
26
|
+
# following block, and then match the corresponding end line.
|
|
27
|
+
tag_pattern = re.compile(
|
|
28
|
+
# allow empty inner block (no extra blank line required before end)
|
|
29
|
+
r'^[^\n]*repolish-start\[(.+?)\][^\n]*\n(.*?)[^\n]*repolish-end\[\1\][^\n]*',
|
|
30
|
+
re.DOTALL | re.MULTILINE,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Match regex declarations likewise with optional prefixes on the same line
|
|
34
|
+
regex_pattern = re.compile(
|
|
35
|
+
r'^[^\n]*repolish-regex\[(.+?)\]: (.*?)\n',
|
|
36
|
+
re.DOTALL | re.MULTILINE,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Return the raw inner block content (no artificial padding). Strip any
|
|
40
|
+
# leading/trailing newlines that are an artifact of how templates were
|
|
41
|
+
# authored so callers get the pure inner text.
|
|
42
|
+
raw_tag_blocks = dict(tag_pattern.findall(content))
|
|
43
|
+
tag_blocks: dict[str, str] = {}
|
|
44
|
+
for k, v in raw_tag_blocks.items():
|
|
45
|
+
tag_blocks[k] = v.strip('\n')
|
|
46
|
+
|
|
47
|
+
return Patterns(
|
|
48
|
+
tag_blocks=tag_blocks,
|
|
49
|
+
regexes=dict(regex_pattern.findall(content)),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def safe_file_read(file_path: Path) -> str:
|
|
54
|
+
"""Safely reads the content of a file if it exists.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
file_path: Path to the file to read.
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
The content of the file, or an empty string if the file does not exist.
|
|
61
|
+
"""
|
|
62
|
+
if file_path.exists() and file_path.is_file():
|
|
63
|
+
return file_path.read_text()
|
|
64
|
+
return ''
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def replace_tags_in_content(content: str, tags: dict[str, str]) -> str:
|
|
68
|
+
"""Replaces tag blocks in the content with provided tag values.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
content: The original content containing tag blocks.
|
|
72
|
+
tags: A dictionary mapping tag names to their replacement values.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
The content with the tags replaced by their corresponding values.
|
|
76
|
+
"""
|
|
77
|
+
for tag, value in tags.items():
|
|
78
|
+
# Build a pattern that matches a whole start line containing the token
|
|
79
|
+
# `repolish-start[tag]`, then captures the inner block, then matches
|
|
80
|
+
# the end line containing `repolish-end[tag]`. This allows comment
|
|
81
|
+
# prefixes/suffixes on the marker lines.
|
|
82
|
+
# Match entire block including optional leading/trailing newline so
|
|
83
|
+
# the replacement doesn't leave extra blank lines.
|
|
84
|
+
pattern = re.compile(
|
|
85
|
+
r'\n?[^\n]*repolish-start\[' + re.escape(tag) + r'\][^\n]*\n'
|
|
86
|
+
r'(.*?)[^\n]*repolish-end\[' + re.escape(tag) + r'\][^\n]*\n?',
|
|
87
|
+
re.DOTALL | re.MULTILINE,
|
|
88
|
+
)
|
|
89
|
+
content = pattern.sub(lambda _m, v=value: f'\n{v}\n', content)
|
|
90
|
+
return content
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def apply_regex_replacements(
|
|
94
|
+
content: str,
|
|
95
|
+
regexes: dict[str, str],
|
|
96
|
+
local_file_content: str,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Applies regex replacements to the content."""
|
|
99
|
+
regex_pattern = re.compile(
|
|
100
|
+
r'^.*## repolish-regex\[(.+?)\]:.*\n?',
|
|
101
|
+
re.MULTILINE,
|
|
102
|
+
)
|
|
103
|
+
content = regex_pattern.sub('', content)
|
|
104
|
+
|
|
105
|
+
# apply regex replacements
|
|
106
|
+
for regex_pattern in regexes.values():
|
|
107
|
+
pattern = re.compile(rf'{regex_pattern}', re.MULTILINE)
|
|
108
|
+
matches = pattern.search(local_file_content)
|
|
109
|
+
if matches:
|
|
110
|
+
content = pattern.sub(rf'{matches.group(0)}', content)
|
|
111
|
+
return content
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def replace_text(
|
|
115
|
+
template_content: str,
|
|
116
|
+
local_content: str,
|
|
117
|
+
anchors_dictionary: dict[str, str] | None = None,
|
|
118
|
+
) -> str:
|
|
119
|
+
"""Replaces tag blocks and regex patterns in the template content.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
template_content: The content of the template file.
|
|
123
|
+
local_content: The content of the local file to extract patterns from.
|
|
124
|
+
anchors_dictionary: Optional dictionary of anchor replacements provided by
|
|
125
|
+
configuration (maps tag name -> replacement text). If provided, values
|
|
126
|
+
in this dict will be used to replace corresponding `## repolish-start[...]` blocks
|
|
127
|
+
in the template. If not provided, the template's own block contents are
|
|
128
|
+
preserved.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
The modified template content with replaced tag blocks and regex patterns.
|
|
132
|
+
"""
|
|
133
|
+
patterns = extract_patterns(template_content)
|
|
134
|
+
|
|
135
|
+
# Build the replacement mapping for tag blocks. If an anchors dictionary is
|
|
136
|
+
# provided, use its values to replace the corresponding tag blocks. Otherwise
|
|
137
|
+
# fall back to the template's own block content (i.e. leave defaults).
|
|
138
|
+
tags_to_replace: dict[str, str] = {}
|
|
139
|
+
for tag, default_value in patterns.tag_blocks.items():
|
|
140
|
+
if anchors_dictionary and tag in anchors_dictionary:
|
|
141
|
+
tags_to_replace[tag] = anchors_dictionary[tag]
|
|
142
|
+
else:
|
|
143
|
+
tags_to_replace[tag] = default_value
|
|
144
|
+
|
|
145
|
+
content = replace_tags_in_content(template_content, tags_to_replace)
|
|
146
|
+
return apply_regex_replacements(content, patterns.regexes, local_content)
|
repolish/py.typed
ADDED
|
File without changes
|
repolish/version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.1.0'
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: repolish
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Maintain consistency across repositories
|
|
5
|
+
Author: hotdog-werx
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Requires-Dist: cookiecutter>=2.6.0
|
|
9
|
+
Requires-Dist: hotlog>=0.1.1
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# repolish
|
|
13
|
+
|
|
14
|
+
> Repolish is a hybrid of templating and diff/patch systems, useful for
|
|
15
|
+
> maintaining repo consistency while allowing local customizations. It uses
|
|
16
|
+
> templates with placeholders that can be filled from a context, and regex
|
|
17
|
+
> patterns to preserve existing local content in files.
|
|
18
|
+
|
|
19
|
+
## Why this exists
|
|
20
|
+
|
|
21
|
+
Teams often need to enforce repository-level conventions (CI config, build
|
|
22
|
+
tools, metadata, common docs) while letting individual projects keep local
|
|
23
|
+
customizations. The naive approaches are painful:
|
|
24
|
+
|
|
25
|
+
- Copying templates into many repos means drift over time and manual syncs.
|
|
26
|
+
- Running destructive templating can overwrite local changes developers rely on.
|
|
27
|
+
|
|
28
|
+
Repolish solves this by combining templating (to generate canonical files) with
|
|
29
|
+
a set of careful, reversible operations that preserve useful local content.
|
|
30
|
+
Instead of blindly replacing files, Repolish can:
|
|
31
|
+
|
|
32
|
+
- Fill placeholders from provider-supplied context.
|
|
33
|
+
- Apply anchor-driven replacements to keep developer-customized sections.
|
|
34
|
+
- Track provider-specified deletions and record provenance so reviewers can see
|
|
35
|
+
_why_ a path was requested for deletion.
|
|
36
|
+
|
|
37
|
+
## Design overview
|
|
38
|
+
|
|
39
|
+
Key concepts:
|
|
40
|
+
|
|
41
|
+
- Providers (templates): Each provider lives in a template directory and may
|
|
42
|
+
include a `repolish.py` module that exports `create_context()`,
|
|
43
|
+
`create_anchors()`, and/or `create_delete_files()` helpers. Providers supply
|
|
44
|
+
cookiecutter context and may indicate files that should be removed from a
|
|
45
|
+
project.
|
|
46
|
+
- Anchors: A small markup syntax placed in templates (and optionally in project
|
|
47
|
+
files) that marks blocks or regex lines to preserve. Examples:
|
|
48
|
+
- Block anchors: `## repolish-start[readme]` ... `repolish-end[readme]`
|
|
49
|
+
- Regex anchors: `## repolish-regex[keep]: ^important=.*` The processors use
|
|
50
|
+
these anchors to replace or merge the template content with the local
|
|
51
|
+
project file while preserving the parts marked with anchors.
|
|
52
|
+
- Delete semantics: Providers can request deletions using POSIX-style paths. A
|
|
53
|
+
`!` prefix acts as a negation (keep). Config-level `delete_files` are applied
|
|
54
|
+
last and recorded in provenance.
|
|
55
|
+
- Provenance: Repolish records a `delete_history` mapping that stores, for each
|
|
56
|
+
candidate path, a list of decisions (which provider or config requested a
|
|
57
|
+
delete or a keep). This helps reviewers and automation understand why a path
|
|
58
|
+
was flagged.
|
|
59
|
+
|
|
60
|
+
## How it works (high level)
|
|
61
|
+
|
|
62
|
+
1. Load providers configured in `repolish.yaml` (or the default config).
|
|
63
|
+
2. Merge provider contexts; config-level context overrides provider values.
|
|
64
|
+
3. Merge anchors from providers and config.
|
|
65
|
+
4. Stage all provider template directories into a single cookiecutter template
|
|
66
|
+
(adjacent to the config under `.repolish/setup-input`).
|
|
67
|
+
5. Preprocess staged templates by applying anchor-driven replacements using
|
|
68
|
+
local project files (looked up relative to the config location).
|
|
69
|
+
6. Render the merged cookiecutter template once into `.repolish/setup-output`.
|
|
70
|
+
7. In `--check` mode: compare generated files to project files and report either
|
|
71
|
+
diffs, missing files, or paths that providers wanted deleted but which are
|
|
72
|
+
still present.
|
|
73
|
+
8. In apply mode: copy generated files into the project and apply deletions as
|
|
74
|
+
the final step.
|
|
75
|
+
|
|
76
|
+
## Example usage
|
|
77
|
+
|
|
78
|
+
repolish.yaml (simple example):
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
directories:
|
|
82
|
+
- ./templates/template_a
|
|
83
|
+
- ./templates/template_b
|
|
84
|
+
context: {}
|
|
85
|
+
anchors: {}
|
|
86
|
+
delete_files: []
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Run a dry-run check (useful for CI):
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
repolish --check --config repolish.yaml
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
This will produce structured logs that include:
|
|
96
|
+
|
|
97
|
+
- The merged provider `context` and `delete_paths` (so you can see what was
|
|
98
|
+
requested).
|
|
99
|
+
- A `check_result` listing per-path diffs or deletion warnings like
|
|
100
|
+
`PRESENT_BUT_SHOULD_BE_DELETED`.
|
|
101
|
+
|
|
102
|
+
## Processor story (anchors)
|
|
103
|
+
|
|
104
|
+
We iterated on preserving local file semantics and landed on a simple, explicit
|
|
105
|
+
anchor-based system. Anchors are easy for template authors to add and for
|
|
106
|
+
maintainers to reason about:
|
|
107
|
+
|
|
108
|
+
- Block anchors allow entire sections of a file to be preserved or replaced
|
|
109
|
+
while keeping the surrounding template-driven structure.
|
|
110
|
+
- Regex anchors can mark single lines or patterns to keep (useful for
|
|
111
|
+
maintainer-inserted keys or comments that should survive templating).
|
|
112
|
+
|
|
113
|
+
Anchors are processed in staging before cookiecutter runs, so the generated
|
|
114
|
+
output already reflects local overrides while still taking canonical values from
|
|
115
|
+
templates when needed.
|
|
116
|
+
|
|
117
|
+
## How do I add anchors?
|
|
118
|
+
|
|
119
|
+
Anchors are intentionally simple so template authors and maintainers can reason
|
|
120
|
+
about them easily. There are two primary forms:
|
|
121
|
+
|
|
122
|
+
- Block anchors mark a named section to preserve or replace between
|
|
123
|
+
`repolish-start[...]` and `repolish-end[...]` markers. Use them for multi-line
|
|
124
|
+
sections such as README snippets, install blocks, or long descriptions.
|
|
125
|
+
- Regex anchors mark single-line patterns to keep using a regular expression.
|
|
126
|
+
They are useful when you want to preserve a line that follows a predictable
|
|
127
|
+
pattern (version lines, keys, simple single-line edits).
|
|
128
|
+
|
|
129
|
+
Below are two practical examples you can copy into templates and projects.
|
|
130
|
+
|
|
131
|
+
Dockerfile (block anchor)
|
|
132
|
+
|
|
133
|
+
Template (templates/template_a/Dockerfile):
|
|
134
|
+
|
|
135
|
+
```dockerfile
|
|
136
|
+
# base image
|
|
137
|
+
FROM python:3.11-slim
|
|
138
|
+
|
|
139
|
+
## repolish-start[install]
|
|
140
|
+
# install system deps
|
|
141
|
+
RUN apt-get update && apt-get install -y build-essential libssl-dev
|
|
142
|
+
## repolish-end[install]
|
|
143
|
+
|
|
144
|
+
# copy + install python deps
|
|
145
|
+
COPY pyproject.toml .
|
|
146
|
+
RUN pip install --no-cache-dir .
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Project Dockerfile (local override) — developer has custom install needs:
|
|
150
|
+
|
|
151
|
+
```dockerfile
|
|
152
|
+
FROM python:3.11-slim
|
|
153
|
+
|
|
154
|
+
## repolish-start[install]
|
|
155
|
+
# custom build deps for project X
|
|
156
|
+
RUN apt-get update && apt-get install -y locales libpq-dev
|
|
157
|
+
## repolish-end[install]
|
|
158
|
+
|
|
159
|
+
# copy + install python deps
|
|
160
|
+
COPY pyproject.toml .
|
|
161
|
+
RUN pip install --no-cache-dir .
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
When Repolish runs its preprocessing, the `install` block from the local project
|
|
165
|
+
will be preserved in the staged template (so the generated output keeps the
|
|
166
|
+
local custom `RUN` command), while the rest of the Dockerfile comes from the
|
|
167
|
+
template.
|
|
168
|
+
|
|
169
|
+
pyproject.toml (regex anchor + block anchor)
|
|
170
|
+
|
|
171
|
+
Template (templates/template_a/pyproject.toml):
|
|
172
|
+
|
|
173
|
+
```toml
|
|
174
|
+
[tool.poetry]
|
|
175
|
+
name = "{{ cookiecutter.package_name }}"
|
|
176
|
+
version = "0.1.0"
|
|
177
|
+
## repolish-regex[keep]: ^version\s*=\s*".*"
|
|
178
|
+
|
|
179
|
+
description = "A short description"
|
|
180
|
+
|
|
181
|
+
## repolish-start[extra-deps]
|
|
182
|
+
# optional extra deps (preserved when present)
|
|
183
|
+
## repolish-end[extra-deps]
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Project pyproject.toml (developer bumped version and added extras):
|
|
187
|
+
|
|
188
|
+
```toml
|
|
189
|
+
[tool.poetry]
|
|
190
|
+
name = "myproj"
|
|
191
|
+
version = "0.2.0"
|
|
192
|
+
|
|
193
|
+
description = "Local project description"
|
|
194
|
+
|
|
195
|
+
## repolish-start[extra-deps]
|
|
196
|
+
requests = "^2.30"
|
|
197
|
+
## repolish-end[extra-deps]
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
In this example the `## repolish-regex[keep]: ^version\s*=\s*".*"` anchor
|
|
201
|
+
ensures the local `version = "0.2.0"` line is preserved instead of being
|
|
202
|
+
replaced by the template's `0.1.0`. The `extra-deps` block is preserved
|
|
203
|
+
whole-cloth when present, letting projects keep local dependency additions.
|
|
204
|
+
|
|
205
|
+
Notes and tips
|
|
206
|
+
|
|
207
|
+
- Use meaningful anchor names (e.g., `install`, `readme`, `extra-deps`) so
|
|
208
|
+
reviewers immediately understand the preserved section's intent.
|
|
209
|
+
- Regex anchors are applied line-by-line; prefer anchoring to a simple, easy to
|
|
210
|
+
read pattern to avoid surprises.
|
|
211
|
+
- Anchors are processed before cookiecutter rendering, so template substitutions
|
|
212
|
+
still work around preserved sections.
|
|
213
|
+
|
|
214
|
+
### Where anchors are declared and uniqueness
|
|
215
|
+
|
|
216
|
+
Anchors can come from three places (and are merged in this order):
|
|
217
|
+
|
|
218
|
+
1. Provider templates: any `## repolish-start[...]` / `## repolish-regex[...]`
|
|
219
|
+
markers present inside the provider's template files.
|
|
220
|
+
2. Provider code: a provider's `create_anchors()` callable can return an anchors
|
|
221
|
+
mapping (key -> replacement text) used during preprocessing.
|
|
222
|
+
3. Config-level anchors: the `anchors` mapping in `repolish.yaml` applies last
|
|
223
|
+
and can be used to override or add anchor values.
|
|
224
|
+
|
|
225
|
+
When anchors are merged, later sources override earlier ones (config wins).
|
|
226
|
+
Anchor keys must be unique across the whole merged template set — keys are
|
|
227
|
+
global identifiers used to find matching `repolish-start[...]` blocks or
|
|
228
|
+
`repolish-regex[...]` declarations. If two different template files (or
|
|
229
|
+
providers) use the same anchor key, the later provider's value will override the
|
|
230
|
+
earlier one, which can produce surprising results.
|
|
231
|
+
|
|
232
|
+
Example conflict
|
|
233
|
+
|
|
234
|
+
Two provider templates accidentally use the same anchor key `init`:
|
|
235
|
+
|
|
236
|
+
- `templates/a/Dockerfile` contains `## repolish-start[init]` …
|
|
237
|
+
`## repolish-end[init]`
|
|
238
|
+
- `templates/b/README.md` also contains `## repolish-start[init]` …
|
|
239
|
+
`## repolish-end[init]`
|
|
240
|
+
|
|
241
|
+
Because anchor keys are merged globally, the `init` block from the provider that
|
|
242
|
+
is processed later will replace (or be used in place of) the other one. That may
|
|
243
|
+
not be what you want — for predictable behavior, choose anchor keys scoped to
|
|
244
|
+
the file or the provider, e.g. `docker-install` or `readme-intro`.
|
|
245
|
+
|
|
246
|
+
Best practice: prefix anchor keys with the file or provider name when the
|
|
247
|
+
content is file-scoped. This avoids accidental collisions when multiple
|
|
248
|
+
providers contribute templates that contain similarly-named sections.
|
|
249
|
+
|
|
250
|
+
## Why this is useful
|
|
251
|
+
|
|
252
|
+
- Safe consistency: teams get centralized templates without forcing destructive,
|
|
253
|
+
manual rollouts.
|
|
254
|
+
- Clear explainability: the `delete_history` provenance makes it easy to review
|
|
255
|
+
why a file was targeted for deletion or kept.
|
|
256
|
+
- CI-friendly: `--check` can be run in CI to detect drift; logs and diffs make
|
|
257
|
+
it straightforward to require PRs to run repolish before merging.
|
|
258
|
+
|
|
259
|
+
## Final notes
|
|
260
|
+
|
|
261
|
+
Repolish is intentionally small and composable. If you need per-file log
|
|
262
|
+
artifacts, or slightly different merge rules, the processors and cookiecutter
|
|
263
|
+
helpers are isolated so you can adapt them safely.
|
|
264
|
+
|
|
265
|
+
Contributions and issues are welcome — see the test-suite for practical examples
|
|
266
|
+
of how the system behaves.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
repolish/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
repolish/builder.py,sha256=Fw5WFNPYtgxtA_s1eTRS5axJajCmUrOvn1YB-2kZgh0,1734
|
|
3
|
+
repolish/cli.py,sha256=PkqvxzLLAG42gE61kKdGFqZI5Y3hkgfbclugFe95hKw,3721
|
|
4
|
+
repolish/config.py,sha256=r1qDGfE2l3B3nj_6fdjfRTMa8gl9rwbSixr_52llD9Y,4998
|
|
5
|
+
repolish/cookiecutter.py,sha256=rG-rFT6OPSoyx94Wbr8hDdNnVPrf7SUpq_85UUT_ebI,8837
|
|
6
|
+
repolish/loader.py,sha256=30si4o6ZMv91R_FSfjrxoFnPKFj6Y34KxJlSO8ZMiSQ,11906
|
|
7
|
+
repolish/processors.py,sha256=EsqeKcYT20Bmp9cJRDpm7Lb-jo8CZOIlwEIxMvqVYBA,5424
|
|
8
|
+
repolish/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
repolish/version.py,sha256=IMjkMO3twhQzluVTo8Z6rE7Eg-9U79_LGKMcsWLKBkY,22
|
|
10
|
+
repolish-0.1.0.dist-info/METADATA,sha256=RKse-jvSILh7u38iS4wW37OsLzw_DjhNdoMImeZBs84,10088
|
|
11
|
+
repolish-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
12
|
+
repolish-0.1.0.dist-info/entry_points.txt,sha256=qhTr2bkdwLoSjdsoM-LxEjZOooCIowLNrN7vBepJ2P8,47
|
|
13
|
+
repolish-0.1.0.dist-info/RECORD,,
|