ps-plugin-module-delivery 0.2.8__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.
Files changed (25) hide show
  1. ps_plugin_module_delivery-0.2.8/PKG-INFO +11 -0
  2. ps_plugin_module_delivery-0.2.8/pyproject.toml +27 -0
  3. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/__init__.py +5 -0
  4. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/_delivery_module.py +179 -0
  5. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/_delivery_settings.py +9 -0
  6. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/_parallelization.py +105 -0
  7. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/commands/__init__.py +5 -0
  8. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/commands/_delivery_command.py +65 -0
  9. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/output/__init__.py +23 -0
  10. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/output/_formatted.py +112 -0
  11. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/output/_json.py +76 -0
  12. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/output/_models.py +53 -0
  13. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/output/_protocol.py +22 -0
  14. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/stages/__init__.py +26 -0
  15. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/stages/_build.py +53 -0
  16. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/stages/_logging.py +108 -0
  17. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/stages/_metadata.py +243 -0
  18. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/stages/_patch.py +102 -0
  19. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/stages/_publish.py +76 -0
  20. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/token_resolvers/__init__.py +14 -0
  21. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/token_resolvers/_date_resolver.py +135 -0
  22. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/token_resolvers/_env_resolver.py +12 -0
  23. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/token_resolvers/_git_resolver.py +99 -0
  24. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/token_resolvers/_rand_resolver.py +43 -0
  25. ps_plugin_module_delivery-0.2.8/src/ps/plugin/module/delivery/token_resolvers/_version_resolver.py +21 -0
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: ps-plugin-module-delivery
3
+ Version: 0.2.8
4
+ Summary:
5
+ Requires-Python: >=3.10,<3.14
6
+ Classifier: Programming Language :: Python :: 3
7
+ Classifier: Programming Language :: Python :: 3.10
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Requires-Dist: ps-di (>=0.2.8,<0.3.0)
@@ -0,0 +1,27 @@
1
+ [project]
2
+ name = "ps-plugin-module-delivery"
3
+ description = ""
4
+ requires-python = ">=3.10,<3.14"
5
+ version = "0.2.8"
6
+
7
+ [project.entry-points."ps.module"]
8
+ module_entry = "ps.plugin.module.delivery"
9
+
10
+ [tool.poetry.group.ps.dependencies]
11
+ ps-version = ">=0.2.8,<0.3.0"
12
+ ps-token-expressions = ">=0.2.8,<0.3.0"
13
+ ps-plugin-sdk = ">=0.2.8,<0.3.0"
14
+
15
+
16
+ [tool.poetry.dependencies]
17
+ ps-di = ">=0.2.8,<0.3.0"
18
+
19
+ [tool.poetry]
20
+ packages = [ { include = "ps/plugin/module/delivery", from = "src" } ]
21
+
22
+ [tool.ps-plugin]
23
+ host-project = "../.."
24
+
25
+ [build-system]
26
+ requires = ["poetry-core>=1.0.0"]
27
+ build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,5 @@
1
+ from ._delivery_module import DeliveryModule
2
+
3
+ __all__ = [
4
+ "DeliveryModule",
5
+ ]
@@ -0,0 +1,179 @@
1
+ from datetime import datetime
2
+ from pathlib import Path
3
+ from typing import ClassVar, Optional
4
+
5
+ from cleo.events.console_command_event import ConsoleCommandEvent
6
+ from cleo.events.console_terminate_event import ConsoleTerminateEvent
7
+ from cleo.io.inputs.argument import Argument
8
+ from cleo.io.inputs.input import Input
9
+ from cleo.io.inputs.option import Option
10
+ from cleo.io.io import IO
11
+ from poetry.console.application import Application
12
+ from poetry.console.commands.build import BuildCommand
13
+ from poetry.console.commands.publish import PublishCommand
14
+
15
+ from ps.di import DI
16
+ from ps.plugin.sdk.events import ensure_argument, ensure_option
17
+ from ps.plugin.sdk.project import Environment, filter_projects
18
+ from ps.version import Version
19
+ from ps.token_expressions import TokenResolverEntry
20
+
21
+ from .token_resolvers import DateResolver, EnvResolver, RandResolver, VersionResolver, collect_git_info
22
+ from .stages import (
23
+ DeliverableType,
24
+ ResolvedProjectMetadata,
25
+ build_projects,
26
+ log_resolution,
27
+ patch_projects,
28
+ publish_projects,
29
+ resolve_environment_metadata,
30
+ )
31
+ from .commands import DeliveryCommand
32
+
33
+
34
+ BUILD_VERSION_OPTION = "build-version"
35
+ BUILD_VERSION_OPTION_SHORT = "b"
36
+ INPUTS_ARGUMENT = "inputs"
37
+
38
+
39
+ def _get_inputs(input: Input) -> list[str]:
40
+ return input.arguments.get(INPUTS_ARGUMENT, [])
41
+
42
+
43
+ def _get_version_option(input: Input) -> Optional[str]:
44
+ return input.options.get(BUILD_VERSION_OPTION, None)
45
+
46
+
47
+ class DeliveryModule:
48
+ name: ClassVar[str] = "ps-delivery"
49
+
50
+ def __init__(self) -> None:
51
+ self._exit_code: Optional[int] = None
52
+
53
+ def poetry_activate(
54
+ self,
55
+ application: Application,
56
+ environment: Environment,
57
+ di: DI,
58
+ io: IO
59
+ ) -> bool:
60
+ # Extend the BuildCommand with an optional "inputs" argument
61
+ ensure_argument(BuildCommand, Argument(
62
+ name=INPUTS_ARGUMENT,
63
+ description="Optional inputs pointers to build. It could be project names or paths. If not provided, all discovered projects will be built.",
64
+ is_list=True,
65
+ required=False)
66
+ )
67
+ ensure_option(BuildCommand, Option(
68
+ name=BUILD_VERSION_OPTION,
69
+ description="Specify the version to build the project with.",
70
+ shortcut=BUILD_VERSION_OPTION_SHORT,
71
+ flag=False)
72
+ )
73
+ ensure_argument(PublishCommand, Argument(
74
+ name=INPUTS_ARGUMENT,
75
+ description="Optional inputs pointers to publish. It could be project names or paths. If not provided, all discovered projects will be published.",
76
+ is_list=True,
77
+ required=False)
78
+ )
79
+ ensure_option(PublishCommand, Option(
80
+ name=BUILD_VERSION_OPTION,
81
+ description="Specify the version to publish the project with.",
82
+ shortcut=BUILD_VERSION_OPTION_SHORT,
83
+ flag=False)
84
+ )
85
+
86
+ application.add(di.spawn(DeliveryCommand))
87
+
88
+ di.register(TokenResolverEntry).factory(lambda: ("env", EnvResolver()))
89
+ di.register(TokenResolverEntry).factory(lambda: ("rand", RandResolver()))
90
+ di.register(TokenResolverEntry).factory(lambda: ("v", VersionResolver()))
91
+ di.register(TokenResolverEntry).factory(lambda: ("date", DateResolver(datetime.now())))
92
+ di.register(TokenResolverEntry).factory(lambda env: ("git", collect_git_info(env)), env=environment.host_project.path)
93
+ di.register(TokenResolverEntry).factory(lambda ver: ("in", ver), ver=Version.parse(_get_version_option(io.input)))
94
+ return True
95
+
96
+ def poetry_command(self, event: ConsoleCommandEvent, di: DI) -> None:
97
+ if not isinstance(event.command, (BuildCommand, PublishCommand)):
98
+ return
99
+
100
+ # Disable the original command execution
101
+ event.disable_command()
102
+
103
+ environment = di.resolve(Environment)
104
+ assert environment is not None
105
+
106
+ # Filter projects based on inputs
107
+ inputs = _get_inputs(event.io.input)
108
+ # In case no inputs are provided, and the entry project is different from the host project, add the entry project path as input
109
+ if not inputs and environment.host_project != environment.entry_project:
110
+ inputs.append(str(environment.entry_project.path))
111
+ environment_metadata = di.satisfy(resolve_environment_metadata)()
112
+
113
+ log_resolution(event.io, environment_metadata)
114
+
115
+ filtered_projects = filter_projects(inputs, environment.projects)
116
+ excluded = {id(p) for p in filtered_projects if (environment_metadata.projects.get(p.path) or ResolvedProjectMetadata()).deliver != DeliverableType.ENABLED}
117
+ event.io.write_line("<fg=magenta>Delivery scope:</>")
118
+ for p in filtered_projects:
119
+ name = p.name.value or p.path.name
120
+ if id(p) in excluded:
121
+ event.io.write_line(f" - <fg=dark_gray>{name} (not marked for delivery)</>")
122
+ else:
123
+ event.io.write_line(f" - <fg=blue>{name}</>")
124
+ filtered_projects = [p for p in filtered_projects if id(p) not in excluded]
125
+ if not filtered_projects:
126
+ event.io.write_line("<comment>No projects found to process.</comment>")
127
+ return
128
+
129
+ try:
130
+ environment.backup_projects(filtered_projects)
131
+
132
+ # Patch all projects
133
+ patch_exit_code = patch_projects(event.io, filtered_projects, environment_metadata)
134
+ if patch_exit_code != 0:
135
+ self._exit_code = patch_exit_code
136
+ return
137
+
138
+ # Execute build or publish command
139
+ is_publish = isinstance(event.command, PublishCommand)
140
+ if is_publish:
141
+ opts = event.io.input.options
142
+ cert = opts.get("cert")
143
+ client_cert = opts.get("client-cert")
144
+ dist_dir = opts.get("dist-dir")
145
+ self._exit_code = publish_projects(
146
+ event.io,
147
+ filtered_projects,
148
+ environment,
149
+ environment_metadata,
150
+ repository=opts.get("repository"),
151
+ username=opts.get("username"),
152
+ password=opts.get("password"),
153
+ cert=Path(cert) if cert else None,
154
+ client_cert=Path(client_cert) if client_cert else None,
155
+ dist_dir=Path(dist_dir) if dist_dir else None,
156
+ dry_run=bool(opts.get("dry-run")),
157
+ skip_existing=bool(opts.get("skip-existing")),
158
+ )
159
+ else:
160
+ opts = event.io.input.options
161
+ self._exit_code = build_projects(
162
+ event.io,
163
+ filtered_projects,
164
+ formats=BuildCommand._prepare_formats(opts.get("format")),
165
+ clean=bool(opts.get("clean")),
166
+ output=opts.get("output") or "dist",
167
+ config_settings=BuildCommand._prepare_config_settings(
168
+ local_version=opts.get("local-version"),
169
+ config_settings=opts.get("config-settings"),
170
+ io=event.io,
171
+ ),
172
+ )
173
+ finally:
174
+ environment.restore_projects(environment.projects)
175
+
176
+ def poetry_terminate(self, event: ConsoleTerminateEvent) -> None:
177
+ if self._exit_code is None:
178
+ return
179
+ event.set_exit_code(self._exit_code)
@@ -0,0 +1,9 @@
1
+ from typing import Optional
2
+ from pydantic import BaseModel, Field
3
+ from ps.version import VersionConstraint
4
+
5
+
6
+ class DeliverySettings(BaseModel):
7
+ version_patterns: Optional[list[str]] = Field(default_factory=list, alias="version-patterns")
8
+ version_pinning: Optional[VersionConstraint] = Field(default=VersionConstraint.COMPATIBLE, alias="version-pinning")
9
+ deliver: Optional[bool] = Field(default=None, alias="deliver")
@@ -0,0 +1,105 @@
1
+ import logging
2
+ import threading
3
+ from concurrent.futures import ThreadPoolExecutor
4
+ from typing import Callable, Optional, TypeVar
5
+
6
+ from cleo.formatters.formatter import Formatter
7
+ from cleo.io.buffered_io import BufferedIO
8
+ from cleo.io.io import IO
9
+
10
+ T = TypeVar("T")
11
+
12
+ _thread_io: threading.local = threading.local()
13
+
14
+
15
+ class ThreadLocalIOHandler(logging.Handler):
16
+ def emit(self, record: logging.LogRecord) -> None:
17
+ bio: Optional[BufferedIO] = getattr(_thread_io, "io", None)
18
+ if bio is None:
19
+ return
20
+ try:
21
+ msg = self.format(record)
22
+ if record.levelname.lower() in ("warning", "error", "exception", "critical"):
23
+ bio.write_error_line(msg)
24
+ else:
25
+ bio.write_line(msg)
26
+ except Exception:
27
+ self.handleError(record)
28
+
29
+
30
+ def _run_buffered(io: IO, item: T, fn: Callable[[BufferedIO, T], int]) -> tuple[int, str, str]:
31
+ buffered_io = BufferedIO(decorated=io.output.is_decorated())
32
+ buffered_io.set_verbosity(io.output.verbosity)
33
+ _thread_io.io = buffered_io
34
+ try:
35
+ exit_code = fn(buffered_io, item)
36
+ except Exception as e:
37
+ buffered_io.write_error_line(f"\n<error>{Formatter.escape(str(e))}</error>")
38
+ exit_code = 1
39
+ finally:
40
+ _thread_io.io = None
41
+ return exit_code, buffered_io.fetch_output(), buffered_io.fetch_error()
42
+
43
+
44
+ def _flush(io: IO, out: str, err: str) -> None:
45
+ if out:
46
+ io.write(out)
47
+ if err:
48
+ io.write_error(err)
49
+
50
+
51
+ def _normalize(text: str) -> str:
52
+ text = text.replace("\\n", "\n").replace("\r\n", "\n").replace("\r", "\n").strip()
53
+ lines = [line for line in text.splitlines() if line.strip()]
54
+ if not lines:
55
+ return ""
56
+ return "\n".join(lines) + "\n"
57
+
58
+
59
+ def run_parallel(io: IO, items: list[T], fn: Callable[[BufferedIO, T], int]) -> int:
60
+ lock = threading.Lock()
61
+ exit_code = 0
62
+
63
+ def _run_and_flush(item: T) -> int:
64
+ result, out, err = _run_buffered(io, item, fn)
65
+ with lock:
66
+ _flush(io, out, err)
67
+ return result
68
+
69
+ with ThreadPoolExecutor() as executor:
70
+ results = list(executor.map(_run_and_flush, items))
71
+
72
+ for result in results:
73
+ if result != 0:
74
+ exit_code = result
75
+
76
+ return exit_code
77
+
78
+
79
+ def run_topological(
80
+ io: IO,
81
+ items: list[T],
82
+ fn: Callable[[BufferedIO, T], int],
83
+ get_deps: Callable[[T], list[T]],
84
+ ) -> int:
85
+ lock = threading.Lock()
86
+ done_events: dict[int, threading.Event] = {id(item): threading.Event() for item in items}
87
+
88
+ def _run_and_flush(item: T) -> int:
89
+ for dep in get_deps(item):
90
+ done_events[id(dep)].wait()
91
+ result, out, err = _run_buffered(io, item, fn)
92
+ with lock:
93
+ _flush(io, _normalize(out), _normalize(err))
94
+ done_events[id(item)].set()
95
+ return result
96
+
97
+ with ThreadPoolExecutor() as executor:
98
+ futures = [executor.submit(_run_and_flush, item) for item in items]
99
+
100
+ exit_code = 0
101
+ for future in futures:
102
+ r = future.result()
103
+ if r != 0:
104
+ exit_code = r
105
+ return exit_code
@@ -0,0 +1,5 @@
1
+ from ._delivery_command import DeliveryCommand
2
+
3
+ __all__ = [
4
+ "DeliveryCommand",
5
+ ]
@@ -0,0 +1,65 @@
1
+ from cleo.commands.command import Command
2
+ from cleo.io.inputs.option import Option
3
+
4
+ from ps.di import DI
5
+ from ..output import DeliveryRenderer, FormattedDeliveryRenderer, JsonDeliveryRenderer
6
+ from ps.plugin.sdk.project import Environment
7
+
8
+ from ..stages import (
9
+ DeliverableType,
10
+ ResolvedProjectMetadata,
11
+ build_dependency_tree,
12
+ build_publish_waves,
13
+ resolve_environment_metadata,
14
+ )
15
+
16
+
17
+ class DeliveryCommand(Command):
18
+ name = "delivery"
19
+ description = "Display the workspace delivery plan."
20
+ arguments = []
21
+ options = [
22
+ Option("--json", flag=True, description="Output as JSON"),
23
+ Option("--publish-order", flag=True, description="Show publish order"),
24
+ Option("--dependency-tree", flag=True, description="Show dependency tree"),
25
+ Option("--projects", flag=True, description="Show projects"),
26
+ ]
27
+
28
+ def __init__(self, di: DI) -> None:
29
+ super().__init__()
30
+ self._di = di
31
+
32
+ def handle(self) -> int:
33
+ io = self.io
34
+ environment = self._di.resolve(Environment)
35
+ assert environment is not None
36
+
37
+ environment_metadata = self._di.satisfy(resolve_environment_metadata)()
38
+ all_projects = list(environment.projects)
39
+ filtered = [
40
+ p for p in all_projects
41
+ if (environment_metadata.projects.get(p.path) or ResolvedProjectMetadata()).deliver == DeliverableType.ENABLED
42
+ ]
43
+
44
+ renderer: DeliveryRenderer = JsonDeliveryRenderer(io) if self.option("json") else FormattedDeliveryRenderer(io)
45
+ show_publish_order = self.option("publish-order")
46
+ show_dependency_tree = self.option("dependency-tree")
47
+ show_projects = self.option("projects")
48
+ has_filter = show_publish_order or show_dependency_tree or show_projects
49
+
50
+ if not has_filter or show_projects:
51
+ renderer.render_resolution("Projects", environment_metadata.resolutions)
52
+
53
+ if not has_filter or show_dependency_tree:
54
+ nodes = build_dependency_tree(all_projects, environment, environment_metadata)
55
+ renderer.render_dependency_tree("Dependency tree", nodes)
56
+
57
+ if not has_filter or show_publish_order:
58
+ if not filtered:
59
+ renderer.render_message("<comment>No deliverable projects.</comment>")
60
+ else:
61
+ waves = build_publish_waves(filtered, environment, environment_metadata)
62
+ renderer.render_publish_waves("Publish order", waves)
63
+
64
+ renderer.flush()
65
+ return 0
@@ -0,0 +1,23 @@
1
+ from ._formatted import FormattedDeliveryRenderer
2
+ from ._json import JsonDeliveryRenderer
3
+ from ._models import (
4
+ DependencyNode,
5
+ DependencyResolution,
6
+ ProjectResolution,
7
+ ProjectSummary,
8
+ PublishWave,
9
+ VersionPatternResult,
10
+ )
11
+ from ._protocol import DeliveryRenderer
12
+
13
+ __all__ = [
14
+ "DeliveryRenderer",
15
+ "DependencyNode",
16
+ "DependencyResolution",
17
+ "FormattedDeliveryRenderer",
18
+ "JsonDeliveryRenderer",
19
+ "ProjectResolution",
20
+ "ProjectSummary",
21
+ "PublishWave",
22
+ "VersionPatternResult",
23
+ ]
@@ -0,0 +1,112 @@
1
+ from cleo.io.io import IO
2
+
3
+ from ._models import DependencyNode, DependencyResolution, ProjectResolution, PublishWave
4
+ from ._protocol import DeliveryRenderer
5
+
6
+
7
+ class FormattedDeliveryRenderer(DeliveryRenderer):
8
+ def __init__(self, io: IO) -> None:
9
+ self._io = io
10
+
11
+ def render_resolution(self, title: str, resolutions: list[ProjectResolution]) -> None:
12
+ self._io.write_line("")
13
+ self._io.write_line(f"<fg=magenta>{title}:</>")
14
+ for r in resolutions:
15
+ self._io.write_line(f"<fg=magenta>Resolving project:</> <fg=blue>{r.name}</> [<fg=dark_gray>{r.path}</>]")
16
+
17
+ if r.deliver == "Enabled":
18
+ deliver_label = "<fg=green>Enabled</>"
19
+ elif r.deliver == "DisabledByPackageMode":
20
+ deliver_label = "<fg=red>Disabled (package-mode)</>"
21
+ else:
22
+ deliver_label = "<fg=red>Disabled (deliver option)</>"
23
+ self._io.write_line(f" - Deliverable: {deliver_label}")
24
+
25
+ if self._io.is_verbose():
26
+ for i, pr in enumerate(r.pattern_results, 1):
27
+ if pr.matched:
28
+ self._io.write_line(f" <fg=dark_gray>[{i}]</> <fg=green>matched</> '<fg=cyan>{pr.pattern}</>' -> <fg=green>{pr.resolved_raw}</>")
29
+ elif pr.condition and pr.errors:
30
+ self._io.write_line(f" <fg=dark_gray>[{i}]</> <fg=red>error</> '<fg=cyan>{pr.pattern}</>' condition invalid: {'; '.join(pr.errors)}")
31
+ elif pr.condition and not pr.condition_matched:
32
+ self._io.write_line(f" <fg=dark_gray>[{i}]</> <fg=yellow>skipped</> '<fg=cyan>{pr.pattern}</>' condition '<fg=dark_gray>{pr.condition}</>'")
33
+ elif pr.errors and not pr.condition:
34
+ self._io.write_line(f" <fg=dark_gray>[{i}]</> <fg=red>error</> '<fg=cyan>{pr.pattern}</>' invalid: {'; '.join(pr.errors)}")
35
+ elif pr.resolved_raw:
36
+ self._io.write_line(f" <fg=dark_gray>[{i}]</> <fg=yellow>failed</> '<fg=cyan>{pr.pattern}</>' -> '<fg=yellow>{pr.resolved_raw}</>' not a valid version")
37
+ else:
38
+ self._io.write_line(f" <fg=dark_gray>[{i}]</> <fg=dark_gray>ignored</> '<fg=cyan>{pr.pattern}</>'")
39
+
40
+ if r.version:
41
+ if self._io.is_verbose():
42
+ self._io.write_line(f" - Version: <fg=green>{r.version}</> (Pattern: '<fg=cyan>{r.matched_pattern}</>', Pinning rule: <fg=cyan>{r.pinning}</>)")
43
+ else:
44
+ self._io.write_line(f" - Version: <fg=green>{r.version}</>")
45
+
46
+ self._render_dependencies(r.dependencies)
47
+
48
+ def render_dependency_tree(self, title: str, roots: list[DependencyNode]) -> None:
49
+ self._io.write_line("")
50
+ self._io.write_line(f"<fg=magenta>{title}:</>")
51
+ for i, root in enumerate(roots):
52
+ is_last = i == len(roots) - 1
53
+ self._print_node(
54
+ root,
55
+ " " + ("└── " if is_last else "├── "),
56
+ " " + (" " if is_last else "│ "),
57
+ )
58
+
59
+ def _print_node(self, node: DependencyNode, prefix: str, child_prefix: str) -> None:
60
+ self._io.write_line(f"{prefix}<fg=blue>{node.name}</> <fg=green>{node.version}</>")
61
+ for i, child in enumerate(node.children):
62
+ is_last = i == len(node.children) - 1
63
+ self._print_node(
64
+ child,
65
+ child_prefix + ("└── " if is_last else "├── "),
66
+ child_prefix + (" " if is_last else "│ "),
67
+ )
68
+
69
+ def render_publish_waves(self, title: str, waves: list[PublishWave]) -> None:
70
+ self._io.write_line("")
71
+ self._io.write_line(f"<fg=magenta>{title}:</>")
72
+ for i, wave in enumerate(waves):
73
+ is_last_wave = i == len(waves) - 1
74
+ wave_prefix = "└── " if is_last_wave else "├── "
75
+ child_prefix = " " if is_last_wave else "│ "
76
+ self._io.write_line(f" {wave_prefix}<fg=magenta>Wave {wave.index}</>")
77
+ for j, p in enumerate(wave.projects):
78
+ is_last_item = j == len(wave.projects) - 1
79
+ item_prefix = "└── " if is_last_item else "├── "
80
+ self._io.write_line(f" {child_prefix}{item_prefix}<fg=blue>{p.name}</> <fg=green>{p.version}</>")
81
+
82
+ def _render_dependencies(self, dependencies: list[DependencyResolution]) -> None:
83
+ for dep in dependencies:
84
+ if dep.is_project:
85
+ base_line = f" - Project dependency '<fg=cyan>{dep.name}</>'"
86
+ if self._io.is_verbose() and dep.path:
87
+ self._io.write_line(f"{base_line} [<fg=dark_gray>{dep.path}</>]")
88
+ else:
89
+ self._io.write_line(base_line)
90
+ elif dep.source == "skipped":
91
+ if self._io.is_debug():
92
+ self._io.write_line(f" <fg=dark_gray>- Dependency '<fg=cyan>{dep.name}</> skipped (no version constraint).</>")
93
+ else:
94
+ base_line = f" - Dependency '<fg=cyan>{dep.name}</>': <fg=green>{dep.constraint}</>"
95
+ detail = None
96
+ if dep.source == "host":
97
+ detail = f"Resolved from <fg=cyan>host</>: <fg=green>{dep.constraint}</>"
98
+ elif dep.source in ("host-no-constraint", "not-found"):
99
+ detail = f"from <fg=cyan>{dep.source}</>"
100
+ elif dep.source == "direct" and self._io.is_debug():
101
+ detail = "<fg=dark_gray>direct</>"
102
+
103
+ if detail and self._io.is_verbose():
104
+ self._io.write_line(f"{base_line} ({detail})")
105
+ elif not self._io.is_debug() or detail:
106
+ self._io.write_line(base_line)
107
+
108
+ def render_message(self, text: str) -> None:
109
+ self._io.write_line(text)
110
+
111
+ def flush(self) -> None:
112
+ pass
@@ -0,0 +1,76 @@
1
+ import json
2
+ from typing import Any
3
+
4
+ from cleo.io.io import IO
5
+
6
+ from ._models import DependencyNode, ProjectResolution, PublishWave
7
+ from ._protocol import DeliveryRenderer
8
+
9
+
10
+ class JsonDeliveryRenderer(DeliveryRenderer):
11
+ def __init__(self, io: IO) -> None:
12
+ self._io = io
13
+ self._data: dict[str, Any] = {}
14
+
15
+ def render_resolution(self, title: str, resolutions: list[ProjectResolution]) -> None:
16
+ self._data[self._key(title)] = [
17
+ {
18
+ "name": r.name,
19
+ "path": r.path,
20
+ "version": r.version,
21
+ "deliver": r.deliver,
22
+ "pinning": r.pinning,
23
+ "matched_pattern": r.matched_pattern,
24
+ "pattern_results": [
25
+ {
26
+ "pattern": pr.pattern,
27
+ "condition": pr.condition,
28
+ "condition_matched": pr.condition_matched,
29
+ "resolved_raw": pr.resolved_raw,
30
+ "matched": pr.matched,
31
+ "errors": pr.errors,
32
+ }
33
+ for pr in r.pattern_results
34
+ ],
35
+ "dependencies": [
36
+ {
37
+ "name": d.name,
38
+ "constraint": d.constraint,
39
+ "is_project": d.is_project,
40
+ "path": d.path,
41
+ "source": d.source,
42
+ }
43
+ for d in r.dependencies
44
+ ],
45
+ }
46
+ for r in resolutions
47
+ ]
48
+
49
+ def render_dependency_tree(self, title: str, roots: list[DependencyNode]) -> None:
50
+ self._data[self._key(title)] = [self._node_to_dict(n) for n in roots]
51
+
52
+ def render_publish_waves(self, title: str, waves: list[PublishWave]) -> None:
53
+ self._data[self._key(title)] = [
54
+ {
55
+ "wave": w.index,
56
+ "projects": [{"name": p.name, "version": p.version} for p in w.projects],
57
+ }
58
+ for w in waves
59
+ ]
60
+
61
+ def render_message(self, text: str) -> None:
62
+ pass
63
+
64
+ def flush(self) -> None:
65
+ self._io.write_line(json.dumps(self._data, indent=2))
66
+ self._data = {}
67
+
68
+ def _node_to_dict(self, node: DependencyNode) -> dict:
69
+ result: dict[str, Any] = {"name": node.name, "version": node.version}
70
+ if node.children:
71
+ result["children"] = [self._node_to_dict(c) for c in node.children]
72
+ return result
73
+
74
+ @staticmethod
75
+ def _key(title: str) -> str:
76
+ return title.lower().replace(" ", "_")
@@ -0,0 +1,53 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass
6
+ class VersionPatternResult:
7
+ pattern: str
8
+ condition: str = ""
9
+ condition_matched: Optional[bool] = None
10
+ resolved_raw: str = ""
11
+ matched: bool = False
12
+ errors: list[str] = field(default_factory=list)
13
+
14
+
15
+ @dataclass
16
+ class DependencyResolution:
17
+ name: str
18
+ constraint: str
19
+ is_project: bool = False
20
+ path: str = ""
21
+ source: str = ""
22
+
23
+
24
+ @dataclass
25
+ class ProjectResolution:
26
+ name: str
27
+ path: str
28
+ version: str
29
+ deliver: str
30
+ pinning: str = ""
31
+ matched_pattern: str = ""
32
+ pattern_results: list[VersionPatternResult] = field(default_factory=list)
33
+ dependencies: list[DependencyResolution] = field(default_factory=list)
34
+
35
+
36
+ @dataclass
37
+ class ProjectSummary:
38
+ name: str
39
+ version: str
40
+ deliver: str
41
+
42
+
43
+ @dataclass
44
+ class DependencyNode:
45
+ name: str
46
+ version: str
47
+ children: list["DependencyNode"] = field(default_factory=list)
48
+
49
+
50
+ @dataclass
51
+ class PublishWave:
52
+ index: int
53
+ projects: list[ProjectSummary] = field(default_factory=list)