dc-up 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.
- dc_up/__init__.py +1 -0
- dc_up/__main__.py +6 -0
- dc_up/_version.py +24 -0
- dc_up/baseline.py +155 -0
- dc_up/cli.py +145 -0
- dc_up/commands/__init__.py +11 -0
- dc_up/commands/todo.py +23 -0
- dc_up/commands/update.py +50 -0
- dc_up/detect.py +124 -0
- dc_up/errors.py +38 -0
- dc_up/fetch.py +329 -0
- dc_up/plan.py +204 -0
- dc_up/py.typed +0 -0
- dc_up/render.py +35 -0
- dc_up/todo.py +97 -0
- dc_up/types.py +62 -0
- dc_up-0.1.0.dist-info/METADATA +150 -0
- dc_up-0.1.0.dist-info/RECORD +21 -0
- dc_up-0.1.0.dist-info/WHEEL +4 -0
- dc_up-0.1.0.dist-info/entry_points.txt +2 -0
- dc_up-0.1.0.dist-info/licenses/LICENSE +21 -0
dc_up/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""dc-up package."""
|
dc_up/__main__.py
ADDED
dc_up/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
dc_up/baseline.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Baseline layer inference and managed file declarations."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import tomllib
|
|
5
|
+
from typing import cast
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"PRESERVE_PATTERNS",
|
|
9
|
+
"infer_layers",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
TomlTable = dict[str, object]
|
|
13
|
+
|
|
14
|
+
COURSE_PREFIXES: tuple[str, ...] = (
|
|
15
|
+
"datafun-",
|
|
16
|
+
"streaming-",
|
|
17
|
+
"cintel-",
|
|
18
|
+
"nlp-",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
PRESERVE_PATTERNS: tuple[str, ...] = (
|
|
23
|
+
"README.md",
|
|
24
|
+
"docs/**",
|
|
25
|
+
"src/**",
|
|
26
|
+
"tests/**",
|
|
27
|
+
"notebooks/**",
|
|
28
|
+
"data/**",
|
|
29
|
+
"sql/**",
|
|
30
|
+
"artifacts/**",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def infer_layers(
|
|
35
|
+
*,
|
|
36
|
+
repo_root: Path,
|
|
37
|
+
repo_slug: str,
|
|
38
|
+
files: set[str],
|
|
39
|
+
) -> list[str]:
|
|
40
|
+
"""Infer additive template layers for a repository."""
|
|
41
|
+
normalized_slug = repo_slug.lower()
|
|
42
|
+
|
|
43
|
+
if "package.json" in files and "pyproject.toml" not in files:
|
|
44
|
+
layers = ["ALL", "ALL-TS"]
|
|
45
|
+
elif "notebook" in normalized_slug or normalized_slug.endswith("-notebooks"):
|
|
46
|
+
layers = ["ALL", "ALL-PY", "ALL-PY-NB"]
|
|
47
|
+
elif "kafka" in normalized_slug:
|
|
48
|
+
layers = ["ALL", "ALL-PY", "ALL-PY-KAFKA"]
|
|
49
|
+
elif normalized_slug == "dc-up" or _looks_like_pypi_package(repo_root):
|
|
50
|
+
layers = ["ALL", "ALL-PY", "ALL-PY-SRC", "ALL-PY-PYPI"]
|
|
51
|
+
else:
|
|
52
|
+
layers = ["ALL", "ALL-PY", "ALL-PY-SRC"]
|
|
53
|
+
|
|
54
|
+
if _looks_like_course_repo(repo_slug=repo_slug, files=files):
|
|
55
|
+
layers.append("ALL-COURSE")
|
|
56
|
+
|
|
57
|
+
return layers
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _has_console_scripts(project: TomlTable) -> bool:
|
|
61
|
+
"""Return whether project metadata declares console scripts."""
|
|
62
|
+
scripts = _as_string_dict(project.get("scripts"))
|
|
63
|
+
if scripts:
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
entry_points = _as_table(project.get("entry-points"))
|
|
67
|
+
console_scripts = _as_string_dict(entry_points.get("console_scripts"))
|
|
68
|
+
|
|
69
|
+
return bool(console_scripts)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _read_toml(path: Path) -> TomlTable:
|
|
73
|
+
"""Read a TOML file as typed object data."""
|
|
74
|
+
with path.open("rb") as file:
|
|
75
|
+
return cast(TomlTable, tomllib.load(file))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _as_table(value: object) -> TomlTable:
|
|
79
|
+
"""Return value as a TOML table or an empty table."""
|
|
80
|
+
if isinstance(value, dict):
|
|
81
|
+
return cast(TomlTable, value)
|
|
82
|
+
return {}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _as_string_list(value: object) -> list[str]:
|
|
86
|
+
"""Return value as a list of strings."""
|
|
87
|
+
if not isinstance(value, list):
|
|
88
|
+
return []
|
|
89
|
+
|
|
90
|
+
return [str(item) for item in cast(list[object], value)]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _as_string_dict(value: object) -> dict[str, str]:
|
|
94
|
+
"""Return value as a string-to-string dictionary."""
|
|
95
|
+
if not isinstance(value, dict):
|
|
96
|
+
return {}
|
|
97
|
+
|
|
98
|
+
table = cast(dict[object, object], value)
|
|
99
|
+
return {str(key): str(item) for key, item in table.items()}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _as_bool(value: object) -> bool:
|
|
103
|
+
"""Return value as a boolean."""
|
|
104
|
+
if isinstance(value, bool):
|
|
105
|
+
return value
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _looks_like_course_repo(*, repo_slug: str, files: set[str]) -> bool:
|
|
110
|
+
"""Return whether a repository looks like a course project repo."""
|
|
111
|
+
normalized_slug = repo_slug.lower()
|
|
112
|
+
|
|
113
|
+
if not normalized_slug.startswith(COURSE_PREFIXES):
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
# Avoid admin/maintenance repos unless you decide they should get course docs.
|
|
117
|
+
if normalized_slug.endswith("-00-admin") or normalized_slug.endswith("-admin"):
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
# Require project repo shape, not just matching name.
|
|
121
|
+
return "pyproject.toml" in files
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _looks_like_pypi_package(repo_root: Path) -> bool:
|
|
125
|
+
"""Return whether pyproject.toml looks like a publishable package.
|
|
126
|
+
|
|
127
|
+
This is intentionally stricter than "has pyproject + src" so ordinary
|
|
128
|
+
course repos do not accidentally receive PyPI release surfaces.
|
|
129
|
+
"""
|
|
130
|
+
pyproject_path = repo_root / "pyproject.toml"
|
|
131
|
+
if not pyproject_path.exists():
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
data = _read_toml(pyproject_path)
|
|
136
|
+
except OSError:
|
|
137
|
+
return False
|
|
138
|
+
except tomllib.TOMLDecodeError:
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
project = _as_table(data.get("project"))
|
|
142
|
+
if not project:
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
if _has_console_scripts(project):
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
classifiers = _as_string_list(project.get("classifiers"))
|
|
149
|
+
if any("PyPI" in item for item in classifiers):
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
tool = _as_table(data.get("tool"))
|
|
153
|
+
uv = _as_table(tool.get("uv"))
|
|
154
|
+
|
|
155
|
+
return _as_bool(uv.get("package"))
|
dc_up/cli.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Command-line interface for dc-up.
|
|
2
|
+
|
|
3
|
+
This module parses arguments and dispatches commands.
|
|
4
|
+
Command behavior lives in dc_up.commands.
|
|
5
|
+
|
|
6
|
+
Commands:
|
|
7
|
+
uv run dc-up
|
|
8
|
+
uv run dc-up --write
|
|
9
|
+
uv run dc-up todo
|
|
10
|
+
|
|
11
|
+
Equivalent uvx usage after release:
|
|
12
|
+
uvx dc-up
|
|
13
|
+
uvx dc-up --write
|
|
14
|
+
uvx dc-up todo
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
from collections.abc import Callable, Sequence
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from dc_up.commands import todo, update
|
|
22
|
+
|
|
23
|
+
__all__ = ["build_parser", "main"]
|
|
24
|
+
|
|
25
|
+
CommandFunc = Callable[[argparse.Namespace], int]
|
|
26
|
+
|
|
27
|
+
EXIT_OK = 0
|
|
28
|
+
EXIT_NO_COMMAND = 2
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _run_update(args: argparse.Namespace) -> int:
|
|
32
|
+
"""Bring the current repository up to the managed baseline."""
|
|
33
|
+
return update.run(
|
|
34
|
+
root=args.root,
|
|
35
|
+
write=args.write,
|
|
36
|
+
templates=args.templates,
|
|
37
|
+
ref=args.ref,
|
|
38
|
+
templates_path=args.templates_path,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _run_todo(args: argparse.Namespace) -> int:
|
|
43
|
+
"""Show repo-specific human work that dc-up cannot safely calculate."""
|
|
44
|
+
return todo.run(root=args.root)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
48
|
+
"""Build the argument parser."""
|
|
49
|
+
parser = argparse.ArgumentParser(
|
|
50
|
+
prog="dc-up",
|
|
51
|
+
description=(
|
|
52
|
+
"Bring the current repository up to the current Denise Case "
|
|
53
|
+
"managed baseline from canonical templates."
|
|
54
|
+
),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--root",
|
|
59
|
+
type=Path,
|
|
60
|
+
default=None,
|
|
61
|
+
help=(
|
|
62
|
+
"Repository root to update. Defaults to the nearest parent "
|
|
63
|
+
"directory containing .git, or the current directory."
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--write",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help=(
|
|
70
|
+
"Apply managed baseline changes. Without this flag, dc-up performs "
|
|
71
|
+
"a dry run and reports what would change."
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--templates",
|
|
76
|
+
default="denisecase/templates",
|
|
77
|
+
help=(
|
|
78
|
+
"GitHub owner/repo for canonical templates. "
|
|
79
|
+
"Defaults to denisecase/templates."
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
parser.add_argument(
|
|
83
|
+
"--ref",
|
|
84
|
+
default="main",
|
|
85
|
+
help="Git ref, branch, or tag to fetch templates from. Defaults to main.",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--templates-path",
|
|
89
|
+
type=Path,
|
|
90
|
+
default=None,
|
|
91
|
+
help=(
|
|
92
|
+
"Optional local templates repository path. If provided, templates "
|
|
93
|
+
"are read from disk instead of GitHub raw URLs."
|
|
94
|
+
),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
subparsers = parser.add_subparsers(dest="command", required=False)
|
|
98
|
+
|
|
99
|
+
# === TODO COMMAND ===
|
|
100
|
+
|
|
101
|
+
todo_parser = subparsers.add_parser(
|
|
102
|
+
"todo",
|
|
103
|
+
help=(
|
|
104
|
+
"Show repo-specific human review work that cannot be safely "
|
|
105
|
+
"calculated or overwritten."
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
todo_parser.add_argument(
|
|
109
|
+
"--root",
|
|
110
|
+
type=Path,
|
|
111
|
+
default=None,
|
|
112
|
+
help=(
|
|
113
|
+
"Repository root to inspect. Defaults to the nearest parent "
|
|
114
|
+
"directory containing .git, or the current directory."
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
todo_parser.set_defaults(func=_run_todo)
|
|
118
|
+
|
|
119
|
+
parser.set_defaults(func=_run_update)
|
|
120
|
+
|
|
121
|
+
return parser
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
125
|
+
"""Run the command-line interface.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
argv: Optional command-line arguments. If None, uses sys.argv.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Exit code from the executed command.
|
|
132
|
+
"""
|
|
133
|
+
parser = build_parser()
|
|
134
|
+
args = parser.parse_args(argv)
|
|
135
|
+
|
|
136
|
+
func: CommandFunc | None = getattr(args, "func", None)
|
|
137
|
+
if func is None:
|
|
138
|
+
parser.print_help()
|
|
139
|
+
return EXIT_NO_COMMAND
|
|
140
|
+
|
|
141
|
+
return func(args)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
raise SystemExit(main())
|
dc_up/commands/todo.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Print repo-specific human review work for dc-up."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from dc_up.detect import detect_repository
|
|
6
|
+
from dc_up.todo import build_todo_report, print_todo_report
|
|
7
|
+
|
|
8
|
+
__all__ = ["run"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(*, root: Path | None = None) -> int:
|
|
12
|
+
"""Show non-calculatable repo-specific work.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
root: Repository root. If None, dc-up detects the current repo root.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Process exit code.
|
|
19
|
+
"""
|
|
20
|
+
repository = detect_repository(root)
|
|
21
|
+
report = build_todo_report(repository)
|
|
22
|
+
print_todo_report(report)
|
|
23
|
+
return 0
|
dc_up/commands/update.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Apply or preview the managed repository baseline."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from dc_up.detect import detect_repository
|
|
6
|
+
from dc_up.fetch import TemplateSource
|
|
7
|
+
from dc_up.plan import build_update_plan, print_update_plan, write_update_plan
|
|
8
|
+
|
|
9
|
+
__all__ = ["run"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def run(
|
|
13
|
+
*,
|
|
14
|
+
root: Path | None = None,
|
|
15
|
+
write: bool = False,
|
|
16
|
+
templates: str = "denisecase/templates",
|
|
17
|
+
ref: str = "main",
|
|
18
|
+
templates_path: Path | None = None,
|
|
19
|
+
) -> int:
|
|
20
|
+
"""Preview or apply managed baseline updates.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
root: Repository root. If None, dc-up detects the current repo root.
|
|
24
|
+
write: Whether to write changes. False means dry-run only.
|
|
25
|
+
templates: GitHub owner/repo for canonical templates.
|
|
26
|
+
ref: Git ref, branch, or tag.
|
|
27
|
+
templates_path: Optional local templates repo path.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
Process exit code.
|
|
31
|
+
"""
|
|
32
|
+
repository = detect_repository(root)
|
|
33
|
+
|
|
34
|
+
source = TemplateSource(
|
|
35
|
+
repository=templates,
|
|
36
|
+
ref=ref,
|
|
37
|
+
local_path=templates_path,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
plan = build_update_plan(
|
|
41
|
+
target=repository,
|
|
42
|
+
source=source,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
print_update_plan(plan, write=write)
|
|
46
|
+
|
|
47
|
+
if write:
|
|
48
|
+
write_update_plan(plan)
|
|
49
|
+
|
|
50
|
+
return 0
|
dc_up/detect.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Repository detection."""
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from dc_up.baseline import infer_layers
|
|
8
|
+
from dc_up.errors import RepositoryDetectionError
|
|
9
|
+
from dc_up.types import RepositoryContext
|
|
10
|
+
|
|
11
|
+
__all__ = ["detect_repository"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
GITHUB_REMOTE_RE = re.compile(
|
|
15
|
+
r"(?:git@github\.com:|https://github\.com/)(?P<repo>[^/\s]+/[^/\s]+?)(?:\.git)?/?$"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def detect_repository(root: Path | None = None) -> RepositoryContext:
|
|
20
|
+
"""Detect the repository context.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
root: Optional repository root. If None, detect from current directory.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Repository context used by planning and TODO generation.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
RepositoryDetectionError: If the target directory cannot be resolved.
|
|
30
|
+
"""
|
|
31
|
+
repo_root = _resolve_repo_root(root)
|
|
32
|
+
files = _snapshot_files(repo_root)
|
|
33
|
+
|
|
34
|
+
repo_slug = repo_root.name
|
|
35
|
+
repo_name = _detect_repo_name(repo_root) or f"denisecase/{repo_slug}"
|
|
36
|
+
|
|
37
|
+
repo_url = f"https://github.com/{repo_name}"
|
|
38
|
+
site_url = f"https://denisecase.github.io/{repo_slug}/"
|
|
39
|
+
|
|
40
|
+
layers = tuple(infer_layers(repo_root=repo_root, repo_slug=repo_slug, files=files))
|
|
41
|
+
|
|
42
|
+
return RepositoryContext(
|
|
43
|
+
root=repo_root,
|
|
44
|
+
repo_slug=repo_slug,
|
|
45
|
+
repo_name=repo_name,
|
|
46
|
+
repo_url=repo_url,
|
|
47
|
+
site_url=site_url,
|
|
48
|
+
files=frozenset(files),
|
|
49
|
+
layers=layers,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_repo_root(root: Path | None) -> Path:
|
|
54
|
+
"""Resolve the target repository root."""
|
|
55
|
+
start = Path.cwd() if root is None else root
|
|
56
|
+
start = start.expanduser().resolve()
|
|
57
|
+
|
|
58
|
+
if not start.exists():
|
|
59
|
+
raise RepositoryDetectionError(f"Repository path does not exist: {start}")
|
|
60
|
+
|
|
61
|
+
if start.is_file():
|
|
62
|
+
start = start.parent
|
|
63
|
+
|
|
64
|
+
for candidate in [start, *start.parents]:
|
|
65
|
+
if (candidate / ".git").exists():
|
|
66
|
+
return candidate
|
|
67
|
+
|
|
68
|
+
return start
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _snapshot_files(root: Path) -> set[str]:
|
|
72
|
+
"""Return repository-relative file and directory markers."""
|
|
73
|
+
result: set[str] = set()
|
|
74
|
+
|
|
75
|
+
ignored_dirs = {
|
|
76
|
+
".git",
|
|
77
|
+
".mypy_cache",
|
|
78
|
+
".pytest_cache",
|
|
79
|
+
".ruff_cache",
|
|
80
|
+
".venv",
|
|
81
|
+
"__pycache__",
|
|
82
|
+
"dist",
|
|
83
|
+
"build",
|
|
84
|
+
"htmlcov",
|
|
85
|
+
"node_modules",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for path in root.rglob("*"):
|
|
89
|
+
rel = path.relative_to(root).as_posix()
|
|
90
|
+
|
|
91
|
+
if any(part in ignored_dirs for part in path.parts):
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
if path.is_dir():
|
|
95
|
+
result.add(rel)
|
|
96
|
+
result.add(f"{rel}/")
|
|
97
|
+
else:
|
|
98
|
+
result.add(rel)
|
|
99
|
+
|
|
100
|
+
return result
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _detect_repo_name(root: Path) -> str | None:
|
|
104
|
+
"""Infer owner/repo from .git/config if possible."""
|
|
105
|
+
git_config = root / ".git" / "config"
|
|
106
|
+
if not git_config.exists():
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
parser = configparser.ConfigParser()
|
|
110
|
+
try:
|
|
111
|
+
parser.read(git_config, encoding="utf-8")
|
|
112
|
+
except configparser.Error:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
section = 'remote "origin"'
|
|
116
|
+
if not parser.has_section(section):
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
url = parser.get(section, "url", fallback="")
|
|
120
|
+
match = GITHUB_REMOTE_RE.search(url.strip())
|
|
121
|
+
if match is None:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
return match.group("repo")
|
dc_up/errors.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Exception types for dc-up."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"DcUpError",
|
|
7
|
+
"RepositoryDetectionError",
|
|
8
|
+
"TemplateFetchError",
|
|
9
|
+
"UnsafePathError",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class DcUpError(Exception):
|
|
14
|
+
"""Base exception for dc-up."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RepositoryDetectionError(DcUpError):
|
|
18
|
+
"""Raised when dc-up cannot determine the target repository."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str) -> None:
|
|
21
|
+
"""Initialize the error."""
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TemplateFetchError(DcUpError):
|
|
26
|
+
"""Raised when template content cannot be fetched."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str) -> None:
|
|
29
|
+
"""Initialize the error."""
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UnsafePathError(DcUpError):
|
|
34
|
+
"""Raised when a path would escape the target repository."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, path: Path) -> None:
|
|
37
|
+
"""Initialize the error."""
|
|
38
|
+
super().__init__(f"Unsafe path escapes repository root: {path}")
|