pypeline-runner 1.14.0__py3-none-any.whl → 1.15.1__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.
@@ -1,461 +1,513 @@
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
+ import argparse
2
+ import configparser
3
+ import ensurepip
4
+ import hashlib
5
+ import json
6
+ import logging
7
+ import os
8
+ import re
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import venv
13
+ from abc import ABC, abstractmethod
14
+ from dataclasses import dataclass
15
+ from enum import Enum
16
+ from functools import total_ordering
17
+ from pathlib import Path
18
+ from typing import List, Optional, Tuple
19
+ from urllib.parse import urlparse
20
+
21
+ logging.basicConfig(level=logging.INFO)
22
+ logger = logging.getLogger("bootstrap")
23
+
24
+
25
+ def get_bootstrap_script() -> Path:
26
+ """Get the path to the internal bootstrap script."""
27
+ return Path(__file__)
28
+
29
+
30
+ @total_ordering
31
+ class Version:
32
+ def __init__(self, version_str: str) -> None:
33
+ self.version = self.parse_version(version_str)
34
+
35
+ @staticmethod
36
+ def parse_version(version_str: str) -> Tuple[int, ...]:
37
+ """Convert a version string into a tuple of integers for comparison."""
38
+ return tuple(map(int, re.split(r"\D+", version_str)))
39
+
40
+ def __eq__(self, other: object) -> bool:
41
+ if not isinstance(other, Version):
42
+ return NotImplemented
43
+ return self.version == other.version
44
+
45
+ def __lt__(self, other: object) -> bool:
46
+ if not isinstance(other, Version):
47
+ return NotImplemented
48
+ return self.version < other.version
49
+
50
+ def __repr__(self) -> str:
51
+ return f"Version({'.'.join(map(str, self.version))})"
52
+
53
+
54
+ @dataclass
55
+ class PyPiSource:
56
+ name: str
57
+ url: str
58
+
59
+
60
+ @dataclass
61
+ class TomlSection:
62
+ name: str
63
+ content: str
64
+
65
+ def __str__(self) -> str:
66
+ return f"[{self.name}]\n{self.content}"
67
+
68
+
69
+ class PyPiSourceParser:
70
+ @staticmethod
71
+ def find_pypi_source_in_content(content: str) -> Optional[PyPiSource]:
72
+ """Parses TOML content, finds the first section containing 'name' and 'url' keys, and returns it as a PyPiSource."""
73
+ sections = PyPiSourceParser.get_toml_sections(content)
74
+ logger.debug(f"Found {len(sections)} potential sections in TOML content.")
75
+
76
+ for section in sections:
77
+ logger.debug(f"Checking section: [{section.name}]")
78
+ try:
79
+ parser = configparser.ConfigParser(interpolation=None) # Disable interpolation
80
+ # Provide the section string directly to read_string
81
+ # The TomlSection.__str__ method formats it correctly
82
+ parser.read_string(str(section))
83
+
84
+ # Check if the section was parsed and contains the required keys
85
+ if section.name in parser and "name" in parser[section.name] and "url" in parser[section.name]:
86
+ name = parser[section.name]["name"].strip("\"' ") # Strip quotes and whitespace
87
+ url = parser[section.name]["url"].strip("\"' ") # Strip quotes and whitespace
88
+
89
+ # Ensure values are not empty after stripping
90
+ if name and url:
91
+ logger.info(f"Found valid PyPI source in section '[{section.name}]': name='{name}', url='{url}'")
92
+ return PyPiSource(name=name, url=url)
93
+ else:
94
+ logger.debug(f"Section '[{section.name}]' contains 'name' and 'url' keys, but one or both values are empty.")
95
+ else:
96
+ logger.debug(f"Section '[{section.name}]' does not contain both 'name' and 'url' keys.")
97
+
98
+ except configparser.Error as e:
99
+ # This might happen if the section content is not valid INI/config format
100
+ # or if the section name itself causes issues (though get_toml_sections should handle it)
101
+ logger.debug(f"Could not parse section '[{section.name}]' with configparser: {e}")
102
+ # Continue to the next section
103
+ continue
104
+
105
+ logger.info("No suitable PyPI source section found in the provided TOML content.")
106
+ return None
107
+
108
+ @staticmethod
109
+ def from_pyproject(project_dir: Path) -> Optional[PyPiSource]:
110
+ """Reads pyproject.toml or Pipfile and finds the PyPI source configuration without relying on a specific section name."""
111
+ pyproject_toml = project_dir / "pyproject.toml"
112
+ pipfile = project_dir / "Pipfile"
113
+ content = None
114
+ file_checked = None
115
+
116
+ if pyproject_toml.exists():
117
+ logger.debug(f"Checking for PyPI source in {pyproject_toml}")
118
+ content = pyproject_toml.read_text()
119
+ file_checked = pyproject_toml
120
+ elif pipfile.exists():
121
+ logger.debug(f"Checking for PyPI source in {pipfile}")
122
+ content = pipfile.read_text()
123
+ file_checked = pipfile
124
+
125
+ if content:
126
+ source = PyPiSourceParser.find_pypi_source_in_content(content)
127
+ if source:
128
+ return source
129
+ else:
130
+ logger.debug(f"No PyPI source definition found in {file_checked}")
131
+ return None
132
+ else:
133
+ logger.debug("Neither pyproject.toml nor Pipfile found in the project directory.")
134
+ return None
135
+
136
+ @staticmethod
137
+ def get_toml_sections(toml_content: str) -> List[TomlSection]:
138
+ # Use a regular expression to find all sections with [ or [[ at the beginning of the line
139
+ raw_sections = re.findall(r"^\[+.*\]+\n(?:[^[]*\n)*", toml_content, re.MULTILINE)
140
+
141
+ # Process each section
142
+ sections = []
143
+ for section in raw_sections:
144
+ # Split the lines, from the first line extract the section name
145
+ # and merge all the other lines into the content
146
+ lines = section.splitlines()
147
+ name_match = re.match(r"^\[+([^]]*)\]+", lines[0])
148
+ if name_match:
149
+ name = name_match.group(1).strip()
150
+ content = "\n".join(lines[1:]).strip()
151
+ sections.append(TomlSection(name, content))
152
+
153
+ return sections
154
+
155
+
156
+ class Runnable(ABC):
157
+ @abstractmethod
158
+ def run(self) -> int:
159
+ """Run stage."""
160
+
161
+ @abstractmethod
162
+ def get_name(self) -> str:
163
+ """Get stage name."""
164
+
165
+ @abstractmethod
166
+ def get_inputs(self) -> List[Path]:
167
+ """Get stage dependencies."""
168
+
169
+ @abstractmethod
170
+ def get_outputs(self) -> List[Path]:
171
+ """Get stage outputs."""
172
+
173
+
174
+ class RunInfoStatus(Enum):
175
+ MATCH = (False, "Nothing has changed, previous execution information matches.")
176
+ NO_INFO = (True, "No previous execution information found.")
177
+ FILE_CHANGED = (True, "Dependencies have been changed.")
178
+
179
+ def __init__(self, should_run: bool, message: str) -> None:
180
+ self.should_run = should_run
181
+ self.message = message
182
+
183
+
184
+ class Executor:
185
+ """
186
+ Accepts Runnable objects and executes them.
187
+
188
+ It create a file with the same name as the runnable's name
189
+ and stores the inputs and outputs with their hashes.
190
+ If the file exists, it checks the hashes of the inputs and outputs
191
+ and if they match, it skips the execution.
192
+ """
193
+
194
+ RUN_INFO_FILE_EXTENSION = ".deps.json"
195
+
196
+ def __init__(self, cache_dir: Path) -> None:
197
+ self.cache_dir = cache_dir
198
+
199
+ @staticmethod
200
+ def get_file_hash(path: Path) -> str:
201
+ """
202
+ Get the hash of a file.
203
+
204
+ Returns an empty string if the file does not exist.
205
+ """
206
+ if path.is_file():
207
+ with open(path, "rb") as file:
208
+ bytes = file.read()
209
+ readable_hash = hashlib.sha256(bytes).hexdigest()
210
+ return readable_hash
211
+ else:
212
+ return ""
213
+
214
+ def store_run_info(self, runnable: Runnable) -> None:
215
+ file_info = {
216
+ "inputs": {str(path): self.get_file_hash(path) for path in runnable.get_inputs()},
217
+ "outputs": {str(path): self.get_file_hash(path) for path in runnable.get_outputs()},
218
+ }
219
+
220
+ run_info_path = self.get_runnable_run_info_file(runnable)
221
+ run_info_path.parent.mkdir(parents=True, exist_ok=True)
222
+ with run_info_path.open("w") as f:
223
+ # pretty print the json file
224
+ json.dump(file_info, f, indent=4)
225
+
226
+ def get_runnable_run_info_file(self, runnable: Runnable) -> Path:
227
+ return self.cache_dir / f"{runnable.get_name()}{self.RUN_INFO_FILE_EXTENSION}"
228
+
229
+ def previous_run_info_matches(self, runnable: Runnable) -> RunInfoStatus:
230
+ run_info_path = self.get_runnable_run_info_file(runnable)
231
+ if not run_info_path.exists():
232
+ return RunInfoStatus.NO_INFO
233
+
234
+ with run_info_path.open() as f:
235
+ previous_info = json.load(f)
236
+
237
+ for file_type in ["inputs", "outputs"]:
238
+ for path_str, previous_hash in previous_info[file_type].items():
239
+ path = Path(path_str)
240
+ if self.get_file_hash(path) != previous_hash:
241
+ return RunInfoStatus.FILE_CHANGED
242
+ return RunInfoStatus.MATCH
243
+
244
+ def execute(self, runnable: Runnable) -> int:
245
+ run_info_status = self.previous_run_info_matches(runnable)
246
+ if run_info_status.should_run:
247
+ logger.info(f"Runnable '{runnable.get_name()}' must run. {run_info_status.message}")
248
+ exit_code = runnable.run()
249
+ self.store_run_info(runnable)
250
+ return exit_code
251
+ logger.info(f"Runnable '{runnable.get_name()}' execution skipped. {run_info_status.message}")
252
+
253
+ return 0
254
+
255
+
256
+ class UserNotificationException(Exception):
257
+ pass
258
+
259
+
260
+ class SubprocessExecutor:
261
+ def __init__(
262
+ self,
263
+ command: List[str | Path],
264
+ cwd: Optional[Path] = None,
265
+ capture_output: bool = True,
266
+ ):
267
+ self.command = " ".join([str(cmd) for cmd in command])
268
+ self.current_working_directory = cwd
269
+ self.capture_output = capture_output
270
+
271
+ def execute(self) -> None:
272
+ result = None
273
+ try:
274
+ current_dir = (self.current_working_directory or Path.cwd()).as_posix()
275
+ logger.info(f"Running command: {self.command} in {current_dir}")
276
+ # print all virtual environment variables
277
+ logger.debug(json.dumps(dict(os.environ), indent=4))
278
+ result = subprocess.run(self.command.split(), cwd=current_dir, capture_output=self.capture_output, text=True) # noqa: S603
279
+ result.check_returncode()
280
+ except subprocess.CalledProcessError as e:
281
+ raise UserNotificationException(f"Command '{self.command}' failed with:\n{result.stdout if result else ''}\n{result.stderr if result else e}") from e
282
+
283
+
284
+ class VirtualEnvironment(ABC):
285
+ def __init__(self, venv_dir: Path) -> None:
286
+ self.venv_dir = venv_dir
287
+
288
+ def create(self) -> None:
289
+ """
290
+ Create a new virtual environment.
291
+
292
+ This should configure the virtual environment such that
293
+ subsequent calls to `pip` and `run` operate within this environment.
294
+ """
295
+ try:
296
+ venv.create(env_dir=self.venv_dir, with_pip=True)
297
+ self.gitignore_configure()
298
+ except PermissionError as e:
299
+ if "python.exe" in str(e):
300
+ raise UserNotificationException(
301
+ f"Failed to create virtual environment in {self.venv_dir}.\nVirtual environment python.exe is still running."
302
+ f" Please kill all instances and run again.\nError: {e}"
303
+ ) from e
304
+ raise UserNotificationException(f"Failed to create virtual environment in {self.venv_dir}.\nPlease make sure you have the necessary permissions.\nError: {e}") from e
305
+
306
+ def gitignore_configure(self) -> None:
307
+ """Create a .gitignore file in the virtual environment directory to ignore all files."""
308
+ gitignore_path = self.venv_dir / ".gitignore"
309
+ with open(gitignore_path, "w") as gitignore_file:
310
+ gitignore_file.write("*\n")
311
+
312
+ def pip_configure(self, index_url: str, verify_ssl: bool = True) -> None:
313
+ """
314
+ Configure pip to use the given index URL and SSL verification setting.
315
+
316
+ This method should behave as if the user had activated the virtual environment
317
+ and run `pip config set global.index-url <index_url>` and
318
+ `pip config set global.cert <verify_ssl>` from the command line.
319
+
320
+ Args:
321
+ ----
322
+ index_url: The index URL to use for pip.
323
+ verify_ssl: Whether to verify SSL certificates when using pip.
324
+
325
+ """
326
+ pip_ini_path = self.pip_config_path()
327
+ with open(pip_ini_path, "w") as pip_ini_file:
328
+ pip_ini_file.write(f"[global]\nindex-url = {index_url}\n")
329
+ if not verify_ssl:
330
+ pip_ini_file.write("cert = false\n")
331
+
332
+ def pip(self, args: List[str]) -> None:
333
+ SubprocessExecutor([self.pip_path().as_posix(), *args]).execute()
334
+
335
+ @abstractmethod
336
+ def pip_path(self) -> Path:
337
+ """Get the path to the pip executable within the virtual environment."""
338
+
339
+ @abstractmethod
340
+ def pip_config_path(self) -> Path:
341
+ """Get the path to the pip configuration file within the virtual environment."""
342
+
343
+ @abstractmethod
344
+ def run(self, args: List[str], capture_output: bool = True) -> None:
345
+ """
346
+ Run an arbitrary command within the virtual environment.
347
+
348
+ This method should behave as if the user had activated the virtual environment
349
+ and run the given command from the command line.
350
+
351
+ """
352
+
353
+
354
+ class WindowsVirtualEnvironment(VirtualEnvironment):
355
+ def __init__(self, venv_dir: Path) -> None:
356
+ super().__init__(venv_dir)
357
+ self.activate_script = self.venv_dir.joinpath("Scripts/activate")
358
+
359
+ def pip_path(self) -> Path:
360
+ return self.venv_dir.joinpath("Scripts/pip.exe")
361
+
362
+ def pip_config_path(self) -> Path:
363
+ return self.venv_dir.joinpath("pip.ini")
364
+
365
+ def run(self, args: List[str], capture_output: bool = True) -> None:
366
+ SubprocessExecutor(
367
+ command=[f"cmd /c {self.activate_script.as_posix()} && ", *args],
368
+ capture_output=capture_output,
369
+ ).execute()
370
+
371
+
372
+ class UnixVirtualEnvironment(VirtualEnvironment):
373
+ def __init__(self, venv_dir: Path) -> None:
374
+ super().__init__(venv_dir)
375
+ self.activate_script = self.venv_dir.joinpath("bin/activate")
376
+
377
+ def pip_path(self) -> Path:
378
+ return self.venv_dir.joinpath("bin/pip")
379
+
380
+ def pip_config_path(self) -> Path:
381
+ return self.venv_dir.joinpath("pip.conf")
382
+
383
+ def run(self, args: List[str], capture_output: bool = True) -> None:
384
+ # Create a temporary shell script
385
+ with tempfile.NamedTemporaryFile("w", delete=False, suffix=".sh") as f:
386
+ f.write("#!/bin/bash\n") # Add a shebang line
387
+ f.write(f"source {self.activate_script.as_posix()}\n") # Write the activate command
388
+ f.write(" ".join(args)) # Write the provided command
389
+ temp_script_path = f.name # Get the path of the temporary script
390
+
391
+ # Make the temporary script executable
392
+ SubprocessExecutor(["chmod", "+x", temp_script_path]).execute()
393
+ # Run the temporary script
394
+ SubprocessExecutor(
395
+ command=[f"{Path(temp_script_path).as_posix()}"],
396
+ capture_output=capture_output,
397
+ ).execute()
398
+ # Delete the temporary script
399
+ os.remove(temp_script_path)
400
+
401
+
402
+ class CreateVirtualEnvironment(Runnable):
403
+ def __init__(self, root_dir: Path, package_manager: str) -> None:
404
+ self.root_dir = root_dir
405
+ self.venv_dir = self.root_dir / ".venv"
406
+ self.virtual_env = self.instantiate_os_specific_venv(self.venv_dir)
407
+ self.package_manager = package_manager.replace('"', "").replace("'", "")
408
+ self.execution_info_file = self.venv_dir / "virtual_env_exec_info.json"
409
+
410
+ @property
411
+ def package_manager_name(self) -> str:
412
+ match = re.match(r"^([a-zA-Z0-9_-]+)", self.package_manager)
413
+ if match:
414
+ return match.group(1)
415
+ else:
416
+ raise UserNotificationException(f"Could not extract the package manager name from {self.package_manager}")
417
+
418
+ def get_install_argument(self) -> str:
419
+ """Determine the install argument based on the package manager name."""
420
+ if self.package_manager_name == "uv":
421
+ return "sync"
422
+ return "install"
423
+
424
+ def run(self) -> int:
425
+ # Create the virtual environment if pip executable does not exist
426
+ if not self.virtual_env.pip_path().exists():
427
+ self.virtual_env.create()
428
+
429
+ # Get the PyPi source from pyproject.toml or Pipfile if it is defined
430
+ pypi_source = PyPiSourceParser.from_pyproject(self.root_dir)
431
+ if pypi_source:
432
+ self.virtual_env.pip_configure(index_url=pypi_source.url, verify_ssl=True)
433
+ # We need pip-system-certs in venv to use certificates, that are stored in the system's trust store,
434
+ pip_args = ["install", self.package_manager, "pip-system-certs"]
435
+ # but to install it, we need either a pip version with the trust store feature or to trust the host
436
+ # (trust store feature enabled by default since 24.2)
437
+ if Version(ensurepip.version()) < Version("24.2"):
438
+ # Add trusted host of configured source for older Python versions
439
+ if pypi_source and pypi_source.url:
440
+ if hostname := urlparse(pypi_source.url).hostname:
441
+ pip_args.extend(["--trusted-host", hostname])
442
+ else:
443
+ pip_args.extend(["--trusted-host", "pypi.org", "--trusted-host", "pypi.python.org", "--trusted-host", "files.pythonhosted.org"])
444
+ self.virtual_env.pip(pip_args)
445
+ self.virtual_env.run(["python", "-m", self.package_manager_name, self.get_install_argument()])
446
+ return 0
447
+
448
+ @staticmethod
449
+ def instantiate_os_specific_venv(venv_dir: Path) -> VirtualEnvironment:
450
+ if sys.platform.startswith("win32"):
451
+ return WindowsVirtualEnvironment(venv_dir)
452
+ elif sys.platform.startswith("linux") or sys.platform.startswith("darwin"):
453
+ return UnixVirtualEnvironment(venv_dir)
454
+ else:
455
+ raise UserNotificationException(f"Unsupported operating system: {sys.platform}")
456
+
457
+ def get_name(self) -> str:
458
+ return "create-virtual-environment"
459
+
460
+ def get_inputs(self) -> List[Path]:
461
+ venv_relevant_files = [
462
+ "uv.lock",
463
+ "poetry.lock",
464
+ "poetry.toml",
465
+ "pyproject.toml",
466
+ ".env",
467
+ "Pipfile",
468
+ "Pipfile.lock",
469
+ ]
470
+ return [self.root_dir / file for file in venv_relevant_files] + [get_bootstrap_script()]
471
+
472
+ def get_outputs(self) -> List[Path]:
473
+ return []
474
+
475
+
476
+ def print_environment_info() -> None:
477
+ str_bar = "".join(["-" for _ in range(80)])
478
+ logger.debug(str_bar)
479
+ logger.debug("Environment: \n" + json.dumps(dict(os.environ), indent=4))
480
+ logger.info(str_bar)
481
+ logger.info(f"Arguments: {sys.argv[1:]}")
482
+ logger.info(str_bar)
483
+
484
+
485
+ def main() -> int:
486
+ try:
487
+ parser = argparse.ArgumentParser(description="Create the python virtual environment.")
488
+ parser.add_argument(
489
+ "--package-manager",
490
+ type=str,
491
+ required=False,
492
+ default="poetry>=2.0",
493
+ help="Specify the package manager to use (e.g., poetry>=2.0).",
494
+ )
495
+ parser.add_argument(
496
+ "--project-dir",
497
+ type=Path,
498
+ required=False,
499
+ default=Path.cwd(),
500
+ help="Specify the project directory (default: current working directory).",
501
+ )
502
+ args = parser.parse_args()
503
+
504
+ creator = CreateVirtualEnvironment(args.project_dir, package_manager=args.package_manager)
505
+ Executor(creator.venv_dir).execute(creator)
506
+ except UserNotificationException as e:
507
+ logger.error(e)
508
+ return 1
509
+ return 0
510
+
511
+
512
+ if __name__ == "__main__":
513
+ sys.exit(main())