fractal-server 2.2.0a1__py3-none-any.whl → 2.3.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.
Files changed (67) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/app/models/v1/state.py +1 -2
  3. fractal_server/app/routes/admin/v1.py +2 -2
  4. fractal_server/app/routes/admin/v2.py +2 -2
  5. fractal_server/app/routes/api/v1/job.py +2 -2
  6. fractal_server/app/routes/api/v1/task_collection.py +4 -4
  7. fractal_server/app/routes/api/v2/__init__.py +23 -3
  8. fractal_server/app/routes/api/v2/job.py +2 -2
  9. fractal_server/app/routes/api/v2/submit.py +6 -0
  10. fractal_server/app/routes/api/v2/task_collection.py +74 -34
  11. fractal_server/app/routes/api/v2/task_collection_custom.py +170 -0
  12. fractal_server/app/routes/api/v2/task_collection_ssh.py +125 -0
  13. fractal_server/app/routes/aux/_runner.py +10 -2
  14. fractal_server/app/runner/compress_folder.py +120 -0
  15. fractal_server/app/runner/executors/slurm/__init__.py +0 -3
  16. fractal_server/app/runner/executors/slurm/_batching.py +0 -1
  17. fractal_server/app/runner/executors/slurm/_slurm_config.py +9 -9
  18. fractal_server/app/runner/executors/slurm/ssh/__init__.py +3 -0
  19. fractal_server/app/runner/executors/slurm/ssh/_executor_wait_thread.py +112 -0
  20. fractal_server/app/runner/executors/slurm/ssh/_slurm_job.py +120 -0
  21. fractal_server/app/runner/executors/slurm/ssh/executor.py +1488 -0
  22. fractal_server/app/runner/executors/slurm/sudo/__init__.py +3 -0
  23. fractal_server/app/runner/executors/slurm/{_check_jobs_status.py → sudo/_check_jobs_status.py} +1 -1
  24. fractal_server/app/runner/executors/slurm/{_executor_wait_thread.py → sudo/_executor_wait_thread.py} +1 -1
  25. fractal_server/app/runner/executors/slurm/{_subprocess_run_as_user.py → sudo/_subprocess_run_as_user.py} +1 -1
  26. fractal_server/app/runner/executors/slurm/{executor.py → sudo/executor.py} +12 -12
  27. fractal_server/app/runner/extract_archive.py +38 -0
  28. fractal_server/app/runner/v1/__init__.py +78 -40
  29. fractal_server/app/runner/v1/_slurm/__init__.py +1 -1
  30. fractal_server/app/runner/v2/__init__.py +147 -62
  31. fractal_server/app/runner/v2/_local_experimental/__init__.py +22 -12
  32. fractal_server/app/runner/v2/_local_experimental/executor.py +12 -8
  33. fractal_server/app/runner/v2/_slurm/__init__.py +1 -6
  34. fractal_server/app/runner/v2/_slurm_ssh/__init__.py +125 -0
  35. fractal_server/app/runner/v2/_slurm_ssh/_submit_setup.py +83 -0
  36. fractal_server/app/runner/v2/_slurm_ssh/get_slurm_config.py +182 -0
  37. fractal_server/app/runner/v2/runner_functions_low_level.py +9 -11
  38. fractal_server/app/runner/versions.py +30 -0
  39. fractal_server/app/schemas/v1/__init__.py +1 -0
  40. fractal_server/app/schemas/{state.py → v1/state.py} +4 -21
  41. fractal_server/app/schemas/v2/__init__.py +4 -1
  42. fractal_server/app/schemas/v2/task_collection.py +101 -30
  43. fractal_server/config.py +184 -3
  44. fractal_server/main.py +27 -1
  45. fractal_server/ssh/__init__.py +4 -0
  46. fractal_server/ssh/_fabric.py +245 -0
  47. fractal_server/tasks/utils.py +12 -64
  48. fractal_server/tasks/v1/background_operations.py +2 -2
  49. fractal_server/tasks/{endpoint_operations.py → v1/endpoint_operations.py} +7 -12
  50. fractal_server/tasks/v1/utils.py +67 -0
  51. fractal_server/tasks/v2/_TaskCollectPip.py +61 -32
  52. fractal_server/tasks/v2/_venv_pip.py +195 -0
  53. fractal_server/tasks/v2/background_operations.py +257 -295
  54. fractal_server/tasks/v2/background_operations_ssh.py +317 -0
  55. fractal_server/tasks/v2/endpoint_operations.py +136 -0
  56. fractal_server/tasks/v2/templates/_1_create_venv.sh +46 -0
  57. fractal_server/tasks/v2/templates/_2_upgrade_pip.sh +30 -0
  58. fractal_server/tasks/v2/templates/_3_pip_install.sh +32 -0
  59. fractal_server/tasks/v2/templates/_4_pip_freeze.sh +21 -0
  60. fractal_server/tasks/v2/templates/_5_pip_show.sh +59 -0
  61. fractal_server/tasks/v2/utils.py +54 -0
  62. {fractal_server-2.2.0a1.dist-info → fractal_server-2.3.0.dist-info}/METADATA +4 -2
  63. {fractal_server-2.2.0a1.dist-info → fractal_server-2.3.0.dist-info}/RECORD +66 -42
  64. fractal_server/tasks/v2/get_collection_data.py +0 -14
  65. {fractal_server-2.2.0a1.dist-info → fractal_server-2.3.0.dist-info}/LICENSE +0 -0
  66. {fractal_server-2.2.0a1.dist-info → fractal_server-2.3.0.dist-info}/WHEEL +0 -0
  67. {fractal_server-2.2.0a1.dist-info → fractal_server-2.3.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,245 @@
1
+ import time
2
+ from contextlib import contextmanager
3
+ from threading import Lock
4
+ from typing import Any
5
+ from typing import Optional
6
+
7
+ from fabric import Connection
8
+ from fabric import Result
9
+ from invoke import UnexpectedExit
10
+ from paramiko.ssh_exception import NoValidConnectionsError
11
+
12
+ from ..logger import get_logger
13
+ from ..logger import set_logger
14
+ from fractal_server.config import get_settings
15
+ from fractal_server.syringe import Inject
16
+
17
+ logger = set_logger(__name__)
18
+
19
+ MAX_ATTEMPTS = 5
20
+
21
+
22
+ class TimeoutException(Exception):
23
+ pass
24
+
25
+
26
+ @contextmanager
27
+ def acquire_timeout(lock: Lock, timeout: int) -> Any:
28
+ logger.debug(f"Trying to acquire lock, with {timeout=}")
29
+ result = lock.acquire(timeout=timeout)
30
+ try:
31
+ if not result:
32
+ raise TimeoutException(
33
+ f"Failed to acquire lock within {timeout} seconds"
34
+ )
35
+ logger.debug("Lock was acquired.")
36
+ yield result
37
+ finally:
38
+ if result:
39
+ lock.release()
40
+ logger.debug("Lock was released")
41
+
42
+
43
+ class FractalSSH(object):
44
+ lock: Lock
45
+ connection: Connection
46
+ default_timeout: int
47
+
48
+ # FIXME SSH: maybe extend the actual_timeout logic to other methods
49
+
50
+ def __init__(self, connection: Connection, default_timeout: int = 250):
51
+ self.lock = Lock()
52
+ self.conn = connection
53
+ self.default_timeout = default_timeout
54
+
55
+ @property
56
+ def is_connected(self) -> bool:
57
+ return self.conn.is_connected
58
+
59
+ def put(self, *args, timeout: Optional[int] = None, **kwargs) -> Result:
60
+ actual_timeout = timeout or self.default_timeout
61
+ with acquire_timeout(self.lock, timeout=actual_timeout):
62
+ return self.conn.put(*args, **kwargs)
63
+
64
+ def get(self, *args, **kwargs) -> Result:
65
+ with acquire_timeout(self.lock, timeout=self.default_timeout):
66
+ return self.conn.get(*args, **kwargs)
67
+
68
+ def run(self, *args, **kwargs) -> Any:
69
+ with acquire_timeout(self.lock, timeout=self.default_timeout):
70
+ return self.conn.run(*args, **kwargs)
71
+
72
+ def close(self):
73
+ return self.conn.close()
74
+
75
+ def sftp(self):
76
+ return self.conn.sftp()
77
+
78
+ def check_connection(self) -> None:
79
+ """
80
+ Open the SSH connection and handle exceptions.
81
+
82
+ This function can be called from within other functions that use
83
+ `connection`, so that we can provide a meaningful error in case the
84
+ SSH connection cannot be opened.
85
+ """
86
+ if not self.conn.is_connected:
87
+ try:
88
+ self.conn.open()
89
+ except Exception as e:
90
+ raise RuntimeError(
91
+ f"Cannot open SSH connection (original error: '{str(e)}')."
92
+ )
93
+
94
+
95
+ def get_ssh_connection(
96
+ *,
97
+ host: Optional[str] = None,
98
+ user: Optional[str] = None,
99
+ key_filename: Optional[str] = None,
100
+ ) -> Connection:
101
+ """
102
+ Create a `fabric.Connection` object based on fractal-server settings
103
+ or explicit arguments.
104
+
105
+ Args:
106
+ host:
107
+ user:
108
+ key_filename:
109
+
110
+ Returns:
111
+ Fabric connection object
112
+ """
113
+ settings = Inject(get_settings)
114
+ if host is None:
115
+ host = settings.FRACTAL_SLURM_SSH_HOST
116
+ if user is None:
117
+ user = settings.FRACTAL_SLURM_SSH_USER
118
+ if key_filename is None:
119
+ key_filename = settings.FRACTAL_SLURM_SSH_PRIVATE_KEY_PATH
120
+
121
+ connection = Connection(
122
+ host=host,
123
+ user=user,
124
+ connect_kwargs={"key_filename": key_filename},
125
+ )
126
+ logger.debug(f"Now created {connection=}.")
127
+ return connection
128
+
129
+
130
+ def run_command_over_ssh(
131
+ *,
132
+ cmd: str,
133
+ fractal_ssh: FractalSSH,
134
+ max_attempts: int = MAX_ATTEMPTS,
135
+ base_interval: float = 3.0,
136
+ ) -> str:
137
+ """
138
+ Run a command within an open SSH connection.
139
+
140
+ Args:
141
+ cmd: Command to be run
142
+ fractal_ssh: FractalSSH connection object with custom lock
143
+
144
+ Returns:
145
+ Standard output of the command, if successful.
146
+ """
147
+ t_0 = time.perf_counter()
148
+ ind_attempt = 0
149
+ while ind_attempt <= max_attempts:
150
+ ind_attempt += 1
151
+ prefix = f"[attempt {ind_attempt}/{max_attempts}]"
152
+ logger.info(f"{prefix} START running '{cmd}' over SSH.")
153
+ try:
154
+ # Case 1: Command runs successfully
155
+ res = fractal_ssh.run(cmd, hide=True)
156
+ t_1 = time.perf_counter()
157
+ logger.info(
158
+ f"{prefix} END running '{cmd}' over SSH, "
159
+ f"elapsed {t_1-t_0:.3f}"
160
+ )
161
+ logger.debug(f"STDOUT: {res.stdout}")
162
+ logger.debug(f"STDERR: {res.stderr}")
163
+ return res.stdout
164
+ except NoValidConnectionsError as e:
165
+ # Case 2: Command fails with a connection error
166
+ logger.warning(
167
+ f"{prefix} Running command `{cmd}` over SSH failed.\n"
168
+ f"Original NoValidConnectionError:\n{str(e)}.\n"
169
+ f"{e.errors=}\n"
170
+ )
171
+ if ind_attempt < max_attempts:
172
+ sleeptime = base_interval**ind_attempt
173
+ logger.warning(
174
+ f"{prefix} Now sleep {sleeptime:.3f} seconds and continue."
175
+ )
176
+ time.sleep(sleeptime)
177
+ continue
178
+ else:
179
+ logger.error(f"{prefix} Reached last attempt")
180
+ break
181
+ except UnexpectedExit as e:
182
+ # Case 3: Command fails with an actual error
183
+ error_msg = (
184
+ f"{prefix} Running command `{cmd}` over SSH failed.\n"
185
+ f"Original error:\n{str(e)}."
186
+ )
187
+ logger.error(error_msg)
188
+ raise ValueError(error_msg)
189
+ except Exception as e:
190
+ logger.error(
191
+ f"Running command `{cmd}` over SSH failed.\n"
192
+ f"Original Error:\n{str(e)}."
193
+ )
194
+ raise e
195
+
196
+ raise ValueError(
197
+ f"Reached last attempt ({max_attempts=}) for running '{cmd}' over SSH"
198
+ )
199
+
200
+
201
+ def put_over_ssh(
202
+ *,
203
+ local: str,
204
+ remote: str,
205
+ fractal_ssh: FractalSSH,
206
+ logger_name: Optional[str] = None,
207
+ ) -> None:
208
+ """
209
+ Transfer a file via SSH
210
+
211
+ Args:
212
+ local: Local path to file
213
+ remote: Target path on remote host
214
+ fractal_ssh: FractalSSH connection object with custom lock
215
+ logger_name: Name of the logger
216
+
217
+ """
218
+ try:
219
+ fractal_ssh.put(local=local, remote=remote)
220
+ except Exception as e:
221
+ logger = get_logger(logger_name=logger_name)
222
+ logger.error(
223
+ f"Transferring {local=} to {remote=} over SSH failed.\n"
224
+ f"Original Error:\n{str(e)}."
225
+ )
226
+ raise e
227
+
228
+
229
+ def _mkdir_over_ssh(
230
+ *, folder: str, fractal_ssh: FractalSSH, parents: bool = True
231
+ ) -> None:
232
+ """
233
+ Create a folder remotely via SSH.
234
+
235
+ Args:
236
+ folder:
237
+ fractal_ssh:
238
+ parents:
239
+ """
240
+ # FIXME SSH: try using `mkdir` method of `paramiko.SFTPClient`
241
+ if parents:
242
+ cmd = f"mkdir -p {folder}"
243
+ else:
244
+ cmd = f"mkdir {folder}"
245
+ run_command_over_ssh(cmd=cmd, fractal_ssh=fractal_ssh)
@@ -1,42 +1,12 @@
1
1
  import re
2
- import shutil
3
- import sys
4
2
  from pathlib import Path
5
- from typing import Optional
6
3
 
7
4
  from fractal_server.config import get_settings
8
- from fractal_server.logger import get_logger
9
5
  from fractal_server.syringe import Inject
10
- from fractal_server.utils import execute_command
11
6
 
12
7
  COLLECTION_FILENAME = "collection.json"
13
8
  COLLECTION_LOG_FILENAME = "collection.log"
14
-
15
-
16
- def get_python_interpreter(version: Optional[str] = None) -> str:
17
- """
18
- Return the path to the python interpreter
19
-
20
- Args:
21
- version: Python version
22
-
23
- Raises:
24
- ValueError: If the python version requested is not available on the
25
- host.
26
-
27
- Returns:
28
- interpreter: string representing the python executable or its path
29
- """
30
- if version:
31
- interpreter = shutil.which(f"python{version}")
32
- if not interpreter:
33
- raise ValueError(
34
- f"Python version {version} not available on host."
35
- )
36
- else:
37
- interpreter = sys.executable
38
-
39
- return interpreter
9
+ COLLECTION_FREEZE_FILENAME = "collection_freeze.txt"
40
10
 
41
11
 
42
12
  def slugify_task_name(task_name: str) -> str:
@@ -63,6 +33,10 @@ def get_log_path(base: Path) -> Path:
63
33
  return base / COLLECTION_LOG_FILENAME
64
34
 
65
35
 
36
+ def get_freeze_path(base: Path) -> Path:
37
+ return base / COLLECTION_FREEZE_FILENAME
38
+
39
+
66
40
  def get_collection_log(venv_path: Path) -> str:
67
41
  package_path = get_absolute_venv_path(venv_path)
68
42
  log_path = get_log_path(package_path)
@@ -70,6 +44,13 @@ def get_collection_log(venv_path: Path) -> str:
70
44
  return log
71
45
 
72
46
 
47
+ def get_collection_freeze(venv_path: Path) -> str:
48
+ package_path = get_absolute_venv_path(venv_path)
49
+ freeze_path = get_freeze_path(package_path)
50
+ freeze = freeze_path.open().read()
51
+ return freeze
52
+
53
+
73
54
  def _normalize_package_name(name: str) -> str:
74
55
  """
75
56
  Implement PyPa specifications for package-name normalization
@@ -86,36 +67,3 @@ def _normalize_package_name(name: str) -> str:
86
67
  The normalized package name.
87
68
  """
88
69
  return re.sub(r"[-_.]+", "-", name).lower()
89
-
90
-
91
- async def _init_venv(
92
- *,
93
- path: Path,
94
- python_version: Optional[str] = None,
95
- logger_name: str,
96
- ) -> Path:
97
- """
98
- Set a virtual environment at `path/venv`
99
-
100
- Args:
101
- path : Path
102
- path to directory in which to set up the virtual environment
103
- python_version : default=None
104
- Python version the virtual environment will be based upon
105
-
106
- Returns:
107
- python_bin : Path
108
- path to python interpreter
109
- """
110
- logger = get_logger(logger_name)
111
- logger.debug(f"[_init_venv] {path=}")
112
- interpreter = get_python_interpreter(version=python_version)
113
- logger.debug(f"[_init_venv] {interpreter=}")
114
- await execute_command(
115
- cwd=path,
116
- command=f"{interpreter} -m venv venv",
117
- logger_name=logger_name,
118
- )
119
- python_bin = path / "venv/bin/python"
120
- logger.debug(f"[_init_venv] {python_bin=}")
121
- return python_bin
@@ -6,13 +6,13 @@ import json
6
6
  from pathlib import Path
7
7
  from shutil import rmtree as shell_rmtree
8
8
 
9
- from ..utils import _init_venv
10
9
  from ..utils import _normalize_package_name
11
10
  from ..utils import get_collection_log
12
11
  from ..utils import get_collection_path
13
12
  from ..utils import get_log_path
14
13
  from ..utils import slugify_task_name
15
14
  from ._TaskCollectPip import _TaskCollectPip
15
+ from .utils import _init_venv_v1
16
16
  from fractal_server.app.db import DBSyncSession
17
17
  from fractal_server.app.db import get_sync_db
18
18
  from fractal_server.app.models.v1 import State
@@ -168,7 +168,7 @@ async def _create_venv_install_package(
168
168
  task_pkg.package_name = _normalize_package_name(task_pkg.package_name)
169
169
  task_pkg.package = _normalize_package_name(task_pkg.package)
170
170
 
171
- python_bin = await _init_venv(
171
+ python_bin = await _init_venv_v1(
172
172
  path=path,
173
173
  python_version=task_pkg.python_version,
174
174
  logger_name=logger_name,
@@ -4,12 +4,10 @@ from typing import Optional
4
4
  from typing import Union
5
5
  from zipfile import ZipFile
6
6
 
7
- from .utils import _normalize_package_name
8
- from .utils import get_python_interpreter
9
- from .v1._TaskCollectPip import _TaskCollectPip as _TaskCollectPipV1
10
- from .v2._TaskCollectPip import _TaskCollectPip as _TaskCollectPipV2
7
+ from ..utils import _normalize_package_name
8
+ from ._TaskCollectPip import _TaskCollectPip as _TaskCollectPipV1
9
+ from .utils import get_python_interpreter_v1
11
10
  from fractal_server.app.schemas.v1 import ManifestV1
12
- from fractal_server.app.schemas.v2 import ManifestV2
13
11
  from fractal_server.config import get_settings
14
12
  from fractal_server.logger import get_logger
15
13
  from fractal_server.syringe import Inject
@@ -21,13 +19,13 @@ FRACTAL_PUBLIC_TASK_SUBDIR = ".fractal"
21
19
 
22
20
  async def download_package(
23
21
  *,
24
- task_pkg: Union[_TaskCollectPipV1, _TaskCollectPipV2],
22
+ task_pkg: _TaskCollectPipV1,
25
23
  dest: Union[str, Path],
26
24
  ) -> Path:
27
25
  """
28
26
  Download package to destination
29
27
  """
30
- interpreter = get_python_interpreter(version=task_pkg.python_version)
28
+ interpreter = get_python_interpreter_v1(version=task_pkg.python_version)
31
29
  pip = f"{interpreter} -m pip"
32
30
  version = (
33
31
  f"=={task_pkg.package_version}" if task_pkg.package_version else ""
@@ -43,7 +41,7 @@ async def download_package(
43
41
 
44
42
  def _load_manifest_from_wheel(
45
43
  path: Path, wheel: ZipFile, logger_name: Optional[str] = None
46
- ) -> Union[ManifestV1, ManifestV2]:
44
+ ) -> ManifestV1:
47
45
  logger = get_logger(logger_name)
48
46
  namelist = wheel.namelist()
49
47
  try:
@@ -60,9 +58,6 @@ def _load_manifest_from_wheel(
60
58
  if manifest_version == "1":
61
59
  pkg_manifest = ManifestV1(**manifest_dict)
62
60
  return pkg_manifest
63
- elif manifest_version == "2":
64
- pkg_manifest = ManifestV2(**manifest_dict)
65
- return pkg_manifest
66
61
  else:
67
62
  msg = f"Manifest version {manifest_version=} not supported"
68
63
  logger.error(msg)
@@ -140,7 +135,7 @@ def inspect_package(path: Path, logger_name: Optional[str] = None) -> dict:
140
135
 
141
136
  def create_package_dir_pip(
142
137
  *,
143
- task_pkg: Union[_TaskCollectPipV1, _TaskCollectPipV2],
138
+ task_pkg: _TaskCollectPipV1,
144
139
  create: bool = True,
145
140
  ) -> Path:
146
141
  """
@@ -0,0 +1,67 @@
1
+ from pathlib import Path
2
+ from typing import Optional
3
+
4
+ from fractal_server.logger import get_logger
5
+ from fractal_server.utils import execute_command
6
+
7
+
8
+ def get_python_interpreter_v1(version: Optional[str] = None) -> str:
9
+ """
10
+ Return the path to the python interpreter
11
+
12
+ Args:
13
+ version: Python version
14
+
15
+ Raises:
16
+ ValueError: If the python version requested is not available on the
17
+ host.
18
+
19
+ Returns:
20
+ interpreter: string representing the python executable or its path
21
+ """
22
+ import shutil
23
+ import sys
24
+
25
+ if version:
26
+ interpreter = shutil.which(f"python{version}")
27
+ if not interpreter:
28
+ raise ValueError(
29
+ f"Python version {version} not available on host."
30
+ )
31
+ else:
32
+ interpreter = sys.executable
33
+
34
+ return interpreter
35
+
36
+
37
+ async def _init_venv_v1(
38
+ *,
39
+ path: Path,
40
+ python_version: Optional[str] = None,
41
+ logger_name: str,
42
+ ) -> Path:
43
+ """
44
+ Set a virtual environment at `path/venv`
45
+
46
+ Args:
47
+ path : Path
48
+ path to directory in which to set up the virtual environment
49
+ python_version : default=None
50
+ Python version the virtual environment will be based upon
51
+
52
+ Returns:
53
+ python_bin : Path
54
+ path to python interpreter
55
+ """
56
+ logger = get_logger(logger_name)
57
+ logger.debug(f"[_init_venv] {path=}")
58
+ interpreter = get_python_interpreter_v1(version=python_version)
59
+ logger.debug(f"[_init_venv] {interpreter=}")
60
+ await execute_command(
61
+ cwd=path,
62
+ command=f"{interpreter} -m venv venv",
63
+ logger_name=logger_name,
64
+ )
65
+ python_bin = path / "venv/bin/python"
66
+ logger.debug(f"[_init_venv] {python_bin=}")
67
+ return python_bin
@@ -1,56 +1,90 @@
1
+ import logging
1
2
  from pathlib import Path
2
3
  from typing import Optional
3
4
 
5
+ from pydantic import BaseModel
6
+ from pydantic import Extra
7
+ from pydantic import Field
4
8
  from pydantic import root_validator
9
+ from pydantic import validator
5
10
 
11
+ from fractal_server.app.schemas._validators import valdictkeys
12
+ from fractal_server.app.schemas._validators import valstr
6
13
  from fractal_server.app.schemas.v2 import ManifestV2
7
- from fractal_server.app.schemas.v2 import TaskCollectPipV2
14
+ from fractal_server.tasks.utils import _normalize_package_name
15
+ from fractal_server.tasks.v2.utils import _parse_wheel_filename
8
16
 
9
17
 
10
- class _TaskCollectPip(TaskCollectPipV2):
18
+ class _TaskCollectPip(BaseModel, extra=Extra.forbid):
11
19
  """
12
- Internal TaskCollectPip schema
20
+ Internal task-collection model.
13
21
 
14
- Differences with its parent class (`TaskCollectPip`):
22
+ This model is similar to the API request-body model (`TaskCollectPip`), but
23
+ with enough differences that we keep them separated (and they do not have a
24
+ common base).
15
25
 
16
- 1. We check if the package corresponds to a path in the filesystem, and
17
- whether it exists (via new validator `check_local_package`, new
18
- method `is_local_package` and new attribute `package_path`).
19
- 2. We include an additional `package_manifest` attribute.
20
- 3. We expose an additional attribute `package_name`, which is filled
21
- during task collection.
26
+ Attributes:
27
+ package: Either a PyPI package name or the absolute path to a wheel
28
+ file.
29
+ package_name: The actual normalized name of the package, which is set
30
+ internally through a validator.
31
+ package_version: Package version. For local packages, it is set
32
+ internally through a validator.
22
33
  """
23
34
 
24
- package_name: Optional[str] = None
35
+ package: str
36
+ package_name: str
37
+ python_version: str
38
+ package_extras: Optional[str] = None
39
+ pinned_package_versions: dict[str, str] = Field(default_factory=dict)
40
+ package_version: Optional[str] = None
25
41
  package_path: Optional[Path] = None
26
42
  package_manifest: Optional[ManifestV2] = None
27
43
 
44
+ _pinned_package_versions = validator(
45
+ "pinned_package_versions", allow_reuse=True
46
+ )(valdictkeys("pinned_package_versions"))
47
+ _package_extras = validator("package_extras", allow_reuse=True)(
48
+ valstr("package_extras")
49
+ )
50
+ _python_version = validator("python_version", allow_reuse=True)(
51
+ valstr("python_version")
52
+ )
53
+
28
54
  @property
29
55
  def is_local_package(self) -> bool:
30
56
  return bool(self.package_path)
31
57
 
32
58
  @root_validator(pre=True)
33
- def check_local_package(cls, values):
59
+ def set_package_info(cls, values):
34
60
  """
35
- Checks if package corresponds to an existing path on the filesystem
36
-
37
- In this case, the user is providing directly a package file, rather
38
- than a remote one from PyPI. We set the `package_path` attribute and
39
- get the actual package name and version from the package file name.
61
+ Depending on whether the package is a local wheel file or a PyPI
62
+ package, set some of its metadata.
40
63
  """
41
64
  if "/" in values["package"]:
65
+ # Local package: parse wheel filename
42
66
  package_path = Path(values["package"])
43
67
  if not package_path.is_absolute():
44
68
  raise ValueError("Package path must be absolute")
45
- if package_path.exists():
46
- values["package_path"] = package_path
47
- (
48
- values["package"],
49
- values["version"],
50
- *_,
51
- ) = package_path.name.split("-")
52
- else:
53
- raise ValueError(f"Package {package_path} does not exist.")
69
+ if not package_path.exists():
70
+ logging.warning(
71
+ f"Package {package_path} does not exist locally."
72
+ )
73
+ values["package_path"] = package_path
74
+ wheel_metadata = _parse_wheel_filename(package_path.name)
75
+ values["package_name"] = _normalize_package_name(
76
+ wheel_metadata["distribution"]
77
+ )
78
+ values["package_version"] = wheel_metadata["version"]
79
+ else:
80
+ # Remote package: use `package` as `package_name`
81
+ _package = values["package"]
82
+ if _package.endswith(".whl"):
83
+ raise ValueError(
84
+ f"ERROR: package={_package} ends with '.whl' "
85
+ "but it is not the absolute path to a wheel file."
86
+ )
87
+ values["package_name"] = _normalize_package_name(values["package"])
54
88
  return values
55
89
 
56
90
  @property
@@ -74,10 +108,7 @@ class _TaskCollectPip(TaskCollectPipV2):
74
108
  collection_type = "pip_remote"
75
109
 
76
110
  package_extras = self.package_extras or ""
77
- if self.python_version:
78
- python_version = f"py{self.python_version}"
79
- else:
80
- python_version = "" # FIXME: can we allow this?
111
+ python_version = f"py{self.python_version}"
81
112
 
82
113
  source = ":".join(
83
114
  (
@@ -95,8 +126,6 @@ class _TaskCollectPip(TaskCollectPipV2):
95
126
  Verify that the package has all attributes that are needed to continue
96
127
  with task collection
97
128
  """
98
- if not self.package_name:
99
- raise ValueError("`package_name` attribute is not set")
100
129
  if not self.package_version:
101
130
  raise ValueError("`package_version` attribute is not set")
102
131
  if not self.package_manifest: