pypeline-runner 1.7.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.7.0"
1
+ __version__ = "1.8.0"
@@ -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
@@ -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.7.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.
@@ -1,4 +1,4 @@
1
- pypeline/__init__.py,sha256=lEEx5lwinUby-YNl7w-VFGUcjcibsJ9EuzgJ2W7ocrU,22
1
+ pypeline/__init__.py,sha256=Oc_xF94AMAHKZkZlB5rBt1iO0TXWFalg65MP4T2qt-A,22
2
2
  pypeline/__run.py,sha256=TCdaX05Qm3g8T4QYryKB25Xxf0L5Km7hFOHe1mK9vI0,350
3
3
  pypeline/domain/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
4
  pypeline/domain/artifacts.py,sha256=5k7cVfHhLmvWXNuHKxXb9ca4Lxu0JytGQqazENCeKEU,1404
@@ -7,24 +7,26 @@ pypeline/domain/execution_context.py,sha256=ho-WvCVRMUfYo1532eQYabXCHtXDgvSNUkX8
7
7
  pypeline/domain/pipeline.py,sha256=2BsN2lw2znUxLH--Novyqe6SubVKs6XeHQSQf9yxirw,7788
8
8
  pypeline/domain/project_slurper.py,sha256=e3BLV88GvfW3efh0agUWKqMk3oWnL602P5u9jER_o9U,971
9
9
  pypeline/kickstart/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
- pypeline/kickstart/create.py,sha256=dWqSdDsqNh7_AnNEsJzsmheR2-xyO6rpUwF-7AwzCYY,2188
10
+ pypeline/kickstart/create.py,sha256=iaB8MMC7PinpPBwRmz3rWZuE-DRbsLh2NtvczYaVgi0,2133
11
11
  pypeline/kickstart/templates/project/.gitignore,sha256=y8GJoVvRPez1LBokf1NaDOt2X1XtGwKFMF5yjA8AVS0,24
12
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
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
17
18
  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/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
19
21
  pypeline/main.py,sha256=54_-aINmJY6IILSp6swL2kYqNoBQqJw4W2duxY7gcQ4,3607
20
22
  pypeline/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
23
  pypeline/pypeline.py,sha256=FHGS2iNtiuiM4dZDHCbeL-UW1apdCtZeFzl1xZW9qBw,6482
22
24
  pypeline/steps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
- pypeline/steps/create_venv.py,sha256=vTPSA0gGGzq_QhI1jAgsGchS2s8_ZpEtnWcb0uL8BHE,1934
25
+ pypeline/steps/create_venv.py,sha256=xCc73Hk62bbuJIM3btvapuoWeQy_Se5MsgffokYc-r0,2429
24
26
  pypeline/steps/scoop_install.py,sha256=_YdoCMXLON0eIwck8PJOcNhayx_ka1krBAidw_oRuFE,3373
25
27
  pypeline/steps/west_install.py,sha256=hPyr28ksdKsQ0tv0gMNytzupgk1IgjN9CpmaBdX5zps,1947
26
- pypeline_runner-1.7.0.dist-info/LICENSE,sha256=sKxdoqSmW9ezvPvt0ZGJbneyA0SBcm0GiqzTv2jN230,1066
27
- pypeline_runner-1.7.0.dist-info/METADATA,sha256=O-Y7JgczzWmfE0m2W7CRH23JJmNDNTrCdxc0P0ZLpkI,7154
28
- pypeline_runner-1.7.0.dist-info/WHEEL,sha256=7dDg4QLnNKTvwIDR9Ac8jJaAmBC_owJrckbC0jjThyA,88
29
- pypeline_runner-1.7.0.dist-info/entry_points.txt,sha256=pe1u0uuhPI_yeQ0KjEw6jK-EvQfPcZwBSajgbAdKz1o,47
30
- pypeline_runner-1.7.0.dist-info/RECORD,,
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,,