fractal-server 1.4.3a0__py3-none-any.whl → 1.4.3a1__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 (29) hide show
  1. fractal_server/__init__.py +1 -1
  2. fractal_server/app/db/__init__.py +36 -25
  3. fractal_server/app/routes/admin.py +8 -8
  4. fractal_server/app/routes/api/v1/_aux_functions.py +3 -5
  5. fractal_server/app/routes/api/v1/dataset.py +24 -23
  6. fractal_server/app/routes/api/v1/job.py +7 -7
  7. fractal_server/app/routes/api/v1/project.py +14 -19
  8. fractal_server/app/routes/api/v1/task.py +6 -6
  9. fractal_server/app/routes/api/v1/task_collection.py +12 -126
  10. fractal_server/app/routes/api/v1/workflow.py +13 -13
  11. fractal_server/app/routes/api/v1/workflowtask.py +5 -5
  12. fractal_server/app/routes/auth.py +2 -2
  13. fractal_server/app/runner/__init__.py +0 -1
  14. fractal_server/app/schemas/__init__.py +1 -0
  15. fractal_server/app/schemas/applyworkflow.py +5 -9
  16. fractal_server/app/schemas/task_collection.py +2 -10
  17. fractal_server/app/security/__init__.py +3 -3
  18. fractal_server/config.py +14 -0
  19. fractal_server/tasks/_TaskCollectPip.py +103 -0
  20. fractal_server/tasks/__init__.py +3 -1
  21. fractal_server/tasks/background_operations.py +384 -0
  22. fractal_server/tasks/endpoint_operations.py +167 -0
  23. fractal_server/tasks/utils.py +86 -0
  24. {fractal_server-1.4.3a0.dist-info → fractal_server-1.4.3a1.dist-info}/METADATA +1 -1
  25. {fractal_server-1.4.3a0.dist-info → fractal_server-1.4.3a1.dist-info}/RECORD +28 -25
  26. fractal_server/tasks/collection.py +0 -556
  27. {fractal_server-1.4.3a0.dist-info → fractal_server-1.4.3a1.dist-info}/LICENSE +0 -0
  28. {fractal_server-1.4.3a0.dist-info → fractal_server-1.4.3a1.dist-info}/WHEEL +0 -0
  29. {fractal_server-1.4.3a0.dist-info → fractal_server-1.4.3a1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,384 @@
1
+ """
2
+ The main function exported from this module is `background_collect_pip`, which
3
+ is used as a background task for the task-collection endpoint.
4
+ """
5
+ import json
6
+ from pathlib import Path
7
+ from shutil import rmtree as shell_rmtree
8
+ from typing import Optional
9
+
10
+ from fractal_server.app.db import DBSyncSession
11
+ from fractal_server.app.db import get_sync_db
12
+ from fractal_server.app.models import State
13
+ from fractal_server.app.models import Task
14
+ from fractal_server.app.schemas import TaskCollectStatus
15
+ from fractal_server.app.schemas import TaskCreate
16
+ from fractal_server.app.schemas import TaskRead
17
+ from fractal_server.logger import close_logger
18
+ from fractal_server.logger import get_logger
19
+ from fractal_server.logger import set_logger
20
+ from fractal_server.tasks._TaskCollectPip import _TaskCollectPip
21
+ from fractal_server.tasks.utils import _normalize_package_name
22
+ from fractal_server.tasks.utils import get_collection_log
23
+ from fractal_server.tasks.utils import get_collection_path
24
+ from fractal_server.tasks.utils import get_log_path
25
+ from fractal_server.tasks.utils import get_python_interpreter
26
+ from fractal_server.tasks.utils import slugify_task_name
27
+ from fractal_server.utils import execute_command
28
+
29
+
30
+ async def _init_venv(
31
+ *,
32
+ path: Path,
33
+ python_version: Optional[str] = None,
34
+ logger_name: str,
35
+ ) -> Path:
36
+ """
37
+ Set a virtual environment at `path/venv`
38
+
39
+ Args:
40
+ path : Path
41
+ path to directory in which to set up the virtual environment
42
+ python_version : default=None
43
+ Python version the virtual environment will be based upon
44
+
45
+ Returns:
46
+ python_bin : Path
47
+ path to python interpreter
48
+ """
49
+ logger = get_logger(logger_name)
50
+ logger.debug(f"[_init_venv] {path=}")
51
+ interpreter = get_python_interpreter(version=python_version)
52
+ logger.debug(f"[_init_venv] {interpreter=}")
53
+ await execute_command(
54
+ cwd=path,
55
+ command=f"{interpreter} -m venv venv",
56
+ logger_name=logger_name,
57
+ )
58
+ python_bin = path / "venv/bin/python"
59
+ logger.debug(f"[_init_venv] {python_bin=}")
60
+ return python_bin
61
+
62
+
63
+ async def _pip_install(
64
+ venv_path: Path,
65
+ task_pkg: _TaskCollectPip,
66
+ logger_name: str,
67
+ ) -> Path:
68
+ """
69
+ Install package in venv
70
+
71
+ Args:
72
+ venv_path:
73
+ task_pkg:
74
+ logger_name:
75
+
76
+ Returns:
77
+ The location of the package.
78
+ """
79
+
80
+ logger = get_logger(logger_name)
81
+
82
+ pip = venv_path / "venv/bin/pip"
83
+
84
+ extras = f"[{task_pkg.package_extras}]" if task_pkg.package_extras else ""
85
+
86
+ if task_pkg.is_local_package:
87
+ pip_install_str = f"{task_pkg.package_path.as_posix()}{extras}"
88
+ else:
89
+ version_string = (
90
+ f"=={task_pkg.package_version}" if task_pkg.package_version else ""
91
+ )
92
+ pip_install_str = f"{task_pkg.package}{extras}{version_string}"
93
+
94
+ cmd_install = f"{pip} install {pip_install_str}"
95
+ cmd_inspect = f"{pip} show {task_pkg.package}"
96
+
97
+ await execute_command(
98
+ cwd=venv_path,
99
+ command=f"{pip} install --upgrade pip",
100
+ logger_name=logger_name,
101
+ )
102
+ await execute_command(
103
+ cwd=venv_path, command=cmd_install, logger_name=logger_name
104
+ )
105
+ if task_pkg.pinned_package_versions:
106
+ for (
107
+ pinned_pkg_name,
108
+ pinned_pkg_version,
109
+ ) in task_pkg.pinned_package_versions.items():
110
+
111
+ logger.debug(
112
+ "Specific version required: "
113
+ f"{pinned_pkg_name}=={pinned_pkg_version}"
114
+ )
115
+ logger.debug(
116
+ "Preliminary check: verify that "
117
+ f"{pinned_pkg_version} is already installed"
118
+ )
119
+ stdout_inspect = await execute_command(
120
+ cwd=venv_path,
121
+ command=f"{pip} show {pinned_pkg_name}",
122
+ logger_name=logger_name,
123
+ )
124
+ current_version = next(
125
+ line.split()[-1]
126
+ for line in stdout_inspect.split("\n")
127
+ if line.startswith("Version:")
128
+ )
129
+ if current_version != pinned_pkg_version:
130
+ logger.debug(
131
+ f"Currently installed version of {pinned_pkg_name} "
132
+ f"({current_version}) differs from pinned version "
133
+ f"({pinned_pkg_version}); "
134
+ f"install version {pinned_pkg_version}."
135
+ )
136
+ await execute_command(
137
+ cwd=venv_path,
138
+ command=(
139
+ f"{pip} install "
140
+ f"{pinned_pkg_name}=={pinned_pkg_version}"
141
+ ),
142
+ logger_name=logger_name,
143
+ )
144
+ else:
145
+ logger.debug(
146
+ f"Currently installed version of {pinned_pkg_name} "
147
+ f"({current_version}) already matches the pinned version."
148
+ )
149
+
150
+ # Extract package installation path from `pip show`
151
+ stdout_inspect = await execute_command(
152
+ cwd=venv_path, command=cmd_inspect, logger_name=logger_name
153
+ )
154
+
155
+ location = Path(
156
+ next(
157
+ line.split()[-1]
158
+ for line in stdout_inspect.split("\n")
159
+ if line.startswith("Location:")
160
+ )
161
+ )
162
+
163
+ # NOTE
164
+ # https://packaging.python.org/en/latest/specifications/recording-installed-packages/
165
+ # This directory is named as {name}-{version}.dist-info, with name and
166
+ # version fields corresponding to Core metadata specifications. Both
167
+ # fields must be normalized (see the name normalization specification and
168
+ # the version normalization specification), and replace dash (-)
169
+ # characters with underscore (_) characters, so the .dist-info directory
170
+ # always has exactly one dash (-) character in its stem, separating the
171
+ # name and version fields.
172
+ package_root = location / (task_pkg.package.replace("-", "_"))
173
+ logger.debug(f"[_pip install] {location=}")
174
+ logger.debug(f"[_pip install] {task_pkg.package=}")
175
+ logger.debug(f"[_pip install] {package_root=}")
176
+ if not package_root.exists():
177
+ raise RuntimeError(
178
+ "Could not determine package installation location."
179
+ )
180
+ return package_root
181
+
182
+
183
+ async def _create_venv_install_package(
184
+ *,
185
+ task_pkg: _TaskCollectPip,
186
+ path: Path,
187
+ logger_name: str,
188
+ ) -> tuple[Path, Path]:
189
+ """Create venv and install package
190
+
191
+ Args:
192
+ path: the directory in which to create the environment
193
+ task_pkg: object containing the different metadata required to install
194
+ the package
195
+
196
+ Returns:
197
+ python_bin: path to venv's python interpreter
198
+ package_root: the location of the package manifest
199
+ """
200
+
201
+ # Normalize package name
202
+ task_pkg.package_name = _normalize_package_name(task_pkg.package_name)
203
+ task_pkg.package = _normalize_package_name(task_pkg.package)
204
+
205
+ python_bin = await _init_venv(
206
+ path=path,
207
+ python_version=task_pkg.python_version,
208
+ logger_name=logger_name,
209
+ )
210
+ package_root = await _pip_install(
211
+ venv_path=path, task_pkg=task_pkg, logger_name=logger_name
212
+ )
213
+ return python_bin, package_root
214
+
215
+
216
+ async def create_package_environment_pip(
217
+ *,
218
+ task_pkg: _TaskCollectPip,
219
+ venv_path: Path,
220
+ logger_name: str,
221
+ ) -> list[TaskCreate]:
222
+ """
223
+ Create environment, install package, and prepare task list
224
+ """
225
+
226
+ logger = get_logger(logger_name)
227
+
228
+ # Normalize package name
229
+ task_pkg.package_name = _normalize_package_name(task_pkg.package_name)
230
+ task_pkg.package = _normalize_package_name(task_pkg.package)
231
+
232
+ # Only proceed if package, version and manifest attributes are set
233
+ task_pkg.check()
234
+
235
+ try:
236
+
237
+ logger.debug("Creating venv and installing package")
238
+ python_bin, package_root = await _create_venv_install_package(
239
+ path=venv_path,
240
+ task_pkg=task_pkg,
241
+ logger_name=logger_name,
242
+ )
243
+ logger.debug("Venv creation and package installation ended correctly.")
244
+
245
+ # Prepare task_list with appropriate metadata
246
+ logger.debug("Creating task list from manifest")
247
+ task_list = []
248
+ for t in task_pkg.package_manifest.task_list:
249
+ # Fill in attributes for TaskCreate
250
+ task_executable = package_root / t.executable
251
+ cmd = f"{python_bin.as_posix()} {task_executable.as_posix()}"
252
+ task_name_slug = slugify_task_name(t.name)
253
+ task_source = f"{task_pkg.package_source}:{task_name_slug}"
254
+ if not task_executable.exists():
255
+ raise FileNotFoundError(
256
+ f"Cannot find executable `{task_executable}` "
257
+ f"for task `{t.name}`"
258
+ )
259
+ manifest = task_pkg.package_manifest
260
+ if manifest.has_args_schemas:
261
+ additional_attrs = dict(
262
+ args_schema_version=manifest.args_schema_version
263
+ )
264
+ else:
265
+ additional_attrs = {}
266
+ this_task = TaskCreate(
267
+ **t.dict(),
268
+ command=cmd,
269
+ version=task_pkg.package_version,
270
+ **additional_attrs,
271
+ source=task_source,
272
+ )
273
+ task_list.append(this_task)
274
+ logger.debug("Task list created correctly")
275
+ except Exception as e:
276
+ logger.error("Task manifest loading failed")
277
+ raise e
278
+ return task_list
279
+
280
+
281
+ async def _insert_tasks(
282
+ task_list: list[TaskCreate],
283
+ db: DBSyncSession,
284
+ ) -> list[Task]:
285
+ """
286
+ Insert tasks into database
287
+ """
288
+ task_db_list = [Task(**t.dict()) for t in task_list]
289
+ db.add_all(task_db_list)
290
+ db.commit()
291
+ for t in task_db_list:
292
+ db.refresh(t)
293
+ db.close()
294
+ return task_db_list
295
+
296
+
297
+ async def background_collect_pip(
298
+ state_id: int,
299
+ venv_path: Path,
300
+ task_pkg: _TaskCollectPip,
301
+ ) -> None:
302
+ """
303
+ Install package and collect tasks
304
+
305
+ Install a python package and collect the tasks it provides according to
306
+ the manifest.
307
+
308
+ In case of error, copy the log into the state and delete the package
309
+ directory.
310
+ """
311
+ logger_name = task_pkg.package.replace("/", "_")
312
+ logger = set_logger(
313
+ logger_name=logger_name,
314
+ log_file_path=get_log_path(venv_path),
315
+ )
316
+ logger.debug("Start background task collection")
317
+ for key, value in task_pkg.dict(exclude={"package_manifest"}).items():
318
+ logger.debug(f"{key}: {value}")
319
+
320
+ with next(get_sync_db()) as db:
321
+ state: State = db.get(State, state_id)
322
+ data = TaskCollectStatus(**state.data)
323
+ data.info = None
324
+
325
+ try:
326
+ # install
327
+ logger.debug("Task-collection status: installing")
328
+ data.status = "installing"
329
+
330
+ state.data = data.sanitised_dict()
331
+ db.merge(state)
332
+ db.commit()
333
+ task_list = await create_package_environment_pip(
334
+ venv_path=venv_path,
335
+ task_pkg=task_pkg,
336
+ logger_name=logger_name,
337
+ )
338
+
339
+ # collect
340
+ logger.debug("Task-collection status: collecting")
341
+ data.status = "collecting"
342
+ state.data = data.sanitised_dict()
343
+ db.merge(state)
344
+ db.commit()
345
+ tasks = await _insert_tasks(task_list=task_list, db=db)
346
+
347
+ # finalise
348
+ logger.debug("Task-collection status: finalising")
349
+ collection_path = get_collection_path(venv_path)
350
+ data.task_list = [TaskRead(**task.model_dump()) for task in tasks]
351
+ with collection_path.open("w") as f:
352
+ json.dump(data.sanitised_dict(), f)
353
+
354
+ # Update DB
355
+ data.status = "OK"
356
+ data.log = get_collection_log(venv_path)
357
+ state.data = data.sanitised_dict()
358
+ db.add(state)
359
+ db.merge(state)
360
+ db.commit()
361
+
362
+ # Write last logs to file
363
+ logger.debug("Task-collection status: OK")
364
+ logger.info("Background task collection completed successfully")
365
+ close_logger(logger)
366
+ db.close()
367
+
368
+ except Exception as e:
369
+ # Write last logs to file
370
+ logger.debug("Task-collection status: fail")
371
+ logger.info(f"Background collection failed. Original error: {e}")
372
+ close_logger(logger)
373
+
374
+ # Update db
375
+ data.status = "fail"
376
+ data.info = f"Original error: {e}"
377
+ data.log = get_collection_log(venv_path)
378
+ state.data = data.sanitised_dict()
379
+ db.merge(state)
380
+ db.commit()
381
+ db.close()
382
+
383
+ # Delete corrupted package dir
384
+ shell_rmtree(venv_path)
@@ -0,0 +1,167 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Optional
4
+ from typing import Union
5
+ from zipfile import ZipFile
6
+
7
+ from fractal_server.app.schemas import ManifestV1
8
+ from fractal_server.app.schemas import TaskCollectStatus
9
+ from fractal_server.config import get_settings
10
+ from fractal_server.logger import get_logger
11
+ from fractal_server.syringe import Inject
12
+ from fractal_server.tasks._TaskCollectPip import _TaskCollectPip
13
+ from fractal_server.tasks.utils import _normalize_package_name
14
+ from fractal_server.tasks.utils import get_absolute_venv_path
15
+ from fractal_server.tasks.utils import get_collection_path
16
+ from fractal_server.tasks.utils import get_python_interpreter
17
+ from fractal_server.utils import execute_command
18
+
19
+
20
+ FRACTAL_PUBLIC_TASK_SUBDIR = ".fractal"
21
+
22
+
23
+ def get_collection_data(venv_path: Path) -> TaskCollectStatus:
24
+ package_path = get_absolute_venv_path(venv_path)
25
+ collection_path = get_collection_path(package_path)
26
+ with collection_path.open() as f:
27
+ data = json.load(f)
28
+ return TaskCollectStatus(**data)
29
+
30
+
31
+ async def download_package(
32
+ *,
33
+ task_pkg: _TaskCollectPip,
34
+ dest: Union[str, Path],
35
+ ):
36
+ """
37
+ Download package to destination
38
+ """
39
+ interpreter = get_python_interpreter(version=task_pkg.python_version)
40
+ pip = f"{interpreter} -m pip"
41
+ version = (
42
+ f"=={task_pkg.package_version}" if task_pkg.package_version else ""
43
+ )
44
+ package_and_version = f"{task_pkg.package}{version}"
45
+ cmd = f"{pip} download --no-deps {package_and_version} -d {dest}"
46
+ stdout = await execute_command(command=cmd, cwd=Path("."))
47
+ pkg_file = next(
48
+ line.split()[-1] for line in stdout.split("\n") if "Saved" in line
49
+ )
50
+ return Path(pkg_file)
51
+
52
+
53
+ def _load_manifest_from_wheel(
54
+ path: Path, wheel: ZipFile, logger_name: Optional[str] = None
55
+ ) -> ManifestV1:
56
+ logger = get_logger(logger_name)
57
+ namelist = wheel.namelist()
58
+ try:
59
+ manifest = next(
60
+ name for name in namelist if "__FRACTAL_MANIFEST__.json" in name
61
+ )
62
+ except StopIteration:
63
+ msg = f"{path.as_posix()} does not include __FRACTAL_MANIFEST__.json"
64
+ logger.error(msg)
65
+ raise ValueError(msg)
66
+ with wheel.open(manifest) as manifest_fd:
67
+ manifest_dict = json.load(manifest_fd)
68
+ manifest_version = str(manifest_dict["manifest_version"])
69
+ if manifest_version == "1":
70
+ pkg_manifest = ManifestV1(**manifest_dict)
71
+ return pkg_manifest
72
+ else:
73
+ msg = f"Manifest version {manifest_version=} not supported"
74
+ logger.error(msg)
75
+ raise ValueError(msg)
76
+
77
+
78
+ def inspect_package(path: Path, logger_name: Optional[str] = None) -> dict:
79
+ """
80
+ Inspect task package to extract version, name and manifest
81
+
82
+ Note that this only works with wheel files, which have a well-defined
83
+ dist-info section. If we need to generalize to to tar.gz archives, we would
84
+ need to go and look for `PKG-INFO`.
85
+
86
+ Note: package name is normalized via `_normalize_package_name`.
87
+
88
+ Args:
89
+ path: Path
90
+ the path in which the package is saved
91
+
92
+ Returns:
93
+ version_manifest: A dictionary containing `version`, the version of the
94
+ pacakge, and `manifest`, the Fractal manifest object relative to the
95
+ tasks.
96
+ """
97
+
98
+ logger = get_logger(logger_name)
99
+
100
+ if not path.as_posix().endswith(".whl"):
101
+ raise ValueError(
102
+ f"Only wheel packages are supported, given {path.as_posix()}."
103
+ )
104
+
105
+ with ZipFile(path) as wheel:
106
+ namelist = wheel.namelist()
107
+
108
+ # Read and validate task manifest
109
+ logger.debug(f"Now reading manifest for {path.as_posix()}")
110
+ pkg_manifest = _load_manifest_from_wheel(
111
+ path, wheel, logger_name=logger_name
112
+ )
113
+ logger.debug("Manifest read correctly.")
114
+
115
+ # Read package name and version from *.dist-info/METADATA
116
+ logger.debug(
117
+ f"Now reading package name and version for {path.as_posix()}"
118
+ )
119
+ metadata = next(
120
+ name for name in namelist if "dist-info/METADATA" in name
121
+ )
122
+ with wheel.open(metadata) as metadata_fd:
123
+ meta = metadata_fd.read().decode("utf-8")
124
+ pkg_name = next(
125
+ line.split()[-1]
126
+ for line in meta.splitlines()
127
+ if line.startswith("Name")
128
+ )
129
+ pkg_version = next(
130
+ line.split()[-1]
131
+ for line in meta.splitlines()
132
+ if line.startswith("Version")
133
+ )
134
+ logger.debug("Package name and version read correctly.")
135
+
136
+ # Normalize package name:
137
+ pkg_name = _normalize_package_name(pkg_name)
138
+
139
+ info = dict(
140
+ pkg_name=pkg_name,
141
+ pkg_version=pkg_version,
142
+ pkg_manifest=pkg_manifest,
143
+ )
144
+ return info
145
+
146
+
147
+ def create_package_dir_pip(
148
+ *,
149
+ task_pkg: _TaskCollectPip,
150
+ create: bool = True,
151
+ ) -> Path:
152
+ """
153
+ Create venv folder for a task package and return corresponding Path object
154
+ """
155
+ settings = Inject(get_settings)
156
+ user = FRACTAL_PUBLIC_TASK_SUBDIR
157
+ if task_pkg.package_version is None:
158
+ raise ValueError(
159
+ f"Cannot create venv folder for package `{task_pkg.package}` "
160
+ "with `version=None`."
161
+ )
162
+ normalized_package = _normalize_package_name(task_pkg.package)
163
+ package_dir = f"{normalized_package}{task_pkg.package_version}"
164
+ venv_path = settings.FRACTAL_TASKS_DIR / user / package_dir
165
+ if create:
166
+ venv_path.mkdir(exist_ok=False, parents=True)
167
+ return venv_path
@@ -0,0 +1,86 @@
1
+ import re
2
+ import shutil
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from fractal_server.config import get_settings
8
+ from fractal_server.syringe import Inject
9
+
10
+ COLLECTION_FILENAME = "collection.json"
11
+ COLLECTION_LOG_FILENAME = "collection.log"
12
+
13
+
14
+ def get_python_interpreter(version: Optional[str] = None) -> str:
15
+ """
16
+ Return the path to the python interpreter
17
+
18
+ Args:
19
+ version: Python version
20
+
21
+ Raises:
22
+ ValueError: If the python version requested is not available on the
23
+ host.
24
+
25
+ Returns:
26
+ interpreter: string representing the python executable or its path
27
+ """
28
+ if version:
29
+ interpreter = shutil.which(f"python{version}")
30
+ if not interpreter:
31
+ raise ValueError(
32
+ f"Python version {version} not available on host."
33
+ )
34
+ else:
35
+ interpreter = sys.executable
36
+
37
+ return interpreter
38
+
39
+
40
+ def slugify_task_name(task_name: str) -> str:
41
+ return task_name.replace(" ", "_").lower()
42
+
43
+
44
+ def get_absolute_venv_path(venv_path: Path) -> Path:
45
+ """
46
+ If a path is not absolute, make it a relative path of FRACTAL_TASKS_DIR.
47
+ """
48
+ if venv_path.is_absolute():
49
+ package_path = venv_path
50
+ else:
51
+ settings = Inject(get_settings)
52
+ package_path = settings.FRACTAL_TASKS_DIR / venv_path
53
+ return package_path
54
+
55
+
56
+ def get_collection_path(base: Path) -> Path:
57
+ return base / COLLECTION_FILENAME
58
+
59
+
60
+ def get_log_path(base: Path) -> Path:
61
+ return base / COLLECTION_LOG_FILENAME
62
+
63
+
64
+ def get_collection_log(venv_path: Path) -> str:
65
+ package_path = get_absolute_venv_path(venv_path)
66
+ log_path = get_log_path(package_path)
67
+ log = log_path.open().read()
68
+ return log
69
+
70
+
71
+ def _normalize_package_name(name: str) -> str:
72
+ """
73
+ Implement PyPa specifications for package-name normalization
74
+
75
+ The name should be lowercased with all runs of the characters `.`, `-`,
76
+ or `_` replaced with a single `-` character. This can be implemented in
77
+ Python with the re module.
78
+ (https://packaging.python.org/en/latest/specifications/name-normalization)
79
+
80
+ Args:
81
+ name: The non-normalized package name.
82
+
83
+ Returns:
84
+ The normalized package name.
85
+ """
86
+ return re.sub(r"[-_.]+", "-", name).lower()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fractal-server
3
- Version: 1.4.3a0
3
+ Version: 1.4.3a1
4
4
  Summary: Server component of the Fractal analytics platform
5
5
  Home-page: https://github.com/fractal-analytics-platform/fractal-server
6
6
  License: BSD-3-Clause