pypeline-runner 1.6.0__py3-none-any.whl → 1.8.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.6.0"
1
+ __version__ = "1.8.0"
@@ -10,13 +10,10 @@ CONFIG_FILENAME = "pypeline.yaml"
10
10
  class ProjectArtifactsLocator:
11
11
  """Provides paths to project artifacts."""
12
12
 
13
- def __init__(
14
- self,
15
- project_root_dir: Path,
16
- ) -> None:
13
+ def __init__(self, project_root_dir: Path, config_file: Optional[str] = None) -> None:
17
14
  self.project_root_dir = project_root_dir
18
15
  self.build_dir = project_root_dir / "build"
19
- self.config_file = project_root_dir / CONFIG_FILENAME
16
+ self.config_file = project_root_dir.joinpath(config_file if config_file else CONFIG_FILENAME)
20
17
  self.external_dependencies_dir = self.build_dir / "external"
21
18
  scripts_dir = "Scripts" if sys.platform.startswith("win32") else "bin"
22
19
  self.venv_scripts_dir = self.project_root_dir.joinpath(".venv").joinpath(scripts_dir)
@@ -1,5 +1,7 @@
1
+ import importlib
1
2
  from abc import abstractmethod
2
3
  from dataclasses import dataclass
4
+ from importlib.util import module_from_spec, spec_from_file_location
3
5
  from pathlib import Path
4
6
  from typing import (
5
7
  Any,
@@ -9,6 +11,7 @@ from typing import (
9
11
  List,
10
12
  Optional,
11
13
  OrderedDict,
14
+ Protocol,
12
15
  Tuple,
13
16
  Type,
14
17
  TypeAlias,
@@ -17,6 +20,7 @@ from typing import (
17
20
  )
18
21
 
19
22
  from mashumaro import DataClassDictMixin
23
+ from py_app_dev.core.exceptions import UserNotificationException
20
24
  from py_app_dev.core.runnable import Runnable
21
25
 
22
26
  from .execution_context import ExecutionContext
@@ -44,6 +48,21 @@ class PipelineStepConfig(DataClassDictMixin):
44
48
 
45
49
  PipelineConfig: TypeAlias = Union[List[PipelineStepConfig], OrderedDict[str, List[PipelineStepConfig]]]
46
50
 
51
+ TPipelineStep = TypeVar("TPipelineStep", covariant=True)
52
+
53
+
54
+ @dataclass
55
+ class PipelineStepReference(Generic[TPipelineStep]):
56
+ """Once a Step is found, keep the Step class reference to be able to instantiate it later."""
57
+
58
+ group_name: Optional[str]
59
+ _class: Type[TPipelineStep]
60
+ config: Optional[Dict[str, Any]] = None
61
+
62
+ @property
63
+ def name(self) -> str:
64
+ return self._class.__name__
65
+
47
66
 
48
67
  class PipelineConfigIterator:
49
68
  """
@@ -64,6 +83,70 @@ class PipelineConfigIterator:
64
83
  yield from self._items
65
84
 
66
85
 
86
+ class StepClassFactory(Generic[TPipelineStep], Protocol):
87
+ def create_step_class(self, step_config: PipelineStepConfig, project_root_dir: Path) -> Type[TPipelineStep]: ...
88
+
89
+
90
+ class PipelineLoader(Generic[TPipelineStep]):
91
+ def __init__(self, pipeline_config: PipelineConfig, project_root_dir: Path, step_class_factory: Optional[StepClassFactory[TPipelineStep]] = None) -> None:
92
+ self.pipeline_config = pipeline_config
93
+ self.project_root_dir = project_root_dir
94
+ self.step_class_factory = step_class_factory
95
+
96
+ def load_steps_references(self) -> List[PipelineStepReference[TPipelineStep]]:
97
+ result = []
98
+ for group_name, steps_config in PipelineConfigIterator(self.pipeline_config):
99
+ result.extend(self._load_steps(group_name, steps_config, self.project_root_dir, self.step_class_factory))
100
+ return result
101
+
102
+ @staticmethod
103
+ def _load_steps(
104
+ group_name: Optional[str], steps_config: List[PipelineStepConfig], project_root_dir: Path, step_class_factory: Optional[StepClassFactory[TPipelineStep]] = None
105
+ ) -> List[PipelineStepReference[TPipelineStep]]:
106
+ result = []
107
+ for step_config in steps_config:
108
+ step_class_name = step_config.class_name or step_config.step
109
+ if step_config.module:
110
+ step_class = PipelineLoader[TPipelineStep]._load_module_step(step_config.module, step_class_name)
111
+ elif step_config.file:
112
+ step_class = PipelineLoader[TPipelineStep]._load_user_step(project_root_dir.joinpath(step_config.file), step_class_name)
113
+ else:
114
+ if step_class_factory:
115
+ step_class = step_class_factory.create_step_class(step_config, project_root_dir)
116
+ else:
117
+ raise UserNotificationException(
118
+ f"Step '{step_class_name}' has no 'module' nor 'file' defined nor a custom step class factory was provided. Please check your pipeline configuration."
119
+ )
120
+ result.append(PipelineStepReference(group_name, step_class, step_config.config))
121
+ return result
122
+
123
+ @staticmethod
124
+ def _load_user_step(python_file: Path, step_class_name: str) -> Type[TPipelineStep]:
125
+ # Create a module specification from the file path
126
+ spec = spec_from_file_location(f"user__{python_file.stem}", python_file)
127
+ if spec and spec.loader:
128
+ step_module = module_from_spec(spec)
129
+ # Import the module
130
+ spec.loader.exec_module(step_module)
131
+ try:
132
+ step_class = getattr(step_module, step_class_name)
133
+ except AttributeError:
134
+ raise UserNotificationException(f"Could not load class '{step_class_name}' from file '{python_file}'. Please check your pipeline configuration.") from None
135
+ return step_class
136
+ raise UserNotificationException(f"Could not load file '{python_file}'. Please check the file for any errors.")
137
+
138
+ @staticmethod
139
+ def _load_module_step(module_name: str, step_class_name: str) -> Type[TPipelineStep]:
140
+ try:
141
+ module = importlib.import_module(module_name)
142
+ step_class = getattr(module, step_class_name)
143
+ except ImportError:
144
+ raise UserNotificationException(f"Could not load module '{module_name}'. Please check your pipeline configuration.") from None
145
+ except AttributeError:
146
+ raise UserNotificationException(f"Could not load class '{step_class_name}' from module '{module_name}'. Please check your pipeline configuration.") from None
147
+ return step_class
148
+
149
+
67
150
  TExecutionContext = TypeVar("TExecutionContext", bound=ExecutionContext)
68
151
 
69
152
 
@@ -96,14 +179,3 @@ class PipelineStep(Generic[TExecutionContext], Runnable):
96
179
  def get_needs_dependency_management(self) -> bool:
97
180
  """If false, the step executor will not check for outdated dependencies. This is useful for steps consisting of command lines which shall always run."""
98
181
  return True
99
-
100
-
101
- class PipelineStepReference(Generic[TExecutionContext]):
102
- def __init__(self, group_name: Optional[str], _class: Type[PipelineStep[TExecutionContext]], config: Optional[Dict[str, Any]] = None) -> None:
103
- self.group_name = group_name
104
- self._class = _class
105
- self.config = config
106
-
107
- @property
108
- def name(self) -> str:
109
- return self._class.__name__
@@ -1,4 +1,5 @@
1
1
  from pathlib import Path
2
+ from typing import Optional
2
3
 
3
4
  from py_app_dev.core.exceptions import UserNotificationException
4
5
  from py_app_dev.core.logging import logger
@@ -8,9 +9,9 @@ from .config import PipelineConfig, ProjectConfig
8
9
 
9
10
 
10
11
  class ProjectSlurper:
11
- def __init__(self, project_dir: Path) -> None:
12
+ def __init__(self, project_dir: Path, config_file: Optional[str] = None) -> None:
12
13
  self.logger = logger.bind()
13
- self.artifacts_locator = ProjectArtifactsLocator(project_dir)
14
+ self.artifacts_locator = ProjectArtifactsLocator(project_dir, config_file)
14
15
  try:
15
16
  self.user_config: ProjectConfig = ProjectConfig.from_file(self.artifacts_locator.config_file)
16
17
  except FileNotFoundError:
@@ -1,55 +1,55 @@
1
- import shutil
2
- from pathlib import Path
3
- from typing import List, Optional, Union
4
-
5
- from py_app_dev.core.exceptions import UserNotificationException
6
- from py_app_dev.core.logging import logger
7
-
8
-
9
- class ProjectBuilder:
10
- def __init__(self, project_dir: Path, input_dir: Optional[Path] = None) -> None:
11
- self.project_dir = project_dir
12
- self.input_dir = input_dir if input_dir else Path(__file__).parent.joinpath("templates")
13
-
14
- self.dirs: List[Path] = []
15
- self.check_target_directory_flag = True
16
-
17
- def with_disable_target_directory_check(self) -> "ProjectBuilder":
18
- self.check_target_directory_flag = False
19
- return self
20
-
21
- def with_dir(self, dir: Union[Path, str]) -> "ProjectBuilder":
22
- self.dirs.append(self.resolve_file_path(dir))
23
- return self
24
-
25
- def resolve_file_paths(self, files: List[Path | str]) -> List[Path]:
26
- return [self.resolve_file_path(file) for file in files]
27
-
28
- def resolve_file_path(self, file: Union[Path, str]) -> Path:
29
- return self.input_dir.joinpath(file) if isinstance(file, str) else file
30
-
31
- @staticmethod
32
- def _check_target_directory(project_dir: Path) -> None:
33
- if project_dir.is_dir() and any(project_dir.iterdir()):
34
- raise UserNotificationException(f"Project directory '{project_dir}' is not empty. Use --force to override.")
35
-
36
- def build(self) -> None:
37
- if self.check_target_directory_flag:
38
- self._check_target_directory(self.project_dir)
39
- for dir in self.dirs:
40
- shutil.copytree(dir, self.project_dir, dirs_exist_ok=True)
41
-
42
-
43
- class KickstartProject:
44
- def __init__(self, project_dir: Path, force: bool = False) -> None:
45
- self.logger = logger.bind()
46
- self.project_dir = project_dir
47
- self.force = force
48
-
49
- def run(self) -> None:
50
- self.logger.info(f"Kickstart new project in '{self.project_dir.absolute().as_posix()}'")
51
- project_builder = ProjectBuilder(self.project_dir)
52
- if self.force:
53
- project_builder.with_disable_target_directory_check()
54
- project_builder.with_dir("project")
55
- project_builder.build()
1
+ import shutil
2
+ from pathlib import Path
3
+ from typing import List, Optional, Union
4
+
5
+ from py_app_dev.core.exceptions import UserNotificationException
6
+ from py_app_dev.core.logging import logger
7
+
8
+
9
+ class ProjectBuilder:
10
+ def __init__(self, project_dir: Path, input_dir: Optional[Path] = None) -> None:
11
+ self.project_dir = project_dir
12
+ self.input_dir = input_dir if input_dir else Path(__file__).parent.joinpath("templates")
13
+
14
+ self.dirs: List[Path] = []
15
+ self.check_target_directory_flag = True
16
+
17
+ def with_disable_target_directory_check(self) -> "ProjectBuilder":
18
+ self.check_target_directory_flag = False
19
+ return self
20
+
21
+ def with_dir(self, dir: Union[Path, str]) -> "ProjectBuilder":
22
+ self.dirs.append(self.resolve_file_path(dir))
23
+ return self
24
+
25
+ def resolve_file_paths(self, files: List[Path | str]) -> List[Path]:
26
+ return [self.resolve_file_path(file) for file in files]
27
+
28
+ def resolve_file_path(self, file: Union[Path, str]) -> Path:
29
+ return self.input_dir.joinpath(file) if isinstance(file, str) else file
30
+
31
+ @staticmethod
32
+ def _check_target_directory(project_dir: Path) -> None:
33
+ if project_dir.is_dir() and any(project_dir.iterdir()):
34
+ raise UserNotificationException(f"Project directory '{project_dir}' is not empty. Use --force to override.")
35
+
36
+ def build(self) -> None:
37
+ if self.check_target_directory_flag:
38
+ self._check_target_directory(self.project_dir)
39
+ for dir in self.dirs:
40
+ shutil.copytree(dir, self.project_dir, dirs_exist_ok=True)
41
+
42
+
43
+ class KickstartProject:
44
+ def __init__(self, project_dir: Path, force: bool = False) -> None:
45
+ self.logger = logger.bind()
46
+ self.project_dir = project_dir
47
+ self.force = force
48
+
49
+ def run(self) -> None:
50
+ self.logger.info(f"Kickstart new project in '{self.project_dir.absolute().as_posix()}'")
51
+ project_builder = ProjectBuilder(self.project_dir)
52
+ if self.force:
53
+ project_builder.with_disable_target_directory_check()
54
+ project_builder.with_dir("project")
55
+ project_builder.build()
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/python3
2
+ # Source: https://github.com/avengineers/bootstrap
3
+ # Tag: v1.15.1
4
+ import configparser
5
+ import ensurepip
6
+ import hashlib
7
+ import json
8
+ import logging
9
+ import os
10
+ import re
11
+ import subprocess # nosec
12
+ import sys
13
+ import tempfile
14
+ import venv
15
+ from abc import ABC, abstractmethod
16
+ from dataclasses import dataclass
17
+ from enum import Enum
18
+ from functools import total_ordering
19
+ from pathlib import Path
20
+ from typing import List, Optional, Tuple
21
+ from urllib.parse import urlparse
22
+
23
+ logging.basicConfig(level=logging.INFO)
24
+ logger = logging.getLogger("bootstrap")
25
+
26
+
27
+ bootstrap_json_path = Path.cwd() / "bootstrap.json"
28
+ if bootstrap_json_path.exists():
29
+ with bootstrap_json_path.open("r") as f:
30
+ config = json.load(f)
31
+ package_manager = config.get("python_package_manager", "poetry>=2.0")
32
+ package_manager_args = config.get("python_package_manager_args", [])
33
+ else:
34
+ package_manager = "poetry>=2.0"
35
+ package_manager_args = []
36
+
37
+
38
+ @total_ordering
39
+ class Version:
40
+ def __init__(self, version_str: str) -> None:
41
+ self.version = self.parse_version(version_str)
42
+
43
+ @staticmethod
44
+ def parse_version(version_str: str) -> Tuple[int, ...]:
45
+ """Convert a version string into a tuple of integers for comparison."""
46
+ return tuple(map(int, re.split(r"\D+", version_str)))
47
+
48
+ def __eq__(self, other: object) -> bool:
49
+ return isinstance(other, Version) and self.version == other.version
50
+
51
+ def __lt__(self, other: object) -> bool:
52
+ return isinstance(other, Version) and self.version < other.version
53
+
54
+ def __repr__(self) -> str:
55
+ return f"Version({'.'.join(map(str, self.version))})"
56
+
57
+
58
+ @dataclass
59
+ class PyPiSource:
60
+ name: str
61
+ url: str
62
+
63
+
64
+ @dataclass
65
+ class TomlSection:
66
+ name: str
67
+ content: str
68
+
69
+ def __str__(self) -> str:
70
+ return f"[{self.name}]\n{self.content}"
71
+
72
+
73
+ class PyPiSourceParser:
74
+ @staticmethod
75
+ def from_pyproject(project_dir: Path) -> Optional[PyPiSource]:
76
+ pyproject_toml = project_dir / "pyproject.toml"
77
+ pipfile = project_dir / "Pipfile"
78
+ if pyproject_toml.exists():
79
+ return PyPiSourceParser.from_toml_content(pyproject_toml.read_text(), "tool.poetry.source")
80
+ elif pipfile.exists():
81
+ return PyPiSourceParser.from_toml_content(pipfile.read_text(), "source")
82
+ else:
83
+ return None
84
+
85
+ @staticmethod
86
+ def from_toml_content(content: str, source_section_name: str) -> Optional[PyPiSource]:
87
+ sections = PyPiSourceParser.get_toml_sections(content)
88
+ for section in sections:
89
+ if section.name == source_section_name:
90
+ try:
91
+ parser = configparser.ConfigParser()
92
+ parser.read_string(str(section))
93
+ name = parser[section.name]["name"].strip('"')
94
+ url = parser[section.name]["url"].strip('"')
95
+ return PyPiSource(name, url)
96
+ except KeyError:
97
+ raise UserNotificationException(
98
+ f"Could not parse PyPi source from section {section.name}. "
99
+ f"Please make sure the section has the following format:\n"
100
+ f"[{source_section_name}]\n"
101
+ f'name = "name"\n'
102
+ f'url = "https://url"\n'
103
+ f"verify_ssl = true"
104
+ ) from None
105
+ return None
106
+
107
+ @staticmethod
108
+ def get_toml_sections(toml_content: str) -> List[TomlSection]:
109
+ # Use a regular expression to find all sections with [ or [[ at the beginning of the line
110
+ raw_sections = re.findall(r"^\[+.*\]+\n(?:[^[]*\n)*", toml_content, re.MULTILINE)
111
+
112
+ # Process each section
113
+ sections = []
114
+ for section in raw_sections:
115
+ # Split the lines, from the first line extract the section name
116
+ # and merge all the other lines into the content
117
+ lines = section.splitlines()
118
+ name_match = re.match(r"^\[+([^]]*)\]+", lines[0])
119
+ if name_match:
120
+ name = name_match.group(1).strip()
121
+ content = "\n".join(lines[1:]).strip()
122
+ sections.append(TomlSection(name, content))
123
+
124
+ return sections
125
+
126
+
127
+ class Runnable(ABC):
128
+ @abstractmethod
129
+ def run(self) -> int:
130
+ """Run stage"""
131
+
132
+ @abstractmethod
133
+ def get_name(self) -> str:
134
+ """Get stage name"""
135
+
136
+ @abstractmethod
137
+ def get_inputs(self) -> List[Path]:
138
+ """Get stage dependencies"""
139
+
140
+ @abstractmethod
141
+ def get_outputs(self) -> List[Path]:
142
+ """Get stage outputs"""
143
+
144
+
145
+ class RunInfoStatus(Enum):
146
+ MATCH = (False, "Nothing has changed, previous execution information matches.")
147
+ NO_INFO = (True, "No previous execution information found.")
148
+ FILE_CHANGED = (True, "Dependencies have been changed.")
149
+
150
+ def __init__(self, should_run: bool, message: str) -> None:
151
+ self.should_run = should_run
152
+ self.message = message
153
+
154
+
155
+ class Executor:
156
+ """Accepts Runnable objects and executes them.
157
+ It create a file with the same name as the runnable's name
158
+ and stores the inputs and outputs with their hashes.
159
+ If the file exists, it checks the hashes of the inputs and outputs
160
+ and if they match, it skips the execution."""
161
+
162
+ RUN_INFO_FILE_EXTENSION = ".deps.json"
163
+
164
+ def __init__(self, cache_dir: Path) -> None:
165
+ self.cache_dir = cache_dir
166
+
167
+ @staticmethod
168
+ def get_file_hash(path: Path) -> str:
169
+ """Get the hash of a file.
170
+ Returns an empty string if the file does not exist."""
171
+ if path.is_file():
172
+ with open(path, "rb") as file:
173
+ bytes = file.read()
174
+ readable_hash = hashlib.sha256(bytes).hexdigest()
175
+ return readable_hash
176
+ else:
177
+ return ""
178
+
179
+ def store_run_info(self, runnable: Runnable) -> None:
180
+ file_info = {
181
+ "inputs": {str(path): self.get_file_hash(path) for path in runnable.get_inputs()},
182
+ "outputs": {str(path): self.get_file_hash(path) for path in runnable.get_outputs()},
183
+ }
184
+
185
+ run_info_path = self.get_runnable_run_info_file(runnable)
186
+ run_info_path.parent.mkdir(parents=True, exist_ok=True)
187
+ with run_info_path.open("w") as f:
188
+ # pretty print the json file
189
+ json.dump(file_info, f, indent=4)
190
+
191
+ def get_runnable_run_info_file(self, runnable: Runnable) -> Path:
192
+ return self.cache_dir / f"{runnable.get_name()}{self.RUN_INFO_FILE_EXTENSION}"
193
+
194
+ def previous_run_info_matches(self, runnable: Runnable) -> RunInfoStatus:
195
+ run_info_path = self.get_runnable_run_info_file(runnable)
196
+ if not run_info_path.exists():
197
+ return RunInfoStatus.NO_INFO
198
+
199
+ with run_info_path.open() as f:
200
+ previous_info = json.load(f)
201
+
202
+ for file_type in ["inputs", "outputs"]:
203
+ for path_str, previous_hash in previous_info[file_type].items():
204
+ path = Path(path_str)
205
+ if self.get_file_hash(path) != previous_hash:
206
+ return RunInfoStatus.FILE_CHANGED
207
+ return RunInfoStatus.MATCH
208
+
209
+ def execute(self, runnable: Runnable) -> int:
210
+ run_info_status = self.previous_run_info_matches(runnable)
211
+ if run_info_status.should_run:
212
+ logger.info(f"Runnable '{runnable.get_name()}' must run. {run_info_status.message}")
213
+ exit_code = runnable.run()
214
+ self.store_run_info(runnable)
215
+ return exit_code
216
+ logger.info(f"Runnable '{runnable.get_name()}' execution skipped. {run_info_status.message}")
217
+
218
+ return 0
219
+
220
+
221
+ class UserNotificationException(Exception):
222
+ pass
223
+
224
+
225
+ class SubprocessExecutor:
226
+ def __init__(
227
+ self,
228
+ command: List[str | Path],
229
+ cwd: Optional[Path] = None,
230
+ capture_output: bool = True,
231
+ ):
232
+ self.command = " ".join([str(cmd) for cmd in command])
233
+ self.current_working_directory = cwd
234
+ self.capture_output = capture_output
235
+
236
+ def execute(self) -> None:
237
+ result = None
238
+ try:
239
+ current_dir = (self.current_working_directory or Path.cwd()).as_posix()
240
+ logger.info(f"Running command: {self.command} in {current_dir}")
241
+ # print all virtual environment variables
242
+ logger.debug(json.dumps(dict(os.environ), indent=4))
243
+ result = subprocess.run(
244
+ self.command.split(),
245
+ cwd=current_dir,
246
+ capture_output=self.capture_output,
247
+ text=True, # to get stdout and stderr as strings instead of bytes
248
+ ) # nosec
249
+ result.check_returncode()
250
+ except subprocess.CalledProcessError as e:
251
+ raise UserNotificationException(f"Command '{self.command}' failed with:\n" f"{result.stdout if result else ''}\n" f"{result.stderr if result else e}") from e
252
+
253
+
254
+ class VirtualEnvironment(ABC):
255
+ def __init__(self, venv_dir: Path) -> None:
256
+ self.venv_dir = venv_dir
257
+
258
+ def create(self) -> None:
259
+ """
260
+ Create a new virtual environment. This should configure the virtual environment such that
261
+ subsequent calls to `pip` and `run` operate within this environment.
262
+ """
263
+ try:
264
+ venv.create(env_dir=self.venv_dir, with_pip=True)
265
+ except PermissionError as e:
266
+ if "python.exe" in str(e):
267
+ raise UserNotificationException(
268
+ f"Failed to create virtual environment in {self.venv_dir}.\n" f"Virtual environment python.exe is still running. Please kill all instances and run again.\n" f"Error: {e}"
269
+ ) from e
270
+ raise UserNotificationException(f"Failed to create virtual environment in {self.venv_dir}.\n" f"Please make sure you have the necessary permissions.\n" f"Error: {e}") from e
271
+
272
+ def pip_configure(self, index_url: str, verify_ssl: bool = True) -> None:
273
+ """
274
+ Configure pip to use the given index URL and SSL verification setting. This method should
275
+ behave as if the user had activated the virtual environment and run `pip config set
276
+ global.index-url <index_url>` and `pip config set global.cert <verify_ssl>` from the
277
+ command line.
278
+
279
+ Args:
280
+ ----
281
+ index_url: The index URL to use for pip.
282
+ verify_ssl: Whether to verify SSL certificates when using pip.
283
+
284
+ """
285
+ # The pip configuration file should be in the virtual environment directory %VIRTUAL_ENV%
286
+ pip_ini_path = self.pip_config_path()
287
+ with open(pip_ini_path, "w") as pip_ini_file:
288
+ pip_ini_file.write(f"[global]\nindex-url = {index_url}\n")
289
+ if not verify_ssl:
290
+ pip_ini_file.write("cert = false\n")
291
+
292
+ def pip(self, args: List[str]) -> None:
293
+ SubprocessExecutor([self.pip_path().as_posix(), *args]).execute()
294
+
295
+ @abstractmethod
296
+ def pip_path(self) -> Path:
297
+ """
298
+ Get the path to the pip executable within the virtual environment.
299
+ """
300
+
301
+ @abstractmethod
302
+ def pip_config_path(self) -> Path:
303
+ """
304
+ Get the path to the pip configuration file within the virtual environment.
305
+ """
306
+
307
+ @abstractmethod
308
+ def run(self, args: List[str], capture_output: bool = True) -> None:
309
+ """
310
+ Run an arbitrary command within the virtual environment. This method should behave as if the
311
+ user had activated the virtual environment and run the given command from the command line.
312
+
313
+ Args:
314
+ ----
315
+ *args: Command-line arguments. For example, `run('python', 'setup.py', 'install')`
316
+ should behave similarly to `python setup.py install` at the command line.
317
+
318
+ """
319
+
320
+
321
+ class WindowsVirtualEnvironment(VirtualEnvironment):
322
+ def __init__(self, venv_dir: Path) -> None:
323
+ super().__init__(venv_dir)
324
+ self.activate_script = self.venv_dir.joinpath("Scripts/activate")
325
+
326
+ def pip_path(self) -> Path:
327
+ return self.venv_dir.joinpath("Scripts/pip.exe")
328
+
329
+ def pip_config_path(self) -> Path:
330
+ return self.venv_dir.joinpath("pip.ini")
331
+
332
+ def run(self, args: List[str], capture_output: bool = True) -> None:
333
+ SubprocessExecutor(
334
+ command=[f"cmd /c {self.activate_script.as_posix()} && ", *args],
335
+ capture_output=capture_output,
336
+ ).execute()
337
+
338
+
339
+ class UnixVirtualEnvironment(VirtualEnvironment):
340
+ def __init__(self, venv_dir: Path) -> None:
341
+ super().__init__(venv_dir)
342
+ self.activate_script = self.venv_dir.joinpath("bin/activate")
343
+
344
+ def pip_path(self) -> Path:
345
+ return self.venv_dir.joinpath("bin/pip")
346
+
347
+ def pip_config_path(self) -> Path:
348
+ return self.venv_dir.joinpath("pip.conf")
349
+
350
+ def run(self, args: List[str], capture_output: bool = True) -> None:
351
+ # Create a temporary shell script
352
+ with tempfile.NamedTemporaryFile("w", delete=False, suffix=".sh") as f:
353
+ f.write("#!/bin/bash\n") # Add a shebang line
354
+ f.write(f"source {self.activate_script.as_posix()}\n") # Write the activate command
355
+ f.write(" ".join(args)) # Write the provided command
356
+ temp_script_path = f.name # Get the path of the temporary script
357
+
358
+ # Make the temporary script executable
359
+ SubprocessExecutor(["chmod", "+x", temp_script_path]).execute()
360
+ # Run the temporary script
361
+ SubprocessExecutor(
362
+ command=[f"{Path(temp_script_path).as_posix()}"],
363
+ capture_output=capture_output,
364
+ ).execute()
365
+ # Delete the temporary script
366
+ os.remove(temp_script_path)
367
+
368
+
369
+ class CreateVirtualEnvironment(Runnable):
370
+ def __init__(self, root_dir: Path) -> None:
371
+ self.root_dir = root_dir
372
+ self.venv_dir = self.root_dir / ".venv"
373
+ self.bootstrap_dir = self.root_dir / ".bootstrap"
374
+ self.virtual_env = self.instantiate_os_specific_venv(self.venv_dir)
375
+
376
+ @property
377
+ def package_manager_name(self) -> str:
378
+ match = re.match(r"^([a-zA-Z0-9_-]+)", package_manager)
379
+
380
+ if match:
381
+ return match.group(1)
382
+ else:
383
+ raise UserNotificationException(f"Could not extract the package manager name from {package_manager}")
384
+
385
+ def run(self) -> int:
386
+ # Create the virtual environment if pip executable does not exist
387
+ if not self.virtual_env.pip_path().exists():
388
+ self.virtual_env.create()
389
+
390
+ # Get the PyPi source from pyproject.toml or Pipfile if it is defined
391
+ pypi_source = PyPiSourceParser.from_pyproject(self.root_dir)
392
+ if pypi_source:
393
+ self.virtual_env.pip_configure(index_url=pypi_source.url, verify_ssl=True)
394
+ # We need pip-system-certs in venv to use certificates, that are stored in the system's trust store,
395
+ pip_args = ["install", package_manager, "pip-system-certs"]
396
+ # but to install it, we need either a pip version with the trust store feature or to trust the host
397
+ # (trust store feature enabled by default since 24.2)
398
+ if Version(ensurepip.version()) < Version("24.2"):
399
+ # Add trusted host of configured source for older Python versions
400
+ if pypi_source:
401
+ pip_args.extend(["--trusted-host", urlparse(pypi_source.url).hostname])
402
+ else:
403
+ pip_args.extend(["--trusted-host", "pypi.org", "--trusted-host", "pypi.python.org", "--trusted-host", "files.pythonhosted.org"])
404
+ self.virtual_env.pip(pip_args)
405
+ self.virtual_env.run(["python", "-m", self.package_manager_name, "install", *package_manager_args])
406
+ return 0
407
+
408
+ @staticmethod
409
+ def instantiate_os_specific_venv(venv_dir: Path) -> VirtualEnvironment:
410
+ if sys.platform.startswith("win32"):
411
+ return WindowsVirtualEnvironment(venv_dir)
412
+ elif sys.platform.startswith("linux") or sys.platform.startswith("darwin"):
413
+ return UnixVirtualEnvironment(venv_dir)
414
+ else:
415
+ raise UserNotificationException(f"Unsupported operating system: {sys.platform}")
416
+
417
+ def get_name(self) -> str:
418
+ return "create-virtual-environment"
419
+
420
+ def get_inputs(self) -> List[Path]:
421
+ venv_relevant_files = [
422
+ "poetry.lock",
423
+ "poetry.toml",
424
+ "pyproject.toml",
425
+ ".env",
426
+ "Pipfile",
427
+ "Pipfile.lock",
428
+ "bootstrap.json",
429
+ ".bootstrap/bootstrap.ps1",
430
+ ".bootstrap/bootstrap.py",
431
+ "bootstrap.ps1",
432
+ "bootstrap.py",
433
+ ]
434
+ return [self.root_dir / file for file in venv_relevant_files]
435
+
436
+ def get_outputs(self) -> List[Path]:
437
+ return []
438
+
439
+
440
+ def print_environment_info() -> None:
441
+ str_bar = "".join(["-" for _ in range(80)])
442
+ logger.debug(str_bar)
443
+ logger.debug("Environment: \n" + json.dumps(dict(os.environ), indent=4))
444
+ logger.info(str_bar)
445
+ logger.info(f"Arguments: {sys.argv[1:]}")
446
+ logger.info(str_bar)
447
+
448
+
449
+ def main() -> int:
450
+ try:
451
+ # print_environment_info()
452
+ creator = CreateVirtualEnvironment(Path.cwd())
453
+ Executor(creator.venv_dir).execute(creator)
454
+ except UserNotificationException as e:
455
+ logger.error(e)
456
+ return 1
457
+ return 0
458
+
459
+
460
+ if __name__ == "__main__":
461
+ sys.exit(main())
@@ -1,2 +1,2 @@
1
- [virtualenvs]
2
- in-project = true
1
+ [virtualenvs]
2
+ in-project = true
@@ -1,7 +1,7 @@
1
- Push-Location $PSScriptRoot
2
- try {
3
- .\.venv\Scripts\pypeline.exe $args
4
- }
5
- finally {
6
- Pop-Location
7
- }
1
+ Push-Location $PSScriptRoot
2
+ try {
3
+ .\.venv\Scripts\pypeline.exe $args
4
+ }
5
+ finally {
6
+ Pop-Location
7
+ }
@@ -3,7 +3,8 @@ pipeline:
3
3
  module: pypeline.steps.create_venv
4
4
  config:
5
5
  bootstrap_script: .bootstrap/bootstrap.py
6
- - step: ScoopInstall
7
- module: pypeline.steps.scoop_install
6
+ - step: WestInstall
7
+ module: pypeline.steps.west_install
8
+ description: Download external modules
8
9
  - step: MyStep
9
10
  file: steps/my_step.py
@@ -1,9 +1,11 @@
1
- [tool.poetry]
2
- name = "Hello Pypeline"
3
- version = "0.0.1"
4
- description = "A simple generated project to get you started"
5
- authors = ["Your Name"]
6
-
7
- [tool.poetry.dependencies]
8
- python = ">=3.10,<3.13"
9
- pypeline-runner = "*"
1
+ [tool.poetry]
2
+ name = "Hello Pypeline"
3
+ version = "0.0.1"
4
+ description = "A simple generated project to get you started"
5
+ authors = ["Your Name"]
6
+ package-mode = false
7
+
8
+ [tool.poetry.dependencies]
9
+ python = ">=3.10,<3.13"
10
+ pypeline-runner = "*"
11
+ west = "*"
@@ -1,26 +1,26 @@
1
- from pathlib import Path
2
- from typing import List
3
-
4
- from py_app_dev.core.logging import logger
5
-
6
- from pypeline.domain.execution_context import ExecutionContext
7
- from pypeline.domain.pipeline import PipelineStep
8
-
9
-
10
- class MyStep(PipelineStep[ExecutionContext]):
11
- def run(self) -> None:
12
- logger.info(f"Run {self.get_name()} found install dirs:")
13
- for install_dir in self.execution_context.install_dirs:
14
- logger.info(f" {install_dir}")
15
-
16
- def get_inputs(self) -> List[Path]:
17
- return []
18
-
19
- def get_outputs(self) -> List[Path]:
20
- return []
21
-
22
- def get_name(self) -> str:
23
- return self.__class__.__name__
24
-
25
- def update_execution_context(self) -> None:
26
- pass
1
+ from pathlib import Path
2
+ from typing import List
3
+
4
+ from py_app_dev.core.logging import logger
5
+
6
+ from pypeline.domain.execution_context import ExecutionContext
7
+ from pypeline.domain.pipeline import PipelineStep
8
+
9
+
10
+ class MyStep(PipelineStep[ExecutionContext]):
11
+ def run(self) -> None:
12
+ logger.info(f"Run {self.get_name()} found install dirs:")
13
+ for install_dir in self.execution_context.install_dirs:
14
+ logger.info(f" {install_dir}")
15
+
16
+ def get_inputs(self) -> List[Path]:
17
+ return []
18
+
19
+ def get_outputs(self) -> List[Path]:
20
+ return []
21
+
22
+ def get_name(self) -> str:
23
+ return self.__class__.__name__
24
+
25
+ def update_execution_context(self) -> None:
26
+ pass
@@ -0,0 +1,10 @@
1
+ manifest:
2
+ remotes:
3
+ - name: gtest
4
+ url-base: https://github.com/google
5
+
6
+ projects:
7
+ - name: googletest
8
+ remote: gtest
9
+ revision: v1.14.0
10
+ path: external/gtest
pypeline/main.py CHANGED
@@ -42,6 +42,7 @@ def init(
42
42
  @time_it("run")
43
43
  def run(
44
44
  project_dir: Path = typer.Option(Path.cwd().absolute(), help="The project directory"), # noqa: B008,
45
+ config_file: Optional[str] = typer.Option(None, help="The name of the YAML configuration file containing the pypeline definition."),
45
46
  step: Optional[str] = typer.Option(
46
47
  None,
47
48
  help="Name of the step to run (as written in the pipeline config).",
@@ -67,7 +68,7 @@ def run(
67
68
  is_flag=True,
68
69
  ),
69
70
  ) -> None:
70
- project_slurper = ProjectSlurper(project_dir)
71
+ project_slurper = ProjectSlurper(project_dir, config_file)
71
72
  if print:
72
73
  logger.info("Pipeline steps:")
73
74
  for group, step_configs in PipelineConfigIterator(project_slurper.pipeline):
pypeline/pypeline.py CHANGED
@@ -1,5 +1,3 @@
1
- import importlib
2
- from importlib.util import module_from_spec, spec_from_file_location
3
1
  from pathlib import Path
4
2
  from typing import (
5
3
  Any,
@@ -8,7 +6,6 @@ from typing import (
8
6
  List,
9
7
  Optional,
10
8
  Type,
11
- cast,
12
9
  )
13
10
 
14
11
  from py_app_dev.core.exceptions import UserNotificationException
@@ -17,75 +14,18 @@ from py_app_dev.core.runnable import Executor
17
14
 
18
15
  from .domain.artifacts import ProjectArtifactsLocator
19
16
  from .domain.execution_context import ExecutionContext
20
- from .domain.pipeline import PipelineConfig, PipelineConfigIterator, PipelineStep, PipelineStepConfig, PipelineStepReference, TExecutionContext
17
+ from .domain.pipeline import PipelineConfig, PipelineLoader, PipelineStep, PipelineStepConfig, PipelineStepReference, StepClassFactory, TExecutionContext
21
18
 
22
19
 
23
- class PipelineLoader(Generic[TExecutionContext]):
24
- """
25
- Loads pipeline steps from a pipeline configuration.
26
-
27
- The steps are not instantiated, only the references are returned (lazy load).
28
- The pipeline loader needs to know the project root directory to be able to find the
29
- user custom local steps.
30
- """
31
-
32
- def __init__(self, pipeline_config: PipelineConfig, project_root_dir: Path) -> None:
33
- self.pipeline_config = pipeline_config
34
- self.project_root_dir = project_root_dir
35
-
36
- def load_steps_references(self) -> List[PipelineStepReference[TExecutionContext]]:
37
- result = []
38
- for group_name, steps_config in PipelineConfigIterator(self.pipeline_config):
39
- result.extend(self._load_steps(group_name, steps_config, self.project_root_dir))
40
- return result
41
-
42
- @staticmethod
43
- def _load_steps(
44
- group_name: Optional[str],
45
- steps_config: List[PipelineStepConfig],
46
- project_root_dir: Path,
47
- ) -> List[PipelineStepReference[TExecutionContext]]:
48
- result = []
49
- for step_config in steps_config:
50
- step_class_name = step_config.class_name or step_config.step
51
- if step_config.module:
52
- step_class = PipelineLoader._load_module_step(step_config.module, step_class_name)
53
- elif step_config.file:
54
- step_class = PipelineLoader._load_user_step(project_root_dir.joinpath(step_config.file), step_class_name)
55
- elif step_config.run:
56
- # We want the run field to always return a list of strings (the command and its arguments).
57
- run_command = step_config.run.split(" ") if isinstance(step_config.run, str) else step_config.run
58
- step_class = PipelineLoader._create_run_command_step_class(run_command, step_class_name)
59
- else:
60
- raise UserNotificationException(f"Step '{step_class_name}' has no 'module' nor 'file' nor `run` defined. Please check your pipeline configuration.")
61
- result.append(PipelineStepReference[TExecutionContext](group_name, cast(Type[PipelineStep[TExecutionContext]], step_class), step_config.config))
62
- return result
63
-
64
- @staticmethod
65
- def _load_user_step(python_file: Path, step_class_name: str) -> Type[PipelineStep[ExecutionContext]]:
66
- # Create a module specification from the file path
67
- spec = spec_from_file_location(f"user__{python_file.stem}", python_file)
68
- if spec and spec.loader:
69
- step_module = module_from_spec(spec)
70
- # Import the module
71
- spec.loader.exec_module(step_module)
72
- try:
73
- step_class = getattr(step_module, step_class_name)
74
- except AttributeError:
75
- raise UserNotificationException(f"Could not load class '{step_class_name}' from file '{python_file}'. Please check your pipeline configuration.") from None
76
- return step_class
77
- raise UserNotificationException(f"Could not load file '{python_file}'. Please check the file for any errors.")
78
-
79
- @staticmethod
80
- def _load_module_step(module_name: str, step_class_name: str) -> Type[PipelineStep[ExecutionContext]]:
81
- try:
82
- module = importlib.import_module(module_name)
83
- step_class = getattr(module, step_class_name)
84
- except ImportError:
85
- raise UserNotificationException(f"Could not load module '{module_name}'. Please check your pipeline configuration.") from None
86
- except AttributeError:
87
- raise UserNotificationException(f"Could not load class '{step_class_name}' from module '{module_name}'. Please check your pipeline configuration.") from None
88
- return step_class
20
+ class RunCommandClassFactory(StepClassFactory[PipelineStep[TExecutionContext]]):
21
+ def create_step_class(self, step_config: PipelineStepConfig, project_root_dir: Path) -> Type[PipelineStep[ExecutionContext]]:
22
+ _ = project_root_dir # Unused because we do not need to locate files relative to the project root directory
23
+ step_name = step_config.class_name or step_config.step
24
+ if step_config.run:
25
+ # We want the run field to always return a list of strings (the command and its arguments).
26
+ run_command = step_config.run.split(" ") if isinstance(step_config.run, str) else step_config.run
27
+ return self._create_run_command_step_class(run_command, step_name)
28
+ raise UserNotificationException(f"Step '{step_name}' has `run` command defined. Please check your pipeline configuration.")
89
29
 
90
30
  @staticmethod
91
31
  def _create_run_command_step_class(command: List[str], name: str) -> Type[PipelineStep[ExecutionContext]]:
@@ -133,7 +73,7 @@ class PipelineStepsExecutor(Generic[TExecutionContext]):
133
73
  def __init__(
134
74
  self,
135
75
  execution_context: TExecutionContext,
136
- steps_references: List[PipelineStepReference[TExecutionContext]],
76
+ steps_references: List[PipelineStepReference[PipelineStep[TExecutionContext]]],
137
77
  force_run: bool = False,
138
78
  dry_run: bool = False,
139
79
  ) -> None:
@@ -175,16 +115,15 @@ class PipelineScheduler(Generic[TExecutionContext]):
175
115
  self.project_root_dir = project_root_dir
176
116
  self.logger = logger.bind()
177
117
 
178
- def get_steps_to_run(self, step_name: Optional[str] = None, single: bool = False) -> List[PipelineStepReference[TExecutionContext]]:
179
- pipeline_loader = PipelineLoader[TExecutionContext](self.pipeline, self.project_root_dir)
180
- return self.filter_steps_references(pipeline_loader.load_steps_references(), step_name, single)
118
+ def get_steps_to_run(self, step_name: Optional[str] = None, single: bool = False) -> List[PipelineStepReference[PipelineStep[TExecutionContext]]]:
119
+ return self.filter_steps_references(self.create_pipeline_loader(self.pipeline, self.project_root_dir).load_steps_references(), step_name, single)
181
120
 
182
121
  @staticmethod
183
122
  def filter_steps_references(
184
- steps_references: List[PipelineStepReference[TExecutionContext]],
123
+ steps_references: List[PipelineStepReference[PipelineStep[TExecutionContext]]],
185
124
  step_name: Optional[str],
186
125
  single: Optional[bool],
187
- ) -> List[PipelineStepReference[TExecutionContext]]:
126
+ ) -> List[PipelineStepReference[PipelineStep[TExecutionContext]]]:
188
127
  if step_name:
189
128
  step_reference = next((step for step in steps_references if step.name == step_name), None)
190
129
  if not step_reference:
@@ -193,3 +132,7 @@ class PipelineScheduler(Generic[TExecutionContext]):
193
132
  return [step_reference]
194
133
  return [step for step in steps_references if steps_references.index(step) <= steps_references.index(step_reference)]
195
134
  return steps_references
135
+
136
+ @staticmethod
137
+ def create_pipeline_loader(pipeline: PipelineConfig, project_root_dir: Path) -> PipelineLoader[PipelineStep[TExecutionContext]]:
138
+ return PipelineLoader[PipelineStep[TExecutionContext]](pipeline, project_root_dir, RunCommandClassFactory())
@@ -9,10 +9,12 @@ from py_app_dev.core.logging import logger
9
9
  from ..domain.execution_context import ExecutionContext
10
10
  from ..domain.pipeline import PipelineStep
11
11
 
12
+ DEFAULT_BOOTSTRAP_SCRIPT = "bootstrap.py"
13
+
12
14
 
13
15
  @dataclass
14
16
  class CreateVEnvConfig(DataClassDictMixin):
15
- bootstrap_script: str = "bootstrap.py"
17
+ bootstrap_script: str = DEFAULT_BOOTSTRAP_SCRIPT
16
18
 
17
19
 
18
20
  class CreateVEnv(PipelineStep[ExecutionContext]):
@@ -32,9 +34,14 @@ class CreateVEnv(PipelineStep[ExecutionContext]):
32
34
  config = CreateVEnvConfig.from_dict(self.config) if self.config else CreateVEnvConfig()
33
35
  bootstrap_script = self.project_root_dir / config.bootstrap_script
34
36
  if not bootstrap_script.exists():
35
- raise UserNotificationException("Failed to find bootstrap script. Make sure that the project is initialized correctly.")
37
+ if config.bootstrap_script == DEFAULT_BOOTSTRAP_SCRIPT:
38
+ raise UserNotificationException(f"Failed to find bootstrap script '{config.bootstrap_script}'. Make sure that the project is initialized correctly.")
39
+ else: # Fallback to default bootstrap script
40
+ bootstrap_script = self.project_root_dir / DEFAULT_BOOTSTRAP_SCRIPT
41
+ if not bootstrap_script.exists():
42
+ raise UserNotificationException("Failed to find bootstrap script. Make sure that the project is initialized correctly.")
36
43
  self.execution_context.create_process_executor(
37
- ["python", bootstrap_script.as_posix()],
44
+ ["python3", bootstrap_script.as_posix()],
38
45
  cwd=self.project_root_dir,
39
46
  ).execute()
40
47
  self.execution_context.add_install_dirs(self.install_dirs)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pypeline-runner
3
- Version: 1.6.0
3
+ Version: 1.8.0
4
4
  Summary: Configure and execute pipelines with Python (similar to GitHub workflows or Jenkins pipelines).
5
5
  License: MIT
6
6
  Author: cuinixam
@@ -26,7 +26,13 @@ Project-URL: Documentation, https://pypeline-runner.readthedocs.io
26
26
  Project-URL: Repository, https://github.com/cuinixam/pypeline
27
27
  Description-Content-Type: text/markdown
28
28
 
29
- # Pypeline
29
+ <p align="center">
30
+ <a href="https://pypeline-runner.readthedocs.io">
31
+ <img align="center" src="https://github.com/cuinixam/pypeline/raw/main/logo.png" width="400"/>
32
+ </a>
33
+ </p>
34
+
35
+ # pypeline - Define Your CI/CD Pipeline Once, Run It Anywhere
30
36
 
31
37
  <p align="center">
32
38
  <a href="https://github.com/cuinixam/pypeline/actions/workflows/ci.yml?query=branch%3Amain">
@@ -58,28 +64,71 @@ Description-Content-Type: text/markdown
58
64
  <img src="https://img.shields.io/pypi/l/pypeline-runner.svg?style=flat-square" alt="License">
59
65
  </p>
60
66
 
61
- Pypeline is a Python application designed to streamline and automate the software development lifecycle, particularly the pipeline execution processes across various environments such as GitHub and Jenkins.
62
- The primary motivation for developing Pypeline stemmed from the need to unify and simplify the creation of build, test, and deployment pipelines that are traditionally defined separately across these platforms using GitHub workflows (YAML) and Jenkins pipelines (Jenkinsfile).
67
+ Pypeline lets you define your build, test, and deployment pipeline in a single YAML file and run it _consistently_ across your local development environment and _any_ CI/CD platform (GitHub Actions, Jenkins, etc.). No more platform-specific configurations – write once, run anywhere.
63
68
 
64
69
  **Key Features**
65
70
 
66
71
  - **Unified Pipeline Definition**: Users can define their entire pipeline in a single YAML file, eliminating the need to switch between different syntaxes and configurations for different CI/CD tools.
67
72
 
68
- - **Extensibility**: Pypeline supports execution steps defined not only through local scripts but also from installed Python packages.
69
-
70
- - **Execution Context**: Each step in the pipeline receives an execution context that can be updated during step execution. This allows for the sharing of information and state between steps.
73
+ - **Extensibility**: Pypeline supports execution steps defined not only through installed Python packages but also from local scripts.
71
74
 
72
- - **Dependency Handling**: Dependency management ensures that only the necessary steps are executed, reducing runtime and resource usage by avoiding unnecessary operations.
75
+ - **Execution Context**: Allow sharing information and state between steps. Each step in the pipeline receives an execution context that can be updated during step execution.
73
76
 
74
- - **Ease of Use**: With Pypeline, setting up and running pipelines becomes more straightforward, enabling developers to focus more on coding and less on configuring pipeline specifics.
77
+ - **Dependency Handling**: Every step can register its dependencies and will only be scheduled if anything has changed.
75
78
 
76
79
  ## Installation
77
80
 
78
- Install this via pip (or your favourite package manager):
81
+ Use pipx (or your favorite package manager) to install and run it in an isolated environment:
82
+
83
+ ```shell
84
+ pipx install pypeline-runner
85
+ ```
86
+
87
+ This will install the `pypeline` command globally, which you can use to run your pipelines.
79
88
 
80
- `pip install pypeline-runner`
89
+ > [!NOTE]
90
+ > The Python package is called `pypeline-runner` because the name `pypeline` was already taken on PyPI.
91
+ > The command-line interface is `pypeline`.
81
92
 
82
- ## Start developing
93
+ Documentation: [pypeline-runner.readthedocs.io](https://pypeline-runner.readthedocs.io)
94
+
95
+ ## Walkthrough: Getting Started with Pypeline
96
+
97
+ To get started run the `init` command to create a sample project:
98
+
99
+ ```shell
100
+ pypeline init --project-dir my-pipeline
101
+ ```
102
+
103
+ The example project pipeline is defined in the `pipeline.yaml` file.
104
+
105
+ ```yaml
106
+ pipeline:
107
+ - step: CreateVEnv
108
+ module: pypeline.steps.create_venv
109
+ config:
110
+ bootstrap_script: .bootstrap/bootstrap.py
111
+ - step: WestInstall
112
+ module: pypeline.steps.west_install
113
+ description: Download external modules
114
+ - step: MyStep
115
+ file: steps/my_step.py
116
+ description: Run a custom script
117
+ ```
118
+
119
+ This pipeline consists of three steps:
120
+
121
+ - `CreateVEnv`: This is a built-in step that creates a Python virtual environment.
122
+ - `WestInstall`: This is a built-in step that downloads external modules using the `west` tool.
123
+ - `MyStep`: This is a custom step that runs a script defined in the `steps/my_step.py` file.
124
+
125
+ You can run the pipeline using the `run` command:
126
+
127
+ ```shell
128
+ pypeline run --project-dir my-pipeline
129
+ ```
130
+
131
+ ## Contributing
83
132
 
84
133
  The project uses Poetry for dependencies management and packaging.
85
134
  Run the `bootstrap.ps1` script to install Python and create the virtual environment.
@@ -88,43 +137,22 @@ Run the `bootstrap.ps1` script to install Python and create the virtual environm
88
137
  .\bootstrap.ps1
89
138
  ```
90
139
 
91
- This will also generate a `poetry.lock` file, you should track this file in version control.
92
-
93
140
  To execute the test suite, call pytest inside Poetry's virtual environment via `poetry run`:
94
141
 
95
142
  ```shell
96
143
  .venv/Scripts/poetry run pytest
97
144
  ```
98
145
 
99
- Check out the Poetry documentation for more information on the available commands.
100
-
101
146
  For those using [VS Code](https://code.visualstudio.com/) there are tasks defined for the most common commands:
102
147
 
103
- - bootstrap
104
- - install dependencies
105
148
  - run tests
106
149
  - run all checks configured for pre-commit
107
150
  - generate documentation
108
151
 
109
152
  See the `.vscode/tasks.json` for more details.
110
153
 
111
- ## Committing changes
112
-
113
154
  This repository uses [commitlint](https://github.com/conventional-changelog/commitlint) for checking if the commit message meets the [conventional commit format](https://www.conventionalcommits.org/en).
114
155
 
115
- ## Contributors ✨
116
-
117
- Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
118
-
119
- <!-- prettier-ignore-start -->
120
- <!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
121
- <!-- markdownlint-disable -->
122
- <!-- markdownlint-enable -->
123
- <!-- ALL-CONTRIBUTORS-LIST:END -->
124
- <!-- prettier-ignore-end -->
125
-
126
- This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
127
-
128
156
  ## Credits
129
157
 
130
158
  This package was created with [Copier](https://copier.readthedocs.io/) and the [cuinixam/pypackage-template](https://github.com/cuinixam/pypackage-template) project template.
@@ -0,0 +1,32 @@
1
+ pypeline/__init__.py,sha256=Oc_xF94AMAHKZkZlB5rBt1iO0TXWFalg65MP4T2qt-A,22
2
+ pypeline/__run.py,sha256=TCdaX05Qm3g8T4QYryKB25Xxf0L5Km7hFOHe1mK9vI0,350
3
+ pypeline/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ pypeline/domain/artifacts.py,sha256=5k7cVfHhLmvWXNuHKxXb9ca4Lxu0JytGQqazENCeKEU,1404
5
+ pypeline/domain/config.py,sha256=AlavAaz5hSxa6yaKYnj-x71ClhOtA41yv5Qf2JIE47k,1650
6
+ pypeline/domain/execution_context.py,sha256=ho-WvCVRMUfYo1532eQYabXCHtXDgvSNUkX8S3Cr7Xo,1278
7
+ pypeline/domain/pipeline.py,sha256=2BsN2lw2znUxLH--Novyqe6SubVKs6XeHQSQf9yxirw,7788
8
+ pypeline/domain/project_slurper.py,sha256=e3BLV88GvfW3efh0agUWKqMk3oWnL602P5u9jER_o9U,971
9
+ pypeline/kickstart/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ pypeline/kickstart/create.py,sha256=iaB8MMC7PinpPBwRmz3rWZuE-DRbsLh2NtvczYaVgi0,2133
11
+ pypeline/kickstart/templates/project/.gitignore,sha256=y8GJoVvRPez1LBokf1NaDOt2X1XtGwKFMF5yjA8AVS0,24
12
+ pypeline/kickstart/templates/project/bootstrap.ps1,sha256=eR8cyIJwDVt-bA2H3GWmxUew3igJaKYKv4rtg7MqhsY,766
13
+ pypeline/kickstart/templates/project/bootstrap.py,sha256=9cJp_sbU0SKvDjJluvyQfh0_xIsf6E1ct7sa7rRecNU,17244
14
+ pypeline/kickstart/templates/project/poetry.toml,sha256=qgVxBdPcJZOHdHCTOBoZYna3cke4VGgRkNZ0bKgN6rs,32
15
+ pypeline/kickstart/templates/project/pypeline.ps1,sha256=PjCJULG8XA3AHKbNt3oHrIgD04huvvpIue_gjSo3PMA,104
16
+ pypeline/kickstart/templates/project/pypeline.yaml,sha256=7FeIu7OOqq7iToPVdP_a4MehIi9lUkBPbXnl8mYpxSo,279
17
+ pypeline/kickstart/templates/project/pyproject.toml,sha256=frr2i_bresciD1LsW5_y65Mwh_QTHia5MVipwsrc1-Q,248
18
+ pypeline/kickstart/templates/project/scoopfile.json,sha256=DcfZ8jYf9hmPHM-AWwnPKQJCzRG3fCuYtMeoY01nkag,219
19
+ pypeline/kickstart/templates/project/steps/my_step.py,sha256=iZYTzWtL-qxEW_t7q079d-xpnRST_tumSzxqiQDW7sM,707
20
+ pypeline/kickstart/templates/project/west.yaml,sha256=ZfVym7M4yzzC-Nm0vESdhqNYs6EaJuMQWGJBht_i0b4,188
21
+ pypeline/main.py,sha256=54_-aINmJY6IILSp6swL2kYqNoBQqJw4W2duxY7gcQ4,3607
22
+ pypeline/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
+ pypeline/pypeline.py,sha256=FHGS2iNtiuiM4dZDHCbeL-UW1apdCtZeFzl1xZW9qBw,6482
24
+ pypeline/steps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
25
+ pypeline/steps/create_venv.py,sha256=xCc73Hk62bbuJIM3btvapuoWeQy_Se5MsgffokYc-r0,2429
26
+ pypeline/steps/scoop_install.py,sha256=_YdoCMXLON0eIwck8PJOcNhayx_ka1krBAidw_oRuFE,3373
27
+ pypeline/steps/west_install.py,sha256=hPyr28ksdKsQ0tv0gMNytzupgk1IgjN9CpmaBdX5zps,1947
28
+ pypeline_runner-1.8.0.dist-info/LICENSE,sha256=sKxdoqSmW9ezvPvt0ZGJbneyA0SBcm0GiqzTv2jN230,1066
29
+ pypeline_runner-1.8.0.dist-info/METADATA,sha256=PNVemc7HHUUK7pjR6pzw9EWlTfn-FFm506Ca798Y5Gs,7557
30
+ pypeline_runner-1.8.0.dist-info/WHEEL,sha256=7dDg4QLnNKTvwIDR9Ac8jJaAmBC_owJrckbC0jjThyA,88
31
+ pypeline_runner-1.8.0.dist-info/entry_points.txt,sha256=pe1u0uuhPI_yeQ0KjEw6jK-EvQfPcZwBSajgbAdKz1o,47
32
+ pypeline_runner-1.8.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.0.1
2
+ Generator: poetry-core 2.1.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,30 +0,0 @@
1
- pypeline/__init__.py,sha256=8EjIC8Er4Bn8PhErizTXrZVYTgb6tHgj00LrrBVNYXA,22
2
- pypeline/__run.py,sha256=TCdaX05Qm3g8T4QYryKB25Xxf0L5Km7hFOHe1mK9vI0,350
3
- pypeline/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- pypeline/domain/artifacts.py,sha256=qXshnk9umi0AVGV4m5iEiy_MQ5Ad2LDZwI8OULU-qMk,1355
5
- pypeline/domain/config.py,sha256=AlavAaz5hSxa6yaKYnj-x71ClhOtA41yv5Qf2JIE47k,1650
6
- pypeline/domain/execution_context.py,sha256=ho-WvCVRMUfYo1532eQYabXCHtXDgvSNUkX8S3Cr7Xo,1278
7
- pypeline/domain/pipeline.py,sha256=mO5tc18nxiAtnLs_MR56u7PAoTJDMZ_KVkCqMzV-YG8,3916
8
- pypeline/domain/project_slurper.py,sha256=YCho7V1BHjFmC_foxHFaWX8c_VbMJ16XEB4CQBlMrhc,894
9
- pypeline/kickstart/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- pypeline/kickstart/create.py,sha256=dWqSdDsqNh7_AnNEsJzsmheR2-xyO6rpUwF-7AwzCYY,2188
11
- pypeline/kickstart/templates/project/.gitignore,sha256=y8GJoVvRPez1LBokf1NaDOt2X1XtGwKFMF5yjA8AVS0,24
12
- pypeline/kickstart/templates/project/bootstrap.ps1,sha256=eR8cyIJwDVt-bA2H3GWmxUew3igJaKYKv4rtg7MqhsY,766
13
- pypeline/kickstart/templates/project/poetry.toml,sha256=q5gF2MWexTFIahJ6StUa3y62HDUrRW7t8kGFszZhgp4,34
14
- pypeline/kickstart/templates/project/pypeline.ps1,sha256=s7CDfnagg8BIO42fpCfLF1l8uK7PNzui-7t9_suokzc,111
15
- pypeline/kickstart/templates/project/pypeline.yaml,sha256=EV5Tnu3H33gMT3Ov0t14-jKwnv9naSMb0wEDzaG0H2Q,238
16
- pypeline/kickstart/templates/project/pyproject.toml,sha256=yc6RCo-bUo1PXF91XfM-dButgfxU16Uud34NidgJ0zQ,225
17
- pypeline/kickstart/templates/project/scoopfile.json,sha256=DcfZ8jYf9hmPHM-AWwnPKQJCzRG3fCuYtMeoY01nkag,219
18
- pypeline/kickstart/templates/project/steps/my_step.py,sha256=_zx01qAVuwn6IMPBUBwKY-IBjS9Gs2m-d51L9sayGug,733
19
- pypeline/main.py,sha256=GtuOgB9OeNFgbWLHZux80fppYQVwMYNFZoZhDIlJW9c,3457
20
- pypeline/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
- pypeline/pypeline.py,sha256=ADg_HlVA6LxZX1jq2GJw-bRCsy1cWixTQcTzDNufauw,9198
22
- pypeline/steps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
- pypeline/steps/create_venv.py,sha256=vTPSA0gGGzq_QhI1jAgsGchS2s8_ZpEtnWcb0uL8BHE,1934
24
- pypeline/steps/scoop_install.py,sha256=_YdoCMXLON0eIwck8PJOcNhayx_ka1krBAidw_oRuFE,3373
25
- pypeline/steps/west_install.py,sha256=hPyr28ksdKsQ0tv0gMNytzupgk1IgjN9CpmaBdX5zps,1947
26
- pypeline_runner-1.6.0.dist-info/LICENSE,sha256=sKxdoqSmW9ezvPvt0ZGJbneyA0SBcm0GiqzTv2jN230,1066
27
- pypeline_runner-1.6.0.dist-info/METADATA,sha256=W1G0C5EOOD3lelRR3o4TDHkx_QY7K9ShxtwCXlr-FA4,7154
28
- pypeline_runner-1.6.0.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
29
- pypeline_runner-1.6.0.dist-info/entry_points.txt,sha256=pe1u0uuhPI_yeQ0KjEw6jK-EvQfPcZwBSajgbAdKz1o,47
30
- pypeline_runner-1.6.0.dist-info/RECORD,,