lange-python 0.1.0__tar.gz → 0.2.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lange-python
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A bundeld set of tools, clients for the lange-suite of tools and more.
5
5
  Author: contact@robertlange.me
6
6
  Requires-Python: >=3.13
@@ -0,0 +1,20 @@
1
+ """CLI commands for the ``lange`` Python package."""
2
+
3
+ from __future__ import annotations
4
+ from .code import code_group
5
+ from .build import build_command
6
+
7
+ import click
8
+
9
+
10
+
11
+ @click.group()
12
+ def cli() -> None:
13
+ """
14
+ Lange CLI entrypoint.
15
+
16
+ :returns: ``None``.
17
+ """
18
+
19
+ cli.add_command(code_group, "code")
20
+ cli.add_command(build_command, "build")
@@ -0,0 +1,5 @@
1
+ """Public exports for ``lange build`` command helpers."""
2
+
3
+ from ._command import build_command
4
+
5
+ __all__ = ["build_command"]
@@ -0,0 +1,158 @@
1
+ """CLI command orchestration for ``lange build``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from ._discovery import (
11
+ detect_available_build_systems,
12
+ prompt_for_build_system_selection,
13
+ resolve_build_folder,
14
+ )
15
+ from ._docker import ensure_docker_is_available, parse_image_reference, run_docker_build
16
+ from ._poetry import run_poetry_build, run_poetry_publish
17
+ from ._types import DOCKER_BUILD_SYSTEM, POETRY_BUILD_SYSTEM, BuildSystem
18
+
19
+
20
+ @click.command("build")
21
+ @click.argument("folder_name", required=False)
22
+ @click.option("--publish", is_flag=True, help="Publish after a successful build.")
23
+ @click.option("--docker", "force_docker", is_flag=True, help="Force docker build.")
24
+ @click.option("--poetry", "force_poetry", is_flag=True, help="Force poetry build.")
25
+ def build_command(
26
+ folder_name: str | None,
27
+ publish: bool,
28
+ force_docker: bool,
29
+ force_poetry: bool,
30
+ ) -> None:
31
+ """
32
+ Build a folder using docker or poetry with optional publishing.
33
+
34
+ :param folder_name: Optional folder name to build.
35
+ :param publish: Whether publishing is enabled via flag.
36
+ :param force_docker: Force docker build system.
37
+ :param force_poetry: Force poetry build system.
38
+ :returns: ``None``.
39
+ """
40
+ _validate_force_flags(force_docker=force_docker, force_poetry=force_poetry)
41
+
42
+ target_folder = resolve_build_folder(folder_name=folder_name, root=Path.cwd())
43
+ build_system = resolve_build_system(
44
+ folder=target_folder,
45
+ force_docker=force_docker,
46
+ force_poetry=force_poetry,
47
+ )
48
+
49
+ if build_system == DOCKER_BUILD_SYSTEM:
50
+ _run_docker_flow(folder=target_folder, publish=publish)
51
+ return
52
+
53
+ _run_poetry_flow(folder=target_folder, publish=publish)
54
+
55
+
56
+ def _validate_force_flags(force_docker: bool, force_poetry: bool) -> None:
57
+ """
58
+ Validate that at most one build-system force flag is provided.
59
+
60
+ :param force_docker: Whether docker was forced.
61
+ :param force_poetry: Whether poetry was forced.
62
+ :returns: ``None``.
63
+ """
64
+ if force_docker and force_poetry:
65
+ raise click.ClickException("Only one of --docker or --poetry can be used.")
66
+
67
+
68
+ def resolve_build_system(
69
+ folder: Path,
70
+ force_docker: bool,
71
+ force_poetry: bool,
72
+ ) -> BuildSystem:
73
+ """
74
+ Resolve the build system from force flags or discovered files.
75
+
76
+ :param folder: Folder that should be built.
77
+ :param force_docker: Whether docker was forced.
78
+ :param force_poetry: Whether poetry was forced.
79
+ :returns: Resolved build system value.
80
+ """
81
+ if force_docker:
82
+ dockerfile = folder / "Dockerfile"
83
+ if not dockerfile.is_file():
84
+ raise click.ClickException(
85
+ f"Dockerfile was not found at '{dockerfile}'."
86
+ )
87
+ return DOCKER_BUILD_SYSTEM
88
+
89
+ if force_poetry:
90
+ pyproject_file = folder / "pyproject.toml"
91
+ if not pyproject_file.is_file():
92
+ raise click.ClickException(
93
+ f"pyproject.toml was not found at '{pyproject_file}'."
94
+ )
95
+ return POETRY_BUILD_SYSTEM
96
+
97
+ detected_systems = detect_available_build_systems(folder)
98
+ if not detected_systems:
99
+ raise click.ClickException(
100
+ "Could not detect a supported build system. "
101
+ "Expected Dockerfile and/or pyproject.toml."
102
+ )
103
+ if len(detected_systems) == 1:
104
+ return detected_systems[0]
105
+ return prompt_for_build_system_selection(detected_systems)
106
+
107
+
108
+ def _run_docker_flow(folder: Path, publish: bool) -> None:
109
+ """
110
+ Run docker build flow and optional publish flow.
111
+
112
+ :param folder: Folder to build.
113
+ :param publish: Whether publish was requested via flag.
114
+ :returns: ``None``.
115
+ """
116
+ dockerfile = folder / "Dockerfile"
117
+ image_reference = parse_image_reference(dockerfile)
118
+ ensure_docker_is_available()
119
+
120
+ try:
121
+ run_docker_build(folder=folder, image_reference=image_reference, publish=publish)
122
+ if not publish and _confirm_publish():
123
+ run_docker_build(folder=folder, image_reference=image_reference, publish=True)
124
+ except subprocess.CalledProcessError as error:
125
+ raise click.ClickException(
126
+ f"Docker command failed with exit code {error.returncode}."
127
+ ) from error
128
+ except OSError as error:
129
+ raise click.ClickException(str(error)) from error
130
+
131
+
132
+ def _run_poetry_flow(folder: Path, publish: bool) -> None:
133
+ """
134
+ Run poetry build flow and optional publish flow.
135
+
136
+ :param folder: Folder to build.
137
+ :param publish: Whether publish was requested via flag.
138
+ :returns: ``None``.
139
+ """
140
+ try:
141
+ run_poetry_build(folder=folder)
142
+ if publish or _confirm_publish():
143
+ run_poetry_publish(folder=folder)
144
+ except subprocess.CalledProcessError as error:
145
+ raise click.ClickException(
146
+ f"Poetry command failed with exit code {error.returncode}."
147
+ ) from error
148
+ except OSError as error:
149
+ raise click.ClickException(str(error)) from error
150
+
151
+
152
+ def _confirm_publish() -> bool:
153
+ """
154
+ Ask whether the built artifact should be published.
155
+
156
+ :returns: ``True`` when publish was confirmed.
157
+ """
158
+ return click.confirm("Build finished. Publish now?", default=False)
@@ -0,0 +1,105 @@
1
+ """Folder and build-system discovery helpers for ``lange build``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from ._types import DOCKER_BUILD_SYSTEM, POETRY_BUILD_SYSTEM, BuildSystem
10
+
11
+
12
+ def list_candidate_folders(root: Path) -> list[Path]:
13
+ """
14
+ List top-level non-hidden directories.
15
+
16
+ :param root: Directory that should be scanned.
17
+ :returns: Sorted non-hidden child directories.
18
+ """
19
+ candidates = [
20
+ path
21
+ for path in root.iterdir()
22
+ if path.is_dir() and not path.name.startswith(".")
23
+ ]
24
+ return sorted(candidates, key=lambda item: item.name.lower())
25
+
26
+
27
+ def prompt_for_folder_selection(folders: list[Path]) -> Path:
28
+ """
29
+ Prompt the user to choose one folder from candidates.
30
+
31
+ :param folders: Selectable folders.
32
+ :returns: Selected folder path.
33
+ """
34
+ if not folders:
35
+ raise click.ClickException(
36
+ "No selectable folders were found in the current directory."
37
+ )
38
+
39
+ click.echo("Choose a folder to build:")
40
+ for index, folder in enumerate(folders, start=1):
41
+ click.echo(f"{index}. {folder.name}")
42
+
43
+ selection = click.prompt(
44
+ "Selection",
45
+ type=click.IntRange(min=1, max=len(folders)),
46
+ )
47
+ return folders[selection - 1]
48
+
49
+
50
+ def resolve_build_folder(folder_name: str | None, root: Path) -> Path:
51
+ """
52
+ Resolve the build folder from argument value or interactive selection.
53
+
54
+ :param folder_name: Optional folder argument passed via CLI.
55
+ :param root: Current working directory.
56
+ :returns: Target folder for the build.
57
+ """
58
+ if folder_name:
59
+ folder = (root / folder_name).resolve()
60
+ if not folder.exists() or not folder.is_dir():
61
+ raise click.ClickException(
62
+ f"Folder '{folder_name}' was not found or is not a directory."
63
+ )
64
+ return folder
65
+
66
+ folders = list_candidate_folders(root)
67
+ return prompt_for_folder_selection(folders).resolve()
68
+
69
+
70
+ def detect_available_build_systems(folder: Path) -> list[BuildSystem]:
71
+ """
72
+ Detect supported build systems available in the given folder.
73
+
74
+ :param folder: Folder that should be inspected.
75
+ :returns: Ordered list of detected build systems.
76
+ """
77
+ available: list[BuildSystem] = []
78
+ if (folder / "Dockerfile").is_file():
79
+ available.append(DOCKER_BUILD_SYSTEM)
80
+ if (folder / "pyproject.toml").is_file():
81
+ available.append(POETRY_BUILD_SYSTEM)
82
+ return available
83
+
84
+
85
+ def prompt_for_build_system_selection(
86
+ available_systems: list[BuildSystem],
87
+ ) -> BuildSystem:
88
+ """
89
+ Prompt user for a build-system selection.
90
+
91
+ :param available_systems: Selectable build systems.
92
+ :returns: Selected build system.
93
+ """
94
+ if not available_systems:
95
+ raise click.ClickException("No build systems were provided for selection.")
96
+
97
+ click.echo("Multiple build systems detected. Choose one:")
98
+ for index, system in enumerate(available_systems, start=1):
99
+ click.echo(f"{index}. {system}")
100
+
101
+ selection = click.prompt(
102
+ "Selection",
103
+ type=click.IntRange(min=1, max=len(available_systems)),
104
+ )
105
+ return available_systems[selection - 1]
@@ -0,0 +1,127 @@
1
+ """Docker-specific build helpers for ``lange build``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+ BUILDER_NAME = "services-multiarch"
13
+ PLATFORMS = "linux/amd64,linux/arm64"
14
+ IMAGE_LINE_PATTERN = re.compile(r"^#\s*image:\s*(?P<image>\S+)\s*$", re.IGNORECASE)
15
+
16
+
17
+ def ensure_docker_is_available() -> None:
18
+ """
19
+ Validate that docker is installed and available in ``PATH``.
20
+
21
+ :returns: ``None``.
22
+ """
23
+ if shutil.which("docker") is None:
24
+ raise click.ClickException("docker is not installed or not available in PATH.")
25
+
26
+
27
+ def parse_image_reference(dockerfile_path: Path) -> str:
28
+ """
29
+ Parse and normalize image reference from Dockerfile first line.
30
+
31
+ :param dockerfile_path: Dockerfile path to parse.
32
+ :returns: Docker image reference including tag.
33
+ """
34
+ first_line = dockerfile_path.read_text(encoding="utf-8").splitlines()[:1]
35
+ if not first_line:
36
+ raise click.ClickException(
37
+ "Dockerfile must start with '# image: <name>' as first line."
38
+ )
39
+
40
+ match = IMAGE_LINE_PATTERN.match(first_line[0].strip())
41
+ if match is None:
42
+ raise click.ClickException(
43
+ "Dockerfile must start with '# image: <name>' as first line."
44
+ )
45
+
46
+ image_reference = match.group("image").strip()
47
+ if not image_reference:
48
+ raise click.ClickException(
49
+ "Dockerfile must start with '# image: <name>' as first line."
50
+ )
51
+
52
+ if "@" in image_reference or _has_explicit_tag(image_reference):
53
+ return image_reference
54
+ return f"{image_reference}:latest"
55
+
56
+
57
+ def _has_explicit_tag(image_reference: str) -> bool:
58
+ """
59
+ Check whether an image reference already contains a tag component.
60
+
61
+ :param image_reference: Docker image reference.
62
+ :returns: ``True`` when the final path segment contains a tag.
63
+ """
64
+ trailing_segment = image_reference.rsplit("/", maxsplit=1)[-1]
65
+ return ":" in trailing_segment
66
+
67
+
68
+ def ensure_buildx_builder() -> None:
69
+ """
70
+ Ensure the configured docker buildx builder exists and is active.
71
+
72
+ :returns: ``None``.
73
+ """
74
+ inspect_result = subprocess.run(
75
+ ["docker", "buildx", "inspect", BUILDER_NAME],
76
+ check=False,
77
+ stdout=subprocess.DEVNULL,
78
+ stderr=subprocess.DEVNULL,
79
+ )
80
+
81
+ if inspect_result.returncode != 0:
82
+ subprocess.run(
83
+ ["docker", "buildx", "create", "--name", BUILDER_NAME, "--use"],
84
+ check=True,
85
+ stdout=subprocess.DEVNULL,
86
+ )
87
+ else:
88
+ subprocess.run(
89
+ ["docker", "buildx", "use", BUILDER_NAME],
90
+ check=True,
91
+ stdout=subprocess.DEVNULL,
92
+ )
93
+
94
+ subprocess.run(
95
+ ["docker", "buildx", "inspect", "--bootstrap"],
96
+ check=True,
97
+ stdout=subprocess.DEVNULL,
98
+ )
99
+
100
+
101
+ def run_docker_build(folder: Path, image_reference: str, publish: bool) -> None:
102
+ """
103
+ Execute docker buildx build for the given folder.
104
+
105
+ :param folder: Build context folder.
106
+ :param image_reference: Docker image name including tag.
107
+ :param publish: Whether ``--push`` should be included.
108
+ :returns: ``None``.
109
+ """
110
+ dockerfile = (folder / "Dockerfile").resolve()
111
+ ensure_buildx_builder()
112
+
113
+ command: list[str] = [
114
+ "docker",
115
+ "buildx",
116
+ "build",
117
+ "--platform",
118
+ PLATFORMS,
119
+ "--file",
120
+ str(dockerfile),
121
+ "--tag",
122
+ image_reference,
123
+ ]
124
+ if publish:
125
+ command.append("--push")
126
+ command.append(str(folder.resolve()))
127
+ subprocess.run(command, check=True)
@@ -0,0 +1,26 @@
1
+ """Poetry-specific build helpers for ``lange build``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+
9
+ def run_poetry_build(folder: Path) -> None:
10
+ """
11
+ Run ``poetry build`` in the target folder.
12
+
13
+ :param folder: Target folder containing ``pyproject.toml``.
14
+ :returns: ``None``.
15
+ """
16
+ subprocess.run(["poetry", "build"], check=True, cwd=folder)
17
+
18
+
19
+ def run_poetry_publish(folder: Path) -> None:
20
+ """
21
+ Run ``poetry publish`` in the target folder.
22
+
23
+ :param folder: Target folder containing ``pyproject.toml``.
24
+ :returns: ``None``.
25
+ """
26
+ subprocess.run(["poetry", "publish"], check=True, cwd=folder)
@@ -0,0 +1,10 @@
1
+ """Shared build command type definitions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ BuildSystem = Literal["docker", "poetry"]
8
+
9
+ DOCKER_BUILD_SYSTEM: BuildSystem = "docker"
10
+ POETRY_BUILD_SYSTEM: BuildSystem = "poetry"
@@ -0,0 +1,13 @@
1
+ import click
2
+
3
+ from ._stats import code_stats
4
+
5
+ @click.group()
6
+ def code_group() -> None:
7
+ """
8
+ Group for source-code related commands.
9
+
10
+ :returns: ``None``.
11
+ """
12
+
13
+ code_group.add_command(code_stats,"code_stats")
@@ -0,0 +1,81 @@
1
+ from pathlib import Path
2
+ from typing import Iterable
3
+ import os
4
+ import click
5
+
6
+ SUPPORTED_EXTENSIONS: tuple[str, ...] = (".py", ".tsx", ".js", ".jsx", ".ts", ".html", ".css")
7
+ IGNORED_DIRECTORIES: tuple[str, ...] = (".venv", "node_modules", ".git", ".next")
8
+
9
+
10
+ def _render_stats_table(stats: dict[str, int]) -> str:
11
+ """
12
+ Render LOC statistics as an ASCII box table.
13
+
14
+ :param stats: Mapping from file ending to line count.
15
+ :returns: Table string with file type, LOC and percentage values.
16
+ """
17
+ total = sum(stats.values())
18
+ rows: list[tuple[str, str, str]] = []
19
+
20
+ for extension, loc in sorted(stats.items(), key=lambda item: (-item[1], item[0])):
21
+ percentage = (loc / total * 100.0) if total else 0.0
22
+ rows.append((extension, str(loc), f"{percentage:.2f}%"))
23
+
24
+ total_percentage = 100.0 if total else 0.0
25
+ rows.append(("TOTAL", str(total), f"{total_percentage:.2f}%"))
26
+
27
+ headers = ("File-Type", "LOC", "Percentage")
28
+ col_widths = [
29
+ max(len(headers[index]), max((len(row[index]) for row in rows), default=0))
30
+ for index in range(3)
31
+ ]
32
+
33
+ border = "+" + "+".join("-" * (width + 2) for width in col_widths) + "+"
34
+
35
+ def _render_row(values: tuple[str, str, str]) -> str:
36
+ padded = [value.ljust(col_widths[index]) for index, value in enumerate(values)]
37
+ return "| " + " | ".join(padded) + " |"
38
+
39
+ lines = [border, _render_row(headers), border]
40
+ lines.extend(_render_row(row) for row in rows)
41
+ lines.append(border)
42
+ return "\n".join(lines)
43
+
44
+ def _count_lines_by_extension(root: Path, extensions: Iterable[str]) -> dict[str, int]:
45
+ """
46
+ Count file lines recursively grouped by file ending.
47
+
48
+ :param root: Directory that should be scanned recursively.
49
+ :param extensions: Allowed file endings, including the leading dot.
50
+ :returns: Mapping from file ending to counted LOC.
51
+ """
52
+ normalized_extensions = tuple(extension.lower() for extension in extensions)
53
+ counts = {extension: 0 for extension in normalized_extensions}
54
+
55
+ for current_root, directories, files in os.walk(root, topdown=True):
56
+ directories[:] = [name for name in directories if name not in IGNORED_DIRECTORIES]
57
+
58
+ for file_name in files:
59
+ suffix = Path(file_name).suffix.lower()
60
+ if suffix not in counts:
61
+ continue
62
+
63
+ file_path = Path(current_root) / file_name
64
+ with file_path.open("r", encoding="utf-8", errors="ignore") as file_handle:
65
+ counts[suffix] += sum(1 for _ in file_handle)
66
+
67
+ return counts
68
+
69
+ @click.command("stats")
70
+ def code_stats() -> None:
71
+ """
72
+ Print LOC statistics for the current working directory.
73
+
74
+ :returns: ``None``.
75
+ """
76
+ stats = _count_lines_by_extension(Path.cwd(), SUPPORTED_EXTENSIONS)
77
+ click.echo()
78
+ click.echo()
79
+ click.echo(f"Recognized file endings: {' '.join(SUPPORTED_EXTENSIONS)}")
80
+ click.echo(f"Ignored folders: {' '.join(IGNORED_DIRECTORIES)}")
81
+ click.echo(_render_stats_table(stats))
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lange-python"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "A bundeld set of tools, clients for the lange-suite of tools and more."
5
5
  authors = [
6
6
  {name = "contact@robertlange.me"}
@@ -1,264 +0,0 @@
1
- """CLI commands for the ``lange`` Python package."""
2
-
3
- from __future__ import annotations
4
-
5
- from collections.abc import Iterable
6
- import json
7
- import os
8
- from pathlib import Path
9
- import subprocess
10
- from typing import Literal, cast
11
-
12
- import click
13
-
14
- from lange.services_schema import Project, Service
15
-
16
- SUPPORTED_EXTENSIONS: tuple[str, ...] = (".py", ".tsx", ".js", ".jsx", ".ts", ".html", ".css")
17
- IGNORED_DIRECTORIES: tuple[str, ...] = (".venv", "node_modules", ".git", ".next")
18
- SERVICES_FILE_NAME = "services.json"
19
-
20
-
21
- def count_lines_by_extension(root: Path, extensions: Iterable[str]) -> dict[str, int]:
22
- """
23
- Count file lines recursively grouped by file ending.
24
-
25
- :param root: Directory that should be scanned recursively.
26
- :param extensions: Allowed file endings, including the leading dot.
27
- :returns: Mapping from file ending to counted LOC.
28
- """
29
- normalized_extensions = tuple(extension.lower() for extension in extensions)
30
- counts = {extension: 0 for extension in normalized_extensions}
31
-
32
- for current_root, directories, files in os.walk(root, topdown=True):
33
- directories[:] = [name for name in directories if name not in IGNORED_DIRECTORIES]
34
-
35
- for file_name in files:
36
- suffix = Path(file_name).suffix.lower()
37
- if suffix not in counts:
38
- continue
39
-
40
- file_path = Path(current_root) / file_name
41
- with file_path.open("r", encoding="utf-8", errors="ignore") as file_handle:
42
- counts[suffix] += sum(1 for _ in file_handle)
43
-
44
- return counts
45
-
46
-
47
- def render_stats_table(stats: dict[str, int]) -> str:
48
- """
49
- Render LOC statistics as an ASCII box table.
50
-
51
- :param stats: Mapping from file ending to line count.
52
- :returns: Table string with file type, LOC and percentage values.
53
- """
54
- total = sum(stats.values())
55
- rows: list[tuple[str, str, str]] = []
56
-
57
- for extension, loc in sorted(stats.items(), key=lambda item: (-item[1], item[0])):
58
- percentage = (loc / total * 100.0) if total else 0.0
59
- rows.append((extension, str(loc), f"{percentage:.2f}%"))
60
-
61
- total_percentage = 100.0 if total else 0.0
62
- rows.append(("TOTAL", str(total), f"{total_percentage:.2f}%"))
63
-
64
- headers = ("File-Type", "LOC", "Percentage")
65
- col_widths = [
66
- max(len(headers[index]), max((len(row[index]) for row in rows), default=0))
67
- for index in range(3)
68
- ]
69
-
70
- border = "+" + "+".join("-" * (width + 2) for width in col_widths) + "+"
71
-
72
- def _render_row(values: tuple[str, str, str]) -> str:
73
- padded = [value.ljust(col_widths[index]) for index, value in enumerate(values)]
74
- return "| " + " | ".join(padded) + " |"
75
-
76
- lines = [border, _render_row(headers), border]
77
- lines.extend(_render_row(row) for row in rows)
78
- lines.append(border)
79
- return "\n".join(lines)
80
-
81
-
82
- def services_file_path(root: Path) -> Path:
83
- """
84
- Build the absolute path to ``services.json`` in a workspace root.
85
-
86
- :param root: Workspace root directory.
87
- :returns: Absolute path to ``services.json``.
88
- """
89
- return root / SERVICES_FILE_NAME
90
-
91
-
92
- def load_project_config(root: Path) -> Project:
93
- """
94
- Load a project config from ``services.json`` when available.
95
-
96
- :param root: Workspace root directory.
97
- :returns: Parsed project config. Defaults to an empty project.
98
- """
99
- path = services_file_path(root)
100
- if not path.exists():
101
- return Project(services=[])
102
-
103
- data = json.loads(path.read_text(encoding="utf-8"))
104
- return Project.model_validate(data)
105
-
106
-
107
- def save_project_config(root: Path, project: Project) -> Path:
108
- """
109
- Persist project config to ``services.json``.
110
-
111
- :param root: Workspace root directory.
112
- :param project: Project data to store.
113
- :returns: Written file path.
114
- """
115
- path = services_file_path(root)
116
- path.write_text(project.model_dump_json(indent=2) + "\n", encoding="utf-8")
117
- return path
118
-
119
-
120
- @click.group()
121
- def cli() -> None:
122
- """
123
- Lange CLI entrypoint.
124
-
125
- :returns: ``None``.
126
- """
127
-
128
-
129
- @cli.group()
130
- def code() -> None:
131
- """
132
- Group for source-code related commands.
133
-
134
- :returns: ``None``.
135
- """
136
-
137
-
138
- @cli.command("init")
139
- def init_workspace() -> None:
140
- """
141
- Initialize local ``.lange`` workspace artifacts.
142
-
143
- Creates ``.lange/.gitignore`` and ``.lange/secrets.json`` in the current
144
- directory.
145
-
146
- :returns: ``None``.
147
- """
148
- lange_directory = Path.cwd() / ".lange"
149
- lange_directory.mkdir(parents=True, exist_ok=True)
150
- gitignore_file = lange_directory / ".gitignore"
151
- gitignore_file.write_text("*\n", encoding="utf-8")
152
- secrets_file = lange_directory / "secrets.json"
153
- secrets_file.write_text("{}\n", encoding="utf-8")
154
- click.echo(f"Initialized {gitignore_file}")
155
-
156
-
157
- @cli.command("create")
158
- def create_service() -> None:
159
- """
160
- Interactively add a service entry to ``services.json``.
161
-
162
- :returns: ``None``.
163
- """
164
- root = Path.cwd()
165
- project = load_project_config(root)
166
-
167
- name = click.prompt("Service name", type=str).strip()
168
- path = click.prompt("Service path", type=str, default=".", show_default=True).strip()
169
- build_type = click.prompt(
170
- "Build type",
171
- type=click.Choice(["docker", "pyinstall"], case_sensitive=False),
172
- default="docker",
173
- show_default=True,
174
- ).lower()
175
- publish_path_input = click.prompt("Publish path (optional)", default="", show_default=False).strip()
176
-
177
- service = Service(
178
- name=name,
179
- path=path,
180
- build_type=cast(Literal["docker", "pyinstall"], build_type),
181
- publish_path=publish_path_input or None,
182
- )
183
- project.services.append(service)
184
-
185
- config_path = save_project_config(root, project)
186
- click.echo(f"Added service '{service.name}' to {config_path}")
187
-
188
-
189
- def _iter_services_for_build(project: Project, target_name: str | None) -> list[Service]:
190
- """
191
- Select services for a build operation.
192
-
193
- :param project: Parsed project configuration.
194
- :param target_name: Optional service name filter.
195
- :returns: Services to build.
196
- :raises click.ClickException: Raised when no services can be built.
197
- """
198
- if target_name is None:
199
- services = project.services
200
- else:
201
- services = [service for service in project.services if service.name == target_name]
202
-
203
- if not services:
204
- if target_name:
205
- raise click.ClickException(f"Service '{target_name}' not found in {SERVICES_FILE_NAME}.")
206
- raise click.ClickException(f"No services configured in {SERVICES_FILE_NAME}.")
207
-
208
- return services
209
-
210
-
211
- @cli.command("build")
212
- @click.argument("service_name", required=False)
213
- def build_services(service_name: str | None) -> None:
214
- """
215
- Build configured services from ``services.json``.
216
-
217
- Only docker build logic is implemented at the moment.
218
-
219
- :param service_name: Optional single service name to build.
220
- :returns: ``None``.
221
- """
222
- root = Path.cwd()
223
- project = load_project_config(root)
224
- services = _iter_services_for_build(project, service_name)
225
-
226
- for service in services:
227
- if service.build_type != "docker":
228
- click.echo(f"Skipping '{service.name}' (build_type={service.build_type} not implemented).")
229
- continue
230
-
231
- context_path = service.resolve_path(root)
232
- dockerfile_path = context_path / "Dockerfile"
233
- if not dockerfile_path.exists():
234
- raise click.ClickException(f"Missing Dockerfile for service '{service.name}' at {dockerfile_path}")
235
-
236
- image_name = service.publish_path or service.name
237
- click.echo(f"Building '{service.name}' as image '{image_name}' from {context_path}")
238
- subprocess.run(
239
- [
240
- "docker",
241
- "build",
242
- "-t",
243
- image_name,
244
- "-f",
245
- str(dockerfile_path),
246
- str(context_path),
247
- ],
248
- check=True,
249
- )
250
-
251
-
252
- @code.command("stats")
253
- def code_stats() -> None:
254
- """
255
- Print LOC statistics for the current working directory.
256
-
257
- :returns: ``None``.
258
- """
259
- stats = count_lines_by_extension(Path.cwd(), SUPPORTED_EXTENSIONS)
260
- click.echo()
261
- click.echo()
262
- click.echo(f"Recognized file endings: {' '.join(SUPPORTED_EXTENSIONS)}")
263
- click.echo(f"Ignored folders: {' '.join(IGNORED_DIRECTORIES)}")
264
- click.echo(render_stats_table(stats))
@@ -1,50 +0,0 @@
1
- """Pydantic schemas for ``services.json`` project configuration."""
2
-
3
- from __future__ import annotations
4
-
5
- from pathlib import Path
6
- from typing import Literal
7
-
8
- from pydantic import BaseModel, ConfigDict
9
-
10
-
11
- class Service(BaseModel):
12
- """
13
- Service definition stored in ``services.json``.
14
-
15
- :param name: Human-readable service name.
16
- :param path: Relative or absolute directory path for this service.
17
- :param build_type: Build strategy, either docker or pyinstall.
18
- :param publish_path: Optional publish target or image name.
19
- """
20
-
21
- model_config = ConfigDict(extra="forbid")
22
-
23
- name: str
24
- path: str
25
- build_type: Literal["docker", "pyinstall"]
26
- publish_path: str | None = None
27
-
28
- def resolve_path(self, root: Path) -> Path:
29
- """
30
- Resolve the service path relative to a workspace root.
31
-
32
- :param root: Workspace root directory.
33
- :returns: Absolute service path.
34
- """
35
- path = Path(self.path)
36
- if path.is_absolute():
37
- return path
38
- return (root / path).resolve()
39
-
40
-
41
- class Project(BaseModel):
42
- """
43
- Project configuration for service-based operations.
44
-
45
- :param services: Configured services in this project.
46
- """
47
-
48
- model_config = ConfigDict(extra="forbid")
49
-
50
- services: list[Service]
File without changes