pc-init 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.
- gpc_init/__init__.py +1 -0
- gpc_init/about.py +3 -0
- gpc_init/cli.py +204 -0
- gpc_init/exceptions.py +49 -0
- gpc_init/fetcher.py +63 -0
- gpc_init/framework/bevy/preset.yaml +13 -0
- gpc_init/framework/django/preset.yaml +8 -0
- gpc_init/framework/react/preset.yaml +15 -0
- gpc_init/lang/common/default.yaml +23 -0
- gpc_init/lang/go/baseline.yaml +5 -0
- gpc_init/lang/js/baseline.yaml +14 -0
- gpc_init/lang/md/baseline.yaml +9 -0
- gpc_init/lang/py/baseline.yaml +38 -0
- gpc_init/lang/ru/baseline.yaml +28 -0
- gpc_init/lang/toml/baseline.yaml +0 -0
- gpc_init/lang/yaml/baseline.yaml +6 -0
- gpc_init/loader.py +93 -0
- gpc_init/merger.py +148 -0
- gpc_init/profiles.py +63 -0
- gpc_init/renderer.py +28 -0
- gpc_init/resolver.py +155 -0
- pc_init-0.1.0.dist-info/METADATA +743 -0
- pc_init-0.1.0.dist-info/RECORD +27 -0
- pc_init-0.1.0.dist-info/WHEEL +5 -0
- pc_init-0.1.0.dist-info/entry_points.txt +2 -0
- pc_init-0.1.0.dist-info/licenses/LICENSE +674 -0
- pc_init-0.1.0.dist-info/top_level.txt +1 -0
gpc_init/merger.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Merge language and framework presets into a single configuration dict."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _repo_key(repo_entry: dict[str, Any]) -> tuple[str, str]:
|
|
7
|
+
"""Return a (repo, rev) identity key for a repo entry."""
|
|
8
|
+
return (str(repo_entry.get("repo", "")), str(repo_entry.get("rev", "")))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _merge_hook(lower: dict[str, Any], higher: dict[str, Any]) -> dict[str, Any]:
|
|
12
|
+
"""
|
|
13
|
+
Merge two hook dicts: higher-precedence fields replace lower-precedence fields.
|
|
14
|
+
|
|
15
|
+
The hook id and position come from the lower layer; all other fields from
|
|
16
|
+
the higher layer override the lower layer.
|
|
17
|
+
"""
|
|
18
|
+
return {**lower, **higher}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _merge_hooks_list(
|
|
22
|
+
lower: list[dict[str, Any]], higher: list[dict[str, Any]]
|
|
23
|
+
) -> list[dict[str, Any]]:
|
|
24
|
+
"""
|
|
25
|
+
Merge two hook lists by hook id.
|
|
26
|
+
|
|
27
|
+
- Preserves first-seen order from the lower-precedence layer.
|
|
28
|
+
- Appends new hook ids from the higher-precedence layer.
|
|
29
|
+
- When the same hook id appears in both, higher-precedence fields
|
|
30
|
+
replace lower fields.
|
|
31
|
+
"""
|
|
32
|
+
result: list[dict[str, Any]] = []
|
|
33
|
+
lower_by_id: dict[str, int] = {}
|
|
34
|
+
for i, hook in enumerate(lower):
|
|
35
|
+
hook_id = str(hook.get("id", ""))
|
|
36
|
+
lower_by_id[hook_id] = i
|
|
37
|
+
result.append(dict(hook))
|
|
38
|
+
|
|
39
|
+
for hook in higher:
|
|
40
|
+
hook_id = str(hook.get("id", ""))
|
|
41
|
+
if hook_id in lower_by_id:
|
|
42
|
+
idx = lower_by_id[hook_id]
|
|
43
|
+
result[idx] = _merge_hook(result[idx], hook)
|
|
44
|
+
else:
|
|
45
|
+
result.append(dict(hook))
|
|
46
|
+
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _merge_repos(
|
|
51
|
+
lower: list[dict[str, Any]], higher: list[dict[str, Any]]
|
|
52
|
+
) -> list[dict[str, Any]]:
|
|
53
|
+
"""
|
|
54
|
+
Merge two repos lists by (repo, rev) key.
|
|
55
|
+
|
|
56
|
+
- Preserves first-seen order from the lower-precedence layer.
|
|
57
|
+
- Appends new (repo, rev) pairs from the higher-precedence layer.
|
|
58
|
+
- When the same (repo, rev) pair appears in both, hooks are merged by hook id.
|
|
59
|
+
"""
|
|
60
|
+
result: list[dict[str, Any]] = []
|
|
61
|
+
lower_by_key: dict[tuple[str, str], int] = {}
|
|
62
|
+
for i, repo in enumerate(lower):
|
|
63
|
+
key = _repo_key(repo)
|
|
64
|
+
lower_by_key[key] = i
|
|
65
|
+
result.append(dict(repo))
|
|
66
|
+
|
|
67
|
+
for repo in higher:
|
|
68
|
+
key = _repo_key(repo)
|
|
69
|
+
if key in lower_by_key:
|
|
70
|
+
idx = lower_by_key[key]
|
|
71
|
+
merged_repo = dict(result[idx])
|
|
72
|
+
lower_hooks = list(result[idx].get("hooks", []))
|
|
73
|
+
higher_hooks = list(repo.get("hooks", []))
|
|
74
|
+
merged_repo["hooks"] = _merge_hooks_list(lower_hooks, higher_hooks)
|
|
75
|
+
result[idx] = merged_repo
|
|
76
|
+
else:
|
|
77
|
+
result.append(dict(repo))
|
|
78
|
+
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _deep_merge_top_level(
|
|
83
|
+
lower: dict[str, Any], higher: dict[str, Any]
|
|
84
|
+
) -> dict[str, Any]:
|
|
85
|
+
"""
|
|
86
|
+
Deep-merge two top-level dicts (excluding 'repos').
|
|
87
|
+
|
|
88
|
+
Higher-precedence values override lower-precedence values on key conflicts.
|
|
89
|
+
Nested dicts are recursively merged; other types are replaced by higher value.
|
|
90
|
+
"""
|
|
91
|
+
merged: dict[str, Any] = dict(lower)
|
|
92
|
+
for key, value in higher.items():
|
|
93
|
+
if key == "repos":
|
|
94
|
+
continue
|
|
95
|
+
if key in merged and isinstance(merged[key], dict) and isinstance(value, dict):
|
|
96
|
+
merged[key] = _deep_merge_top_level(merged[key], value)
|
|
97
|
+
else:
|
|
98
|
+
merged[key] = value
|
|
99
|
+
return merged
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def merge_presets(
|
|
103
|
+
common: dict[str, Any],
|
|
104
|
+
langs: list[dict[str, Any]],
|
|
105
|
+
frameworks: list[dict[str, Any]],
|
|
106
|
+
) -> dict[str, Any]:
|
|
107
|
+
"""
|
|
108
|
+
Merge preset dicts in deterministic order.
|
|
109
|
+
|
|
110
|
+
Merge order (lowest to highest precedence):
|
|
111
|
+
1. Common preset
|
|
112
|
+
2. Language presets in CLI input order
|
|
113
|
+
3. Framework presets in CLI input order
|
|
114
|
+
|
|
115
|
+
For top-level 'repos' key: entries are merged by (repo, rev) key.
|
|
116
|
+
For other top-level keys: higher-precedence values override lower.
|
|
117
|
+
Framework metadata keys (e.g. 'primary_languages') are excluded from output.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
common: Common baseline preset dict.
|
|
121
|
+
langs: Ordered list of language preset dicts.
|
|
122
|
+
frameworks: Ordered list of framework preset dicts.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Merged configuration dict ready for YAML rendering.
|
|
126
|
+
|
|
127
|
+
"""
|
|
128
|
+
layers: list[dict[str, Any]] = [common, *langs, *frameworks]
|
|
129
|
+
result: dict[str, Any] = {}
|
|
130
|
+
merged_repos: list[dict[str, Any]] = []
|
|
131
|
+
|
|
132
|
+
for layer in layers:
|
|
133
|
+
if not layer:
|
|
134
|
+
continue
|
|
135
|
+
# Merge repos
|
|
136
|
+
layer_repos: list[dict[str, Any]] = list(layer.get("repos", []))
|
|
137
|
+
if layer_repos:
|
|
138
|
+
merged_repos = _merge_repos(merged_repos, layer_repos)
|
|
139
|
+
# Merge other top-level keys (skip repos and framework metadata)
|
|
140
|
+
non_repo = {
|
|
141
|
+
k: v for k, v in layer.items() if k not in ("repos", "primary_languages")
|
|
142
|
+
}
|
|
143
|
+
result = _deep_merge_top_level(result, non_repo)
|
|
144
|
+
|
|
145
|
+
if merged_repos:
|
|
146
|
+
result["repos"] = merged_repos
|
|
147
|
+
|
|
148
|
+
return result
|
gpc_init/profiles.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Data classes for pc-init profiles and generation entities."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class HookConfig:
|
|
8
|
+
"""Individual pre-commit hook item."""
|
|
9
|
+
|
|
10
|
+
id: str
|
|
11
|
+
args: tuple[str, ...] = field(default_factory=tuple)
|
|
12
|
+
additional_dependencies: tuple[str, ...] = field(default_factory=tuple)
|
|
13
|
+
stages: tuple[str, ...] = field(default_factory=tuple)
|
|
14
|
+
extra_fields: dict[str, object] = field(default_factory=dict)
|
|
15
|
+
|
|
16
|
+
def __post_init__(self) -> None:
|
|
17
|
+
"""Validate hook fields."""
|
|
18
|
+
if not self.id:
|
|
19
|
+
msg = "HookConfig.id must be non-empty"
|
|
20
|
+
raise ValueError(msg)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class RepoConfig:
|
|
25
|
+
"""A pre-commit repository entry."""
|
|
26
|
+
|
|
27
|
+
repo: str
|
|
28
|
+
hooks: tuple[HookConfig, ...] = field(default_factory=tuple)
|
|
29
|
+
rev: str = ""
|
|
30
|
+
|
|
31
|
+
def __post_init__(self) -> None:
|
|
32
|
+
"""Validate repo fields."""
|
|
33
|
+
if not self.repo:
|
|
34
|
+
msg = "RepoConfig.repo must be non-empty"
|
|
35
|
+
raise ValueError(msg)
|
|
36
|
+
if not self.hooks:
|
|
37
|
+
msg = "RepoConfig.hooks must contain at least one hook"
|
|
38
|
+
raise ValueError(msg)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class GenerationRequest:
|
|
43
|
+
"""User request parsed from CLI."""
|
|
44
|
+
|
|
45
|
+
langs: tuple[str, ...] = field(default_factory=tuple)
|
|
46
|
+
frameworks: tuple[str, ...] = field(default_factory=tuple)
|
|
47
|
+
force: bool = False
|
|
48
|
+
target_path: str = ".pre-commit-config.yaml"
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
"""Validate generation request fields."""
|
|
52
|
+
if not self.langs:
|
|
53
|
+
msg = "GenerationRequest.langs must be non-empty"
|
|
54
|
+
raise ValueError(msg)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class GenerationResult:
|
|
59
|
+
"""Deterministic rendering and write outcome."""
|
|
60
|
+
|
|
61
|
+
content: str
|
|
62
|
+
path: str
|
|
63
|
+
overwritten: bool = False
|
gpc_init/renderer.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Render merged configuration dict to YAML string."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def render_yaml(merged_dict: dict[str, Any]) -> str:
|
|
9
|
+
"""
|
|
10
|
+
Render a merged configuration dict to a deterministic YAML string.
|
|
11
|
+
|
|
12
|
+
Uses yaml.dump with stable key ordering and block style for readability.
|
|
13
|
+
The output is compatible with pre-commit's expected .pre-commit-config.yaml format.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
merged_dict: Merged configuration dictionary to render.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
UTF-8 YAML string suitable for writing to .pre-commit-config.yaml.
|
|
20
|
+
|
|
21
|
+
"""
|
|
22
|
+
return yaml.dump(
|
|
23
|
+
merged_dict,
|
|
24
|
+
default_flow_style=False,
|
|
25
|
+
sort_keys=True,
|
|
26
|
+
allow_unicode=True,
|
|
27
|
+
width=4096,
|
|
28
|
+
)
|
gpc_init/resolver.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Resolve and validate generation requests against the preset catalog."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from gpc_init.exceptions import UnsupportedFrameworkError, UnsupportedLanguageError
|
|
7
|
+
|
|
8
|
+
# Language name aliases -> canonical id
|
|
9
|
+
_LANG_ALIASES: dict[str, str] = {
|
|
10
|
+
"python": "py",
|
|
11
|
+
"javascript": "js",
|
|
12
|
+
"rust": "ru",
|
|
13
|
+
"golang": "go",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# Default base directory for preset discovery.
|
|
17
|
+
# In development the symlinks gpc_init/lang -> ../lang resolve here; when installed
|
|
18
|
+
# from a wheel the real copies are present at the same location.
|
|
19
|
+
_DEFAULT_PRESETS_BASE = Path(__file__).parent
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _resolve_base(base_dir: Path | None) -> Path:
|
|
23
|
+
return base_dir if base_dir is not None else _DEFAULT_PRESETS_BASE
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _discover_languages(base_dir: Path) -> list[str]:
|
|
27
|
+
"""Scan lang/<lang>/baseline.yaml and return sorted list of language ids."""
|
|
28
|
+
lang_dir = base_dir / "lang"
|
|
29
|
+
if not lang_dir.is_dir():
|
|
30
|
+
return []
|
|
31
|
+
return sorted(
|
|
32
|
+
d.name
|
|
33
|
+
for d in lang_dir.iterdir()
|
|
34
|
+
if d.is_dir() and d.name != "common" and (d / "baseline.yaml").exists()
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _discover_frameworks(base_dir: Path) -> list[str]:
|
|
39
|
+
"""Scan framework/<fw>/preset.yaml and return sorted list of framework ids."""
|
|
40
|
+
fw_dir = base_dir / "framework"
|
|
41
|
+
if not fw_dir.is_dir():
|
|
42
|
+
return []
|
|
43
|
+
return sorted(
|
|
44
|
+
d.name for d in fw_dir.iterdir() if d.is_dir() and (d / "preset.yaml").exists()
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize_lang(lang: str) -> str:
|
|
49
|
+
"""Normalize a language value: lowercase and resolve aliases to canonical id."""
|
|
50
|
+
normalized = lang.strip().lower()
|
|
51
|
+
return _LANG_ALIASES.get(normalized, normalized)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def normalize_framework(fw: str) -> str:
|
|
55
|
+
"""Normalize a framework value: lowercase and strip whitespace."""
|
|
56
|
+
return fw.strip().lower()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def deduplicate_preserving_order(values: list[str]) -> list[str]:
|
|
60
|
+
"""Remove duplicate values from a list, preserving first-occurrence order."""
|
|
61
|
+
seen: set[str] = set()
|
|
62
|
+
result: list[str] = []
|
|
63
|
+
for v in values:
|
|
64
|
+
if v not in seen:
|
|
65
|
+
seen.add(v)
|
|
66
|
+
result.append(v)
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_supported_languages(base_dir: Path | None = None) -> list[str]:
|
|
71
|
+
"""
|
|
72
|
+
Return sorted list of supported language ids discovered from the filesystem.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
base_dir: Override base directory for preset discovery (used in tests).
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
return _discover_languages(_resolve_base(base_dir))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_supported_frameworks(base_dir: Path | None = None) -> list[str]:
|
|
82
|
+
"""
|
|
83
|
+
Return sorted list of supported framework ids discovered from the filesystem.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
base_dir: Override base directory for preset discovery (used in tests).
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
return _discover_frameworks(_resolve_base(base_dir))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def validate_langs(langs: list[str], base_dir: Path | None = None) -> None:
|
|
93
|
+
"""
|
|
94
|
+
Validate that all requested language ids are supported.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
langs: Normalized language ids to validate.
|
|
98
|
+
base_dir: Override base directory for preset discovery (used in tests).
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
UnsupportedLanguageError: If any language is not in the catalog.
|
|
102
|
+
|
|
103
|
+
"""
|
|
104
|
+
supported = get_supported_languages(base_dir)
|
|
105
|
+
for lang in langs:
|
|
106
|
+
if lang not in supported:
|
|
107
|
+
raise UnsupportedLanguageError(lang, supported)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def validate_frameworks(frameworks: list[str], base_dir: Path | None = None) -> None:
|
|
111
|
+
"""
|
|
112
|
+
Validate that all requested framework ids are supported.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
frameworks: Normalized framework ids to validate.
|
|
116
|
+
base_dir: Override base directory for preset discovery (used in tests).
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
UnsupportedFrameworkError: If any framework is not in the catalog.
|
|
120
|
+
|
|
121
|
+
"""
|
|
122
|
+
supported = get_supported_frameworks(base_dir)
|
|
123
|
+
for fw in frameworks:
|
|
124
|
+
if fw not in supported:
|
|
125
|
+
raise UnsupportedFrameworkError(fw, supported)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_primary_languages_info(
|
|
129
|
+
frameworks: list[str],
|
|
130
|
+
framework_presets: list[dict[str, Any]],
|
|
131
|
+
selected_langs: list[str],
|
|
132
|
+
) -> str | None:
|
|
133
|
+
"""
|
|
134
|
+
Return a message if selected langs don't match a framework's primary_languages.
|
|
135
|
+
|
|
136
|
+
This is purely informational and non-blocking.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
frameworks: Normalized framework ids.
|
|
140
|
+
framework_presets: Loaded framework preset dicts.
|
|
141
|
+
selected_langs: Normalized selected language ids.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
Informational message string, or None if no mismatch.
|
|
145
|
+
|
|
146
|
+
"""
|
|
147
|
+
messages: list[str] = []
|
|
148
|
+
for fw_id, fw_preset in zip(frameworks, framework_presets, strict=True):
|
|
149
|
+
primary = fw_preset.get("primary_languages", [])
|
|
150
|
+
if primary and not any(lang in primary for lang in selected_langs):
|
|
151
|
+
primary_str = ", ".join(primary)
|
|
152
|
+
messages.append(
|
|
153
|
+
f"Note: framework '{fw_id}' is typically used with: {primary_str}"
|
|
154
|
+
)
|
|
155
|
+
return "\n".join(messages) if messages else None
|