pypeline-runner 1.24.0__py3-none-any.whl → 1.25.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.
pypeline/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "1.24.0"
1
+ __version__ = "1.25.0"
@@ -316,7 +316,7 @@ class CreateVEnv(PipelineStep[ExecutionContext]):
316
316
 
317
317
  def run(self) -> int:
318
318
  self.logger.debug(f"Run {self.get_name()} step. Output dir: {self.output_dir}")
319
- bootstrap_config: Dict[str, Any] = {}
319
+ bootstrap_config = CreateVEnvConfig()
320
320
  is_managed = False
321
321
  # Determine target script and mode
322
322
  if self.user_config.bootstrap_script:
@@ -356,17 +356,17 @@ class CreateVEnv(PipelineStep[ExecutionContext]):
356
356
  for field_name in self.user_config.get_all_properties_names(["bootstrap_script", "python_executable"]):
357
357
  val = getattr(self.user_config, field_name)
358
358
  if val is not None:
359
- bootstrap_config[field_name] = val
359
+ setattr(bootstrap_config, field_name, val)
360
360
 
361
361
  # Priority: input python_version takes precedence over config python_version
362
362
  input_python_version = self.execution_context.get_input("python_version")
363
363
  if input_python_version:
364
- bootstrap_config["python_version"] = input_python_version
364
+ bootstrap_config.python_version = input_python_version
365
365
 
366
366
  # Write bootstrap.json if any configuration is provided
367
- if bootstrap_config:
367
+ if bootstrap_config.is_any_property_set():
368
368
  self.bootstrap_config_file.parent.mkdir(exist_ok=True)
369
- CreateVEnvConfig.from_dict(bootstrap_config).to_json_file(self.bootstrap_config_file)
369
+ bootstrap_config.to_json_file(self.bootstrap_config_file)
370
370
  self.logger.info(f"Created bootstrap configuration at {self.bootstrap_config_file}")
371
371
 
372
372
  # Build bootstrap script arguments
@@ -1,54 +1,310 @@
1
+ import io
2
+ import json
3
+ import traceback
4
+ from dataclasses import dataclass, field
1
5
  from pathlib import Path
2
- from typing import Any, Dict, List, Optional
6
+ from typing import Any, Optional
3
7
 
8
+ import yaml
9
+ from mashumaro.config import BaseConfig
10
+ from mashumaro.mixins.json import DataClassJSONMixin
11
+ from py_app_dev.core.config import BaseConfigDictMixin
4
12
  from py_app_dev.core.exceptions import UserNotificationException
5
13
  from py_app_dev.core.logging import logger
14
+ from yaml.parser import ParserError
15
+ from yaml.scanner import ScannerError
6
16
 
7
- from ..domain.execution_context import ExecutionContext
8
- from ..domain.pipeline import PipelineStep
17
+ from pypeline.domain.execution_context import ExecutionContext
18
+ from pypeline.domain.pipeline import PipelineStep
19
+
20
+
21
+ class BaseConfigJSONMixin(DataClassJSONMixin):
22
+ class Config(BaseConfig):
23
+ """Mashumaro configuration for JSON serialization."""
24
+
25
+ omit_none = True
26
+ serialize_by_alias = True
27
+
28
+ def to_json_string(self) -> str:
29
+ return json.dumps(self.to_dict(), indent=2)
30
+
31
+ def to_json_file(self, file_path: Path) -> None:
32
+ file_path.write_text(self.to_json_string())
33
+
34
+
35
+ @dataclass
36
+ class WestDependency(BaseConfigDictMixin):
37
+ #: Project name
38
+ name: str
39
+ #: Remote name
40
+ remote: str
41
+ #: Revision (tag, branch, or commit)
42
+ revision: str
43
+ #: Path where the dependency will be installed
44
+ path: str
45
+
46
+
47
+ @dataclass
48
+ class WestRemote(BaseConfigDictMixin):
49
+ #: Remote name
50
+ name: str
51
+ #: URL base
52
+ url_base: str = field(metadata={"alias": "url-base"})
53
+
54
+
55
+ @dataclass
56
+ class WestManifest(BaseConfigDictMixin):
57
+ #: Remote configurations
58
+ remotes: list[WestRemote] = field(default_factory=list)
59
+ #: Project dependencies
60
+ projects: list[WestDependency] = field(default_factory=list)
61
+
62
+
63
+ @dataclass
64
+ class WestManifestFile(BaseConfigDictMixin):
65
+ manifest: WestManifest
66
+ # This field is intended to keep track of where configuration was loaded from and
67
+ # it is automatically added when configuration is loaded from file
68
+ file: Optional[Path] = None
69
+
70
+ @classmethod
71
+ def from_file(cls, config_file: Path) -> "WestManifestFile":
72
+ config_dict = cls.parse_to_dict(config_file)
73
+ return cls.from_dict(config_dict)
74
+
75
+ @staticmethod
76
+ def parse_to_dict(config_file: Path) -> dict[str, Any]:
77
+ try:
78
+ with open(config_file) as fs:
79
+ config_dict = yaml.safe_load(fs)
80
+ # Add file name to config to keep track of where configuration was loaded from
81
+ config_dict["file"] = config_file
82
+ return config_dict
83
+ except ScannerError as e:
84
+ raise UserNotificationException(f"Failed scanning west manifest file '{config_file}'. \nError: {e}") from e
85
+ except ParserError as e:
86
+ raise UserNotificationException(f"Failed parsing west manifest file '{config_file}'. \nError: {e}") from e
87
+
88
+
89
+ @dataclass
90
+ class WestInstallResult(DataClassJSONMixin):
91
+ """Tracks paths of installed west dependencies."""
92
+
93
+ installed_dirs: list[Path] = field(default_factory=list)
94
+
95
+ class Config(BaseConfig):
96
+ """Mashumaro configuration for JSON serialization."""
97
+
98
+ omit_none = True
99
+
100
+ @classmethod
101
+ def from_json_file(cls, file_path: Path) -> "WestInstallResult":
102
+ try:
103
+ result = cls.from_dict(json.loads(file_path.read_text()))
104
+ except Exception as e:
105
+ output = io.StringIO()
106
+ traceback.print_exc(file=output)
107
+ raise UserNotificationException(output.getvalue()) from e
108
+ return result
109
+
110
+ def to_json_string(self) -> str:
111
+ return json.dumps(self.to_dict(), indent=2)
112
+
113
+ def to_json_file(self, file_path: Path) -> None:
114
+ file_path.parent.mkdir(parents=True, exist_ok=True)
115
+ file_path.write_text(self.to_json_string())
116
+
117
+
118
+ @dataclass
119
+ class WestWorkspaceDir:
120
+ """West workspace directory path for data registry sharing."""
121
+
122
+ path: Path
123
+
124
+
125
+ @dataclass
126
+ class WestInstallConfig(DataClassJSONMixin):
127
+ """Configuration for WestInstall step."""
128
+
129
+ #: Relative path from project root for west workspace directory
130
+ workspace_dir: Optional[str] = None
9
131
 
10
132
 
11
133
  class WestInstall(PipelineStep[ExecutionContext]):
12
- def __init__(self, execution_context: ExecutionContext, group_name: str, config: Optional[Dict[str, Any]] = None) -> None:
134
+ def __init__(self, execution_context: ExecutionContext, group_name: str, config: Optional[dict[str, Any]] = None) -> None:
13
135
  super().__init__(execution_context, group_name, config)
14
136
  self.logger = logger.bind()
15
- self.artifacts_locator = execution_context.create_artifacts_locator()
137
+ self.install_result = WestInstallResult()
138
+ self.user_config = WestInstallConfig.from_dict(config) if config else WestInstallConfig()
139
+
140
+ self._west_workspace_dir = self._resolve_workspace_dir()
141
+ self._manifests = self._collect_manifests()
142
+
143
+ def _resolve_workspace_dir(self) -> Path:
144
+ """Resolve workspace directory from data registry (priority) or config."""
145
+ # Check data registry first (highest priority)
146
+ registry_entries = self.execution_context.data_registry.find_data(WestWorkspaceDir)
147
+ if registry_entries:
148
+ return registry_entries[0].path
149
+
150
+ # Check config
151
+ if self.user_config.workspace_dir:
152
+ return self.project_root_dir / self.user_config.workspace_dir
153
+
154
+ # Fallback to build dir
155
+ return self.execution_context.create_artifacts_locator().build_dir
156
+
157
+ def _collect_manifests(self) -> list[WestManifestFile]:
158
+ manifests: list[WestManifestFile] = []
159
+
160
+ if self._source_manifest_file.exists():
161
+ try:
162
+ manifests.append(WestManifestFile.from_file(self._source_manifest_file))
163
+ except Exception as e:
164
+ self.logger.warning(f"Failed to parse source west.yaml: {e}")
165
+
166
+ # Check if there are registered manifests in the execution context data registry
167
+ manifests.extend(self.execution_context.data_registry.find_data(WestManifestFile))
168
+ return manifests
169
+
170
+ @property
171
+ def _source_manifest_file(self) -> Path:
172
+ """Optional west.yaml in project root (input)."""
173
+ return self.project_root_dir / "west.yaml"
174
+
175
+ @property
176
+ def _output_manifest_file(self) -> Path:
177
+ """Generated west.yaml (output)."""
178
+ return self.output_dir / "west.yaml"
179
+
180
+ @property
181
+ def _install_result_file(self) -> Path:
182
+ """Tracks installed dependency directories."""
183
+ return self.output_dir / "west_install_result.json"
184
+
185
+ @property
186
+ def installed_dirs(self) -> list[Path]:
187
+ return self.install_result.installed_dirs
16
188
 
17
189
  def get_name(self) -> str:
18
190
  return self.__class__.__name__
19
191
 
20
- @property
21
- def west_manifest_file(self) -> Path:
22
- return self.project_root_dir.joinpath("west.yaml")
192
+ def get_config(self) -> dict[str, str] | None:
193
+ if self.user_config.workspace_dir:
194
+ return {"workspace_dir": self.user_config.workspace_dir}
195
+ return None
196
+
197
+ def _merge_manifests(self, manifests: list[WestManifest]) -> WestManifest:
198
+ """Merge multiple manifests, preserving order. First occurrence wins."""
199
+ merged = WestManifest()
200
+ for manifest in manifests:
201
+ for remote in manifest.remotes:
202
+ if remote not in merged.remotes:
203
+ merged.remotes.append(remote)
204
+ for project in manifest.projects:
205
+ if project not in merged.projects:
206
+ merged.projects.append(project)
207
+ return merged
208
+
209
+ def _write_west_manifest_file(self, manifest: WestManifest) -> None:
210
+ """Write merged manifest to west.yaml file."""
211
+ if not manifest.remotes and not manifest.projects:
212
+ self.logger.info("No West dependencies found. Skipping west.yaml generation.")
213
+ return
214
+
215
+ west_config = {
216
+ "manifest": {
217
+ "remotes": [remote.to_dict() for remote in manifest.remotes],
218
+ "projects": [project.to_dict() for project in manifest.projects],
219
+ }
220
+ }
221
+
222
+ # Convert url_base back to url-base for west compatibility
223
+ for remote in west_config["manifest"]["remotes"]:
224
+ if "url_base" in remote:
225
+ remote["url-base"] = remote.pop("url_base")
226
+
227
+ self._output_manifest_file.parent.mkdir(parents=True, exist_ok=True)
228
+ with open(self._output_manifest_file, "w") as f:
229
+ yaml.dump(west_config, f, default_flow_style=False)
230
+
231
+ self.logger.info(f"Generated west.yaml with {len(manifest.projects)} dependencies")
232
+
233
+ def _run_west_init(self) -> None:
234
+ """Initialize west workspace."""
235
+ self.execution_context.create_process_executor(
236
+ [
237
+ "west",
238
+ "init",
239
+ "-l",
240
+ "--mf",
241
+ self._output_manifest_file.as_posix(),
242
+ self._west_workspace_dir.joinpath("do_not_care").as_posix(),
243
+ ],
244
+ cwd=self.project_root_dir,
245
+ ).execute()
246
+
247
+ def _run_west_update(self) -> None:
248
+ """Update/download dependencies."""
249
+ self.execution_context.create_process_executor(
250
+ ["west", "update"],
251
+ cwd=self._west_workspace_dir,
252
+ ).execute()
23
253
 
24
254
  def run(self) -> int:
25
255
  self.logger.debug(f"Run {self.get_name()} step. Output dir: {self.output_dir}")
256
+
26
257
  try:
27
- self.execution_context.create_process_executor(
28
- [
29
- "west",
30
- "init",
31
- "-l",
32
- "--mf",
33
- self.west_manifest_file.as_posix(),
34
- self.artifacts_locator.build_dir.joinpath("west").as_posix(),
35
- ],
36
- cwd=self.project_root_dir,
37
- ).execute()
38
- self.execution_context.create_process_executor(
39
- ["west", "update"],
40
- cwd=self.artifacts_locator.build_dir,
41
- ).execute()
258
+ merged_manifest = self._merge_manifests([mf.manifest for mf in self._manifests])
259
+ self._write_west_manifest_file(merged_manifest)
260
+
261
+ if not merged_manifest.projects:
262
+ self.logger.info("No West dependencies to install.")
263
+ return 0
264
+
265
+ self._run_west_init()
266
+ self._run_west_update()
267
+ self._record_installed_directories(merged_manifest)
268
+ self.install_result.to_json_file(self._install_result_file)
269
+
42
270
  except Exception as e:
43
271
  raise UserNotificationException(f"Failed to initialize and update with west: {e}") from e
44
272
 
45
273
  return 0
46
274
 
47
- def get_inputs(self) -> List[Path]:
48
- return [self.west_manifest_file]
275
+ def _record_installed_directories(self, manifest: WestManifest) -> None:
276
+ """Record directories created by west."""
277
+ dirs: list[Path] = []
278
+
279
+ if self._west_workspace_dir.exists():
280
+ dirs.append(self._west_workspace_dir)
281
+
282
+ for project in manifest.projects:
283
+ dep_dir = self._west_workspace_dir / project.path
284
+ if dep_dir.exists():
285
+ dirs.append(dep_dir)
286
+ self.logger.debug(f"Tracked dependency directory: {dep_dir}")
287
+
288
+ self.install_result.installed_dirs = list(dict.fromkeys(dirs))
289
+
290
+ def get_inputs(self) -> list[Path]:
291
+ inputs: list[Path] = []
292
+ for manifest_file in self._manifests:
293
+ if manifest_file.file and manifest_file.file.exists():
294
+ inputs.append(manifest_file.file)
295
+ return inputs
49
296
 
50
- def get_outputs(self) -> List[Path]:
51
- return []
297
+ def get_outputs(self) -> list[Path]:
298
+ outputs: list[Path] = [self._output_manifest_file, self._install_result_file]
299
+ if self.install_result.installed_dirs:
300
+ outputs.extend(self.install_result.installed_dirs)
301
+ elif self._manifests:
302
+ outputs.append(self._west_workspace_dir)
303
+ return outputs
52
304
 
53
305
  def update_execution_context(self) -> None:
54
- pass
306
+ if self._install_result_file.exists():
307
+ result = WestInstallResult.from_json_file(self._install_result_file)
308
+ if result.installed_dirs:
309
+ unique_paths = list(dict.fromkeys(result.installed_dirs))
310
+ self.execution_context.add_install_dirs(unique_paths)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pypeline-runner
3
- Version: 1.24.0
3
+ Version: 1.25.0
4
4
  Summary: Configure and execute pipelines with Python (similar to GitHub workflows or Jenkins pipelines).
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -19,10 +19,10 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
20
  Classifier: Programming Language :: Python :: 3.14
21
21
  Classifier: Topic :: Software Development :: Libraries
22
- Requires-Dist: py-app-dev (>=2.10,<3.0)
23
- Requires-Dist: pyyaml (>=6.0,<7.0)
24
- Requires-Dist: typer (>=0,<1)
25
- Requires-Dist: west (>=1.0,<2.0)
22
+ Requires-Dist: py-app-dev (>=2.18.0,<3.0.0)
23
+ Requires-Dist: pyyaml (>=6.0.3,<7.0.0)
24
+ Requires-Dist: typer (>=0.21.1,<0.22.0)
25
+ Requires-Dist: west (>=1.5.0,<2.0.0)
26
26
  Project-URL: Bug Tracker, https://github.com/cuinixam/pypeline/issues
27
27
  Project-URL: Changelog, https://github.com/cuinixam/pypeline/blob/main/CHANGELOG.md
28
28
  Project-URL: Documentation, https://pypeline-runner.readthedocs.io
@@ -95,6 +95,15 @@ This will install the `pypeline` command globally, which you can use to run your
95
95
 
96
96
  Documentation: [pypeline-runner.readthedocs.io](https://pypeline-runner.readthedocs.io)
97
97
 
98
+ ## Quick Start
99
+
100
+ ```shell
101
+ pipx install pypeline-runner
102
+ pypeline init --project-dir my-pipeline
103
+ cd my-pipeline
104
+ pypeline run
105
+ ```
106
+
98
107
  ## Walkthrough: Getting Started with Pypeline
99
108
 
100
109
  To get started run the `init` command to create a sample project:
@@ -134,10 +143,19 @@ pypeline run --project-dir my-pipeline
134
143
  ## Contributing
135
144
 
136
145
  The project uses Poetry for dependencies management and packaging.
137
- Run the `bootstrap.ps1` script to install Python and create the virtual environment.
146
+ You can set up the development environment using one of the following methods:
147
+
148
+ **Option 1: Using Poetry directly** (minimal setup)
138
149
 
139
- ```powershell
140
- .\bootstrap.ps1
150
+ ```shell
151
+ poetry install
152
+ ```
153
+
154
+ **Option 2: Using pypeline-runner** (runs the full pipeline including tests and checks)
155
+
156
+ ```shell
157
+ pipx install pypeline-runner
158
+ pypeline run
141
159
  ```
142
160
 
143
161
  To execute the test suite, call pytest inside Poetry's virtual environment via `poetry run`:
@@ -158,5 +176,5 @@ This repository uses [commitlint](https://github.com/conventional-changelog/comm
158
176
 
159
177
  ## Credits
160
178
 
161
- This package was created with [Copier](https://copier.readthedocs.io/) and the [cuinixam/pypackage-template](https://github.com/cuinixam/pypackage-template) project template.
179
+ This package was created with [Copier](https://copier.readthedocs.io/) and the [browniebroke/pypackage-template](https://github.com/browniebroke/pypackage-template) project template.
162
180
 
@@ -1,4 +1,4 @@
1
- pypeline/__init__.py,sha256=Iat5kowFGlxZk2HgCHj6JjzGlHfXn2mvuhQ5AbHTC94,23
1
+ pypeline/__init__.py,sha256=nnWtTD0Cs5IqX2RSzxsnBvgnHIH1dYlciKuzG2ViNCk,23
2
2
  pypeline/__run.py,sha256=TCdaX05Qm3g8T4QYryKB25Xxf0L5Km7hFOHe1mK9vI0,350
3
3
  pypeline/bootstrap/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  pypeline/bootstrap/run.py,sha256=H5rxSa_owbAUpMA2lw-UhgBFB0eDgnQC0B4jYO8j3sE,33463
@@ -21,12 +21,12 @@ pypeline/main.py,sha256=k1CkeFGRvQ-zLv6C-AMLC2ed1iyFzDUdvEam3HLHy2E,4210
21
21
  pypeline/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
22
22
  pypeline/pypeline.py,sha256=mDKUnTuMDw8l-kSDJCHRNbn6zrxAfXhAIAqc5HyHd5M,8758
23
23
  pypeline/steps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
24
- pypeline/steps/create_venv.py,sha256=8zZRFyya8DgNcYu4x2R_HLr5RdVCOddU2Ln96MgdegI,19051
24
+ pypeline/steps/create_venv.py,sha256=s8uL0cLfAF-AFvrU77KtdUMTE0v5ZDY2Ie_fO4ZqKjg,19050
25
25
  pypeline/steps/env_setup_script.py,sha256=L8TwGo_Ugo2r4Z10MxtE0P8w0ApAxMKCHMnW-NkyG3w,4968
26
26
  pypeline/steps/scoop_install.py,sha256=2MhsJ0iPmL8ueQhI52sKjVY9fqzj5xOQweQ65C0onfE,4117
27
- pypeline/steps/west_install.py,sha256=hPyr28ksdKsQ0tv0gMNytzupgk1IgjN9CpmaBdX5zps,1947
28
- pypeline_runner-1.24.0.dist-info/METADATA,sha256=bKMBdaJ56y2FiOqv4Xd9fkl2iTUixhVSnv7loKCqBS0,7659
29
- pypeline_runner-1.24.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
30
- pypeline_runner-1.24.0.dist-info/entry_points.txt,sha256=pe1u0uuhPI_yeQ0KjEw6jK-EvQfPcZwBSajgbAdKz1o,47
31
- pypeline_runner-1.24.0.dist-info/licenses/LICENSE,sha256=sKxdoqSmW9ezvPvt0ZGJbneyA0SBcm0GiqzTv2jN230,1066
32
- pypeline_runner-1.24.0.dist-info/RECORD,,
27
+ pypeline/steps/west_install.py,sha256=J-eSD0tBuJ7LS5KuGpPCuu7mcwJ68g8tMBxJ2bvEsUw,11499
28
+ pypeline_runner-1.25.0.dist-info/METADATA,sha256=ITheZZgSUmCIZVZfTaO6LyYhVjhPkHrz5UMWiWHCH5s,8002
29
+ pypeline_runner-1.25.0.dist-info/WHEEL,sha256=3ny-bZhpXrU6vSQ1UPG34FoxZBp3lVcvK0LkgUz6VLk,88
30
+ pypeline_runner-1.25.0.dist-info/entry_points.txt,sha256=pe1u0uuhPI_yeQ0KjEw6jK-EvQfPcZwBSajgbAdKz1o,47
31
+ pypeline_runner-1.25.0.dist-info/licenses/LICENSE,sha256=sKxdoqSmW9ezvPvt0ZGJbneyA0SBcm0GiqzTv2jN230,1066
32
+ pypeline_runner-1.25.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.2.1
2
+ Generator: poetry-core 2.3.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any