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 ADDED
@@ -0,0 +1 @@
1
+ """pc-init: Generate .pre-commit-config.yaml from language and framework presets."""
gpc_init/about.py ADDED
@@ -0,0 +1,3 @@
1
+ """Provide the version of this package."""
2
+
3
+ __version__ = "0.1.0"
gpc_init/cli.py ADDED
@@ -0,0 +1,204 @@
1
+ """pc-init CLI: Generate .pre-commit-config.yaml from language and framework presets."""
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from gpc_init import fetcher
9
+ from gpc_init.exceptions import (
10
+ PresetFetchError,
11
+ PresetNotFoundError,
12
+ PresetParseError,
13
+ UnsupportedFrameworkError,
14
+ UnsupportedLanguageError,
15
+ )
16
+ from gpc_init.loader import (
17
+ load_common_preset,
18
+ load_framework_preset,
19
+ load_language_preset,
20
+ )
21
+ from gpc_init.merger import merge_presets
22
+ from gpc_init.renderer import render_yaml
23
+ from gpc_init.resolver import (
24
+ deduplicate_preserving_order,
25
+ get_primary_languages_info,
26
+ normalize_framework,
27
+ normalize_lang,
28
+ validate_frameworks,
29
+ validate_langs,
30
+ )
31
+
32
+
33
+ def _run(
34
+ langs: list[str],
35
+ frameworks: list[str],
36
+ target: Path,
37
+ base_dir: Path | None,
38
+ ) -> None:
39
+ """Validate, load, merge, render, and write the preset config."""
40
+ if base_dir is not None and not (base_dir / "lang").is_dir():
41
+ typer.echo(f"Error: '{base_dir}' must contain a 'lang' subdirectory.", err=True)
42
+ raise typer.Exit(code=1)
43
+
44
+ try:
45
+ validate_langs(langs, base_dir=base_dir)
46
+ validate_frameworks(frameworks, base_dir=base_dir)
47
+
48
+ common = load_common_preset(base_dir=base_dir)
49
+ lang_presets = [
50
+ load_language_preset(lang_id, base_dir=base_dir) for lang_id in langs
51
+ ]
52
+ fw_presets = [
53
+ load_framework_preset(fw_id, base_dir=base_dir) for fw_id in frameworks
54
+ ]
55
+
56
+ merged = merge_presets(common, lang_presets, fw_presets)
57
+ content = render_yaml(merged)
58
+
59
+ overwritten = target.exists()
60
+ try:
61
+ target.write_text(content, encoding="utf-8")
62
+ except (PermissionError, OSError) as exc:
63
+ typer.echo(f"Error: cannot write to '{target}': {exc}", err=True)
64
+ raise typer.Exit(code=1) from exc
65
+
66
+ info = get_primary_languages_info(frameworks, fw_presets, langs)
67
+ if info:
68
+ typer.echo(info)
69
+
70
+ lang_str = ", ".join(langs)
71
+ fw_str = (", ".join(frameworks)) if frameworks else "none"
72
+ action = "Overwrote" if overwritten else "Generated"
73
+ typer.echo(
74
+ f"{action} {target} with languages: {lang_str} and frameworks: {fw_str}"
75
+ )
76
+
77
+ except UnsupportedLanguageError as exc:
78
+ typer.echo(f"Error: {exc}", err=True)
79
+ raise typer.Exit(code=1) from exc
80
+
81
+ except UnsupportedFrameworkError as exc:
82
+ typer.echo(f"Error: {exc}", err=True)
83
+ raise typer.Exit(code=1) from exc
84
+
85
+ except PresetNotFoundError as exc:
86
+ typer.echo(f"Error: {exc}", err=True)
87
+ raise typer.Exit(code=1) from exc
88
+
89
+ except PresetParseError as exc:
90
+ typer.echo(f"Error: failed to parse preset YAML: {exc}", err=True)
91
+ raise typer.Exit(code=1) from exc
92
+
93
+
94
+ app = typer.Typer(
95
+ name="pc-init",
96
+ help="Generate a .pre-commit-config.yaml for your project.",
97
+ add_completion=False,
98
+ )
99
+
100
+
101
+ def _normalize_langs(raw_langs: list[str]) -> list[str]:
102
+ """Lowercase, resolve aliases, and deduplicate language values."""
103
+ return deduplicate_preserving_order([normalize_lang(v) for v in raw_langs])
104
+
105
+
106
+ def _normalize_frameworks(raw_frameworks: list[str]) -> list[str]:
107
+ """Lowercase and deduplicate framework values."""
108
+ return deduplicate_preserving_order(
109
+ [normalize_framework(v) for v in raw_frameworks]
110
+ )
111
+
112
+
113
+ @app.command()
114
+ def main(
115
+ lang: Annotated[
116
+ list[str],
117
+ typer.Option(
118
+ "--lang",
119
+ help="Language preset to include (repeatable). "
120
+ "Run without --lang to see supported values from the active catalog.",
121
+ ),
122
+ ],
123
+ framework: Annotated[
124
+ list[str] | None,
125
+ typer.Option(
126
+ "--framework",
127
+ help="Framework preset to layer on top of language baselines (repeatable). "
128
+ "Run without --framework to see supported values from the active catalog.",
129
+ ),
130
+ ] = None,
131
+ force: Annotated[
132
+ bool,
133
+ typer.Option(
134
+ "--force",
135
+ help="Overwrite existing .pre-commit-config.yaml without prompting.",
136
+ ),
137
+ ] = False,
138
+ output: Annotated[
139
+ str,
140
+ typer.Option(
141
+ "--output",
142
+ help="Output file path. Defaults to .pre-commit-config.yaml.",
143
+ ),
144
+ ] = ".pre-commit-config.yaml",
145
+ presets: Annotated[
146
+ str | None,
147
+ typer.Option(
148
+ "--presets",
149
+ help=(
150
+ "Preset catalog to use. Accepts a local directory path or a git "
151
+ "repository URL (https://, git@, git://, ssh://). The directory / "
152
+ "repo root must contain lang/ and framework/ subdirectories. "
153
+ "Defaults to the bundled presets."
154
+ ),
155
+ ),
156
+ ] = None,
157
+ ) -> None:
158
+ """
159
+ Generate a .pre-commit-config.yaml from language and optional framework presets.
160
+
161
+ At least one --lang value is required. --framework values are optional.
162
+ Use --force to overwrite an existing config file.
163
+ """
164
+ raw_frameworks: list[str] = framework or []
165
+
166
+ # Normalize and deduplicate
167
+ langs = _normalize_langs(lang)
168
+ frameworks = _normalize_frameworks(raw_frameworks)
169
+
170
+ # Check existing file before any I/O
171
+ target = Path(output)
172
+ if target.exists() and not force:
173
+ typer.echo(
174
+ f"Error: '{target}' already exists. Use --force to overwrite.", err=True
175
+ )
176
+ raise typer.Exit(code=1)
177
+
178
+ # Resolve preset catalog
179
+ if presets is not None and not fetcher.is_git_url(presets):
180
+ local_dir = Path(presets)
181
+ if not local_dir.is_dir():
182
+ typer.echo(
183
+ f"Error: presets directory '{local_dir}' does not exist.", err=True
184
+ )
185
+ raise typer.Exit(code=1)
186
+ _run(langs, frameworks, target, local_dir)
187
+ elif presets is not None:
188
+ try:
189
+ with fetcher.fetch_preset_repo(presets) as cloned:
190
+ _run(langs, frameworks, target, cloned)
191
+ except PresetFetchError as exc:
192
+ typer.echo(f"Error: {exc}", err=True)
193
+ raise typer.Exit(code=1) from exc
194
+ else:
195
+ _run(langs, frameworks, target, None)
196
+
197
+
198
+ def entry_point() -> None:
199
+ """Entry point for the pc-init command."""
200
+ app()
201
+
202
+
203
+ if __name__ == "__main__": # pragma: no cover
204
+ app()
gpc_init/exceptions.py ADDED
@@ -0,0 +1,49 @@
1
+ """Custom exceptions for pc-init."""
2
+
3
+
4
+ class PresetNotFoundError(Exception):
5
+ """Raised when a preset file is not found on the filesystem."""
6
+
7
+
8
+ class PresetParseError(Exception):
9
+ """Raised when a preset file contains invalid YAML or unexpected structure."""
10
+
11
+
12
+ class UnsupportedLanguageError(Exception):
13
+ """Raised when a requested language is not in the language catalog."""
14
+
15
+ def __init__(self, lang: str, supported: list[str]) -> None:
16
+ """Initialize with the unsupported language and supported language list."""
17
+ self.lang = lang
18
+ self.supported = supported
19
+ supported_str = ", ".join(sorted(supported))
20
+ super().__init__(f"Unsupported language '{lang}'. Supported: {supported_str}")
21
+
22
+
23
+ class UnsupportedFrameworkError(Exception):
24
+ """Raised when a requested framework is not in the framework catalog."""
25
+
26
+ def __init__(self, fw: str, supported: list[str]) -> None:
27
+ """Initialize with the unsupported framework and supported framework list."""
28
+ self.fw = fw
29
+ self.supported = supported
30
+ supported_str = ", ".join(sorted(supported))
31
+ super().__init__(f"Unsupported framework '{fw}'. Supported: {supported_str}")
32
+
33
+
34
+ class TargetFileExistsError(Exception):
35
+ """Raised when target file already exists and --force was not provided."""
36
+
37
+ def __init__(self, path: str) -> None:
38
+ """Initialize with the path that already exists."""
39
+ self.path = path
40
+ super().__init__(f"'{path}' already exists. Use --force to overwrite.")
41
+
42
+
43
+ class PresetFetchError(Exception):
44
+ """Raised when a remote preset repository cannot be fetched."""
45
+
46
+ def __init__(self, url: str, detail: str) -> None:
47
+ """Initialize with the URL that failed and the reason."""
48
+ self.url = url
49
+ super().__init__(f"Failed to fetch presets from '{url}': {detail}")
gpc_init/fetcher.py ADDED
@@ -0,0 +1,63 @@
1
+ """Fetch preset catalogs from remote git repositories."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import tempfile
8
+ from contextlib import contextmanager
9
+ from pathlib import Path
10
+ from typing import TYPE_CHECKING
11
+
12
+ from gpc_init.exceptions import PresetFetchError
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Generator
16
+
17
+ _GIT_URL_PREFIXES = ("https://", "git@", "git://", "ssh://")
18
+
19
+
20
+ def is_git_url(value: str) -> bool:
21
+ """Return True if value looks like a git repository URL rather than a local path."""
22
+ if Path(value).exists():
23
+ return False
24
+ return value.startswith(_GIT_URL_PREFIXES) or value.endswith(".git")
25
+
26
+
27
+ @contextmanager
28
+ def fetch_preset_repo(url: str) -> Generator[Path]:
29
+ """
30
+ Shallow-clone a git repository and yield the path to the clone root.
31
+
32
+ The temporary directory is removed on exit regardless of success or failure.
33
+
34
+ Args:
35
+ url: Git repository URL (https://, git@, git://, or ssh://).
36
+
37
+ Yields:
38
+ Path to the root of the cloned repository.
39
+
40
+ Raises:
41
+ PresetFetchError: If git is not installed or the clone fails.
42
+
43
+ """
44
+ git = shutil.which("git")
45
+ if git is None:
46
+ msg = "git is not installed or not on PATH"
47
+ raise PresetFetchError(url, msg)
48
+
49
+ with tempfile.TemporaryDirectory(prefix="gpc-init-presets-") as tmp:
50
+ try:
51
+ subprocess.run( # noqa: S603
52
+ [git, "clone", "--depth=1", "--", url, tmp],
53
+ capture_output=True,
54
+ text=True,
55
+ check=True,
56
+ )
57
+ except subprocess.CalledProcessError as exc:
58
+ detail = (exc.stderr or exc.stdout or "unknown error").strip()
59
+ raise PresetFetchError(url, detail) from exc
60
+ except OSError as exc:
61
+ msg = f"Failed to execute git: {exc}"
62
+ raise PresetFetchError(url, msg) from exc
63
+ yield Path(tmp)
@@ -0,0 +1,13 @@
1
+ primary_languages:
2
+ - ru
3
+ repos:
4
+ - repo: https://github.com/nickel-lang/nickel-pre-commit
5
+ rev: v0.1.0
6
+ hooks:
7
+ - id: bevy-lint
8
+ name: bevy-lint
9
+ description: run bevy-lint to check Bevy ECS usage patterns
10
+ entry: bevy-lint
11
+ language: rust
12
+ types:
13
+ - rust
@@ -0,0 +1,8 @@
1
+ primary_languages:
2
+ - py
3
+ repos:
4
+ - repo: https://github.com/pre-commit/pre-commit-hooks
5
+ rev: v6.0.0
6
+ hooks:
7
+ - id: name-tests-test
8
+ args: ['--django']
@@ -0,0 +1,15 @@
1
+ primary_languages:
2
+ - js
3
+ repos:
4
+ - repo: local
5
+ hooks:
6
+ - id: eslint-react
7
+ name: eslint (react)
8
+ description: lint JavaScript/React files with eslint and react-hooks plugin
9
+ entry: npx eslint --fix
10
+ files: \.(jsx?|tsx?)$
11
+ language: node
12
+ additional_dependencies:
13
+ - eslint@9.28.0
14
+ - eslint-plugin-react@7.37.5
15
+ - eslint-plugin-react-hooks@5.2.0
@@ -0,0 +1,23 @@
1
+ repos:
2
+ - repo: meta
3
+ hooks:
4
+ - id: check-useless-excludes
5
+ - repo: https://github.com/pre-commit/pre-commit-hooks
6
+ rev: v6.0.0
7
+ hooks:
8
+ - id: check-added-large-files
9
+ - id: check-docstring-first
10
+ - id: check-json
11
+ - id: check-merge-conflict
12
+ - id: check-toml
13
+ - id: check-vcs-permalinks
14
+ - id: check-xml
15
+ - id: check-yaml
16
+ - id: debug-statements
17
+ - id: end-of-file-fixer
18
+ - id: mixed-line-ending
19
+ - id: name-tests-test
20
+ - id: no-commit-to-branch
21
+ - id: pretty-format-json
22
+ - id: requirements-txt-fixer
23
+ - id: trailing-whitespace
@@ -0,0 +1,5 @@
1
+ repos:
2
+ - repo: https://github.com/crate-ci/typos
3
+ rev: v1
4
+ hooks:
5
+ - id: typos
@@ -0,0 +1,14 @@
1
+ repos:
2
+ - repo: local
3
+ hooks:
4
+ - id: prettier
5
+ name: run prettier
6
+ description: format files with prettier
7
+ entry: prettier --write '**/*.js' '**/*.yaml' '**/*.yml'
8
+ files: \.(js|ya?ml)$
9
+ language: node
10
+ additional_dependencies: ['prettier@3.6.2']
11
+ - repo: https://github.com/crate-ci/typos
12
+ rev: v1
13
+ hooks:
14
+ - id: typos
@@ -0,0 +1,9 @@
1
+ repos:
2
+ - repo: https://github.com/rvben/rumdl-pre-commit
3
+ rev: v0.2.0
4
+ hooks:
5
+ - id: rumdl-fmt
6
+ - repo: https://github.com/crate-ci/typos
7
+ rev: v1
8
+ hooks:
9
+ - id: typos
@@ -0,0 +1,38 @@
1
+ repos:
2
+ - repo: https://github.com/MarcoGorelli/absolufy-imports
3
+ rev: v0.3.1
4
+ hooks:
5
+ - id: absolufy-imports
6
+ - repo: https://github.com/astral-sh/ruff-pre-commit
7
+ rev: v0.15.12
8
+ hooks:
9
+ - id: ruff-check
10
+ - id: ruff-format
11
+ - repo: https://github.com/abravalheri/validate-pyproject
12
+ rev: v0.25
13
+ hooks:
14
+ - id: validate-pyproject
15
+ - repo: https://github.com/kieran-ryan/pyprojectsort
16
+ rev: v0.4.0
17
+ hooks:
18
+ - id: pyprojectsort
19
+ - repo: https://github.com/adamchainz/blacken-docs
20
+ rev: 1.20.0
21
+ hooks:
22
+ - id: blacken-docs
23
+ - repo: https://github.com/crate-ci/typos
24
+ rev: v1
25
+ hooks:
26
+ - id: typos
27
+ - repo: https://github.com/facebook/pyrefly-pre-commit
28
+ rev: v0.42.0
29
+ hooks:
30
+ - id: pyrefly-check
31
+ - repo: https://github.com/astral-sh/ty-pre-commit
32
+ rev: v0.0.51
33
+ hooks:
34
+ - id: ty
35
+ - repo: https://github.com/rohaquinlop/complexipy-pre-commit
36
+ rev: v5.1.0
37
+ hooks:
38
+ - id: complexipy
@@ -0,0 +1,28 @@
1
+ repos:
2
+ - repo: https://github.com/doublify/pre-commit-rust
3
+ rev: v1.0
4
+ hooks:
5
+ - id: cargo-check
6
+ args:
7
+ - --workspace
8
+ - id: clippy
9
+ args:
10
+ - --
11
+ - -D
12
+ - warnings
13
+ - id: fmt
14
+ args:
15
+ - --
16
+ - --check
17
+ - repo: https://github.com/nim65s/pre-commit-sort
18
+ rev: v1.0.0
19
+ hooks:
20
+ - id: pre-commit-sort
21
+ - repo: https://github.com/crate-ci/typos
22
+ rev: v1
23
+ hooks:
24
+ - id: typos
25
+ - repo: https://github.com/rvben/rumdl-pre-commit
26
+ rev: v0.2.0
27
+ hooks:
28
+ - id: rumdl-fmt
File without changes
@@ -0,0 +1,6 @@
1
+ repos:
2
+ - repo: https://github.com/owenlamont/ryl-pre-commit
3
+ # Match the latest ryl release tag.
4
+ rev: v0.19.1
5
+ hooks:
6
+ - id: ryl
gpc_init/loader.py ADDED
@@ -0,0 +1,93 @@
1
+ """Load language and framework presets from the filesystem."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+ from gpc_init.exceptions import PresetNotFoundError, PresetParseError
9
+
10
+ # Base directory containing lang/ and framework/ preset folders.
11
+ # In development the symlinks gpc_init/lang -> ../lang resolve here; when installed
12
+ # from a wheel the real copies are present at the same location.
13
+ _DEFAULT_PRESETS_BASE = Path(__file__).parent
14
+
15
+
16
+ def _resolve_base(base_dir: Path | None) -> Path:
17
+ return base_dir if base_dir is not None else _DEFAULT_PRESETS_BASE
18
+
19
+
20
+ def _load_yaml_file(path: Path) -> dict[str, Any]:
21
+ """Load and parse a YAML file, raising structured errors on failure."""
22
+ if not path.exists():
23
+ msg = f"Preset file not found: {path}"
24
+ raise PresetNotFoundError(msg)
25
+ try:
26
+ with path.open(encoding="utf-8") as fh:
27
+ data = yaml.safe_load(fh)
28
+ except yaml.YAMLError as exc:
29
+ msg = f"Failed to parse preset YAML '{path}': {exc}"
30
+ raise PresetParseError(msg) from exc
31
+ if data is None:
32
+ data = {}
33
+ if not isinstance(data, dict):
34
+ msg = (
35
+ f"Preset file '{path}' must contain a YAML mapping,"
36
+ f" got {type(data).__name__}"
37
+ )
38
+ raise PresetParseError(msg)
39
+ return data
40
+
41
+
42
+ def load_common_preset(base_dir: Path | None = None) -> dict[str, Any]:
43
+ """
44
+ Load the common baseline preset (lang/common/default.yaml).
45
+
46
+ Returns an empty dict if the file does not exist.
47
+ """
48
+ path = _resolve_base(base_dir) / "lang" / "common" / "default.yaml"
49
+ if not path.exists():
50
+ return {}
51
+ return _load_yaml_file(path)
52
+
53
+
54
+ def load_language_preset(lang_id: str, base_dir: Path | None = None) -> dict[str, Any]:
55
+ """
56
+ Load the baseline preset for a language (lang/<lang_id>/baseline.yaml).
57
+
58
+ Args:
59
+ lang_id: Canonical language identifier (e.g. 'py', 'js', 'go', 'ru').
60
+ base_dir: Override base directory for presets (used in tests).
61
+
62
+ Returns:
63
+ Parsed preset as a dictionary.
64
+
65
+ Raises:
66
+ PresetNotFoundError: If the preset file does not exist.
67
+ PresetParseError: If the YAML is invalid or not a mapping.
68
+
69
+ """
70
+ return _load_yaml_file(_resolve_base(base_dir) / "lang" / lang_id / "baseline.yaml")
71
+
72
+
73
+ def load_framework_preset(
74
+ framework_id: str, base_dir: Path | None = None
75
+ ) -> dict[str, Any]:
76
+ """
77
+ Load the preset for a framework (framework/<framework_id>/preset.yaml).
78
+
79
+ Args:
80
+ framework_id: Canonical framework identifier (e.g. 'react', 'bevy').
81
+ base_dir: Override base directory for presets (used in tests).
82
+
83
+ Returns:
84
+ Parsed preset as a dictionary.
85
+
86
+ Raises:
87
+ PresetNotFoundError: If the preset file does not exist.
88
+ PresetParseError: If the YAML is invalid or not a mapping.
89
+
90
+ """
91
+ return _load_yaml_file(
92
+ _resolve_base(base_dir) / "framework" / framework_id / "preset.yaml"
93
+ )