album-solution-api 0.7.2__tar.gz → 0.7.3__tar.gz

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 (26) hide show
  1. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/PKG-INFO +2 -1
  2. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/pyproject.toml +2 -1
  3. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/api.py +80 -4
  4. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/core/model/solution_script.py +52 -13
  5. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/core/schema/solution_schema.json +1 -1
  6. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album_solution_api.egg-info/PKG-INFO +2 -1
  7. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/LICENSE +0 -0
  8. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/README.md +0 -0
  9. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/setup.cfg +0 -0
  10. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/__init__.py +0 -0
  11. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/__init__.py +0 -0
  12. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/album_logging.py +0 -0
  13. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/core/__init__.py +0 -0
  14. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/core/api/__init__.py +0 -0
  15. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/core/api/model/__init__.py +0 -0
  16. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/core/api/model/coordinates.py +0 -0
  17. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/core/api/model/solution.py +0 -0
  18. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/core/default_values_runner.py +0 -0
  19. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/core/model/__init__.py +0 -0
  20. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/core/model/coordinates.py +0 -0
  21. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/core/model/solution.py +0 -0
  22. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album/runner/core/schema/__init__.py +0 -0
  23. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album_solution_api.egg-info/SOURCES.txt +0 -0
  24. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album_solution_api.egg-info/dependency_links.txt +0 -0
  25. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/src/album_solution_api.egg-info/top_level.txt +0 -0
  26. {album_solution_api-0.7.2 → album_solution_api-0.7.3}/tests/test_unit_common.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: album-solution-api
3
- Version: 0.7.2
3
+ Version: 0.7.3
4
4
  Summary: Album is a framework for unifying computational tools across frameworks, languages, and environments.
5
5
  Author-email: Jan Philipp Albrecht <album@jpalbrecht.de>, Kyle Harrington <album@kyleharrington.com>, Deborah Schmidt <mail@frauzufall.de>
6
6
  Maintainer-email: Jan Philipp Albrecht <album@jpalbrecht.de>, Kyle Harrington <album@kyleharrington.com>, Deborah Schmidt <mail@frauzufall.de>
@@ -23,6 +23,7 @@ Classifier: Programming Language :: Python :: 3.10
23
23
  Classifier: Programming Language :: Python :: 3.11
24
24
  Classifier: Programming Language :: Python :: 3.12
25
25
  Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Programming Language :: Python :: 3.14
26
27
  Classifier: Programming Language :: Python :: Implementation :: PyPy
27
28
  Classifier: Topic :: Utilities
28
29
  Requires-Python: >=3.6
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "album-solution-api"
7
- version = "0.7.2"
7
+ version = "0.7.3"
8
8
  authors = [
9
9
  {name = "Jan Philipp Albrecht", email = "album@jpalbrecht.de"},
10
10
  {name = "Kyle Harrington", email = "album@kyleharrington.com"},
@@ -33,6 +33,7 @@ classifiers = [
33
33
  "Programming Language :: Python :: 3.11",
34
34
  "Programming Language :: Python :: 3.12",
35
35
  "Programming Language :: Python :: 3.13",
36
+ "Programming Language :: Python :: 3.14",
36
37
  "Programming Language :: Python :: Implementation :: PyPy",
37
38
  "Topic :: Utilities"
38
39
  ]
@@ -1,10 +1,11 @@
1
1
  """This module provides the API for the album runner."""
2
+ import hashlib
2
3
  import json
3
4
  import os
4
5
  import sys
5
6
  import tarfile
6
7
  from pathlib import Path
7
- from typing import Any, Dict, Optional
8
+ from typing import Any, Dict, Optional, Tuple, Union
8
9
  from urllib.request import urlretrieve
9
10
 
10
11
  from album.runner.album_logging import configure_logging, get_active_logger, to_loglevel
@@ -17,6 +18,8 @@ from album.runner.core.model.solution_script import SolutionScript
17
18
  def download_if_not_exists(url, file_name):
18
19
  """Download resource if not already cached and returns local resource path.
19
20
 
21
+ Its content is not checked, download_verified checks it against a known hash.
22
+
20
23
  Args:
21
24
  url: The URL of the download.
22
25
  file_name: The local filename of the download.
@@ -40,6 +43,76 @@ def download_if_not_exists(url, file_name):
40
43
  return download_path
41
44
 
42
45
 
46
+ def download_verified(url: str, file_name: Union[str, Path], known_hash: str) -> Path:
47
+ """Download a file into the cache of the solution if it is not there, and check its content.
48
+
49
+ The content has to have the hash known_hash, written as in the resource file of a solution:
50
+ ``"sha256:<hex digest>"``, another algorithm of hashlib instead of sha256 (e.g. ``"md5:<hex digest>"``), or the
51
+ hex digest alone for sha256. A file in the cache with another content is downloaded again; a download with
52
+ another content is removed.
53
+
54
+ Args:
55
+ url: The URL of the download.
56
+ file_name: The local filename of the download, in the cache of the solution.
57
+ known_hash: The hash of the content.
58
+
59
+ Returns: The path to the file.
60
+
61
+ Raises:
62
+ ValueError: If known_hash names an algorithm hashlib does not know, or the download has another hash.
63
+
64
+ """
65
+ algorithm, digest = _split_hash(known_hash)
66
+ download_path = get_cache_path().joinpath(file_name)
67
+
68
+ if download_path.exists():
69
+ if _file_hash(download_path, algorithm) == digest:
70
+ get_active_logger().info(f"Found cache of {url}: {download_path}...")
71
+ return download_path
72
+ get_active_logger().info(
73
+ f"The cache of {url} has another content: {download_path}..."
74
+ )
75
+ download_path.parent.mkdir(parents=True, exist_ok=True)
76
+
77
+ get_active_logger().info(f"Downloading {url} to {download_path}...")
78
+
79
+ # downloaded next to the file, which it replaces only with the right content
80
+ part_path = download_path.with_name(download_path.name + ".part")
81
+ try:
82
+ urlretrieve(url, part_path)
83
+ download_hash = _file_hash(part_path, algorithm)
84
+ if download_hash == digest:
85
+ os.replace(part_path, download_path)
86
+ return download_path
87
+ finally:
88
+ if part_path.exists():
89
+ part_path.unlink()
90
+ raise ValueError(
91
+ f"The download of {url} has the {algorithm} hash {download_hash}, not {digest}."
92
+ )
93
+
94
+
95
+ def _split_hash(known_hash: str) -> Tuple[str, str]:
96
+ """Return the algorithm and the hex digest of a hash, sha256 if it names no algorithm."""
97
+ algorithm, _, digest = known_hash.rpartition(":")
98
+ algorithm = (algorithm or "sha256").lower()
99
+ # a shake algorithm needs the length of its digest
100
+ if algorithm not in hashlib.algorithms_guaranteed or algorithm.startswith("shake_"):
101
+ raise ValueError(
102
+ f'The hash "{known_hash}" names an algorithm hashlib does not know.'
103
+ )
104
+ return algorithm, digest.lower()
105
+
106
+
107
+ def _file_hash(path: Path, algorithm: str) -> str:
108
+ """Return the hex digest of the content of a file."""
109
+ file_hash = hashlib.new(algorithm)
110
+ with open(path, "rb") as file:
111
+ for chunk in iter(lambda: file.read(1024 * 1024), b""):
112
+ file_hash.update(chunk)
113
+ return file_hash.hexdigest()
114
+
115
+
43
116
  def extract_tar(in_tar, out_dir):
44
117
  """Extract a TAR file to a directory.
45
118
 
@@ -103,11 +176,14 @@ def get_cache_path() -> Path:
103
176
 
104
177
 
105
178
  def get_resources_dict(os_specific: bool = False) -> Dict[str, Any]:
106
- """Return the resource files from a json as a dictionary and adds the download paths to the dictionary.
179
+ """Return the resources of the solution, as declared in its resource file, with the path of each file.
180
+
181
+ The dictionary has the form ``{"resources": {<key>: {"name": ..., "path": ..., ...}}}``. A resource
182
+ meant for another OS is not downloaded, its path is None.
107
183
 
108
184
  Args:
109
- os_specific: If True, all resources are returned, otherwise only the installed resources
110
- meant for the current OS are returned.
185
+ os_specific: If True, only the resources meant for the current OS are returned, otherwise all
186
+ resources are returned.
111
187
  """
112
188
  resources_dict = {}
113
189
  active_solution = get_active_solution()
@@ -33,9 +33,24 @@ class SolutionScript:
33
33
  SolutionScript.api_access(solution, package_path, installation_base_path, environment_path)
34
34
  parser = None
35
35
  if solution.setup().args:
36
- append_arguments = (goal == Solution.Action.RUN) or (goal == Solution.Action.TEST)
37
- if append_arguments:
36
+ if goal == Solution.Action.RUN:
38
37
  parser = SolutionScript.append_arguments(solution)
38
+ elif goal == Solution.Action.TEST:
39
+ # For the test goal the arguments come from the command line and from
40
+ # pre_test(), which has not run at this point. Enforcing `required` here
41
+ # made argparse exit with "the following arguments are required: ..." for
42
+ # every solution that marks an argument required -- before pre_test() had
43
+ # any chance to provide it. Solutions worked around this by declaring
44
+ # required=False and writing "(required)" into the description, which hid
45
+ # the requirement from every consumer of the metadata. So parse the command
46
+ # line without enforcing `required`: pre_test() can read get_args() and
47
+ # adapt what the caller passed, and an invalid command line still fails
48
+ # before pre_test() runs. The parse that enforces `required` happens below,
49
+ # after pre_test()'s values are on the command line.
50
+ parser = SolutionScript.build_parser(solution)
51
+ if parser is not None:
52
+ lenient_parser = SolutionScript.build_parser(solution, enforce_required=False)
53
+ solution.set_args(lenient_parser.parse_args())
39
54
  if goal == Solution.Action.INSTALL:
40
55
  solution.setup().install()
41
56
  if goal == Solution.Action.UNINSTALL:
@@ -51,8 +66,10 @@ class SolutionScript:
51
66
  d = {}
52
67
  sys.argv = sys.argv + ["=".join([c, d[c]]) for c in d]
53
68
 
54
- # parse args again after pre_test() routine if necessary.
55
- if parser and "args" in solution.setup().keys():
69
+ # The parse that counts for the test goal: pre_test()'s arguments now follow
70
+ # the caller's, so they win, and a required argument pre_test() supplies is
71
+ # satisfied rather than fatal.
72
+ if parser is not None:
56
73
  args = parser.parse_args()
57
74
  solution.set_args(args)
58
75
 
@@ -111,16 +128,32 @@ class SolutionScript:
111
128
  raise ArgumentError(argument=args, message=message)
112
129
 
113
130
  @staticmethod
114
- def _handle_args_list(solution: ISolution):
131
+ def build_parser(solution: ISolution, enforce_required=True):
132
+ """The argument parser for a solution's declared arguments, built but not yet parsed.
133
+
134
+ Returns None when the solution declares its arguments as a string ('pass-through'),
135
+ which is validated the same way append_arguments() validates it. Separated from the
136
+ parse so that the test goal can enforce required arguments only after pre_test() has
137
+ run. With enforce_required=False, no argument is marked required: the test goal parses
138
+ the command line that way for pre_test(), which may still supply them.
139
+ """
140
+ if isinstance(solution.setup().args, str):
141
+ SolutionScript._handle_args_string(solution.setup().args)
142
+ return None
115
143
  parser = argparse.ArgumentParser(description='album run %s' % solution.setup().name)
116
144
  for arg in solution.setup().args:
117
- SolutionScript._add_parser_argument(solution, parser, arg)
145
+ SolutionScript._add_parser_argument(solution, parser, arg, enforce_required)
146
+ return parser
147
+
148
+ @staticmethod
149
+ def _handle_args_list(solution: ISolution):
150
+ parser = SolutionScript.build_parser(solution)
118
151
  args = parser.parse_args()
119
152
  solution.set_args(args)
120
153
  return parser
121
154
 
122
155
  @staticmethod
123
- def _add_parser_argument(solution, parser, arg):
156
+ def _add_parser_argument(solution, parser, arg, enforce_required=True):
124
157
  keys = arg.keys()
125
158
 
126
159
  if 'default' in keys and 'action' in keys:
@@ -135,8 +168,8 @@ class SolutionScript:
135
168
  if 'description' in keys:
136
169
  args['help'] = arg['description']
137
170
  if 'type' in keys:
138
- args['type'] = SolutionScript._parse_type(arg['type'])
139
- if 'required' in keys:
171
+ args['type'] = SolutionScript._parse_type(arg['type'], arg['name'])
172
+ if 'required' in keys and enforce_required:
140
173
  args['required'] = arg['required']
141
174
  parser.add_argument('--%s' % arg['name'], **args)
142
175
 
@@ -156,16 +189,22 @@ class SolutionScript:
156
189
  raise ValueError("invalid truth value %r" % (val,))
157
190
 
158
191
  @staticmethod
159
- def _parse_type(type_str):
160
- if type_str == 'string':
192
+ def _parse_type(type_str, arg_name):
193
+ # str, int and bool are the Python names of string, integer and boolean
194
+ if type_str in ('string', 'str'):
161
195
  return str
162
196
  if type_str == 'file':
163
197
  return Path
164
198
  if type_str == 'directory':
165
199
  return Path
166
- if type_str == 'integer':
200
+ if type_str in ('integer', 'int'):
167
201
  return int
168
202
  if type_str == 'float':
169
203
  return float
170
- if type_str == 'boolean':
204
+ if type_str in ('boolean', 'bool'):
171
205
  return SolutionScript.strtobool
206
+ # without a type function argparse would pass the value on as text
207
+ raise ValueError(
208
+ 'The argument %s has the unknown type %r. Known types are str, int, float, bool, file and directory, '
209
+ 'and string, integer and boolean.' % (arg_name, type_str)
210
+ )
@@ -64,7 +64,7 @@
64
64
  "type": {
65
65
  "title": "type",
66
66
  "type": "string",
67
- "enum": ["string", "file", "directory", "boolean", "integer", "float"]
67
+ "enum": ["string", "file", "directory", "boolean", "integer", "float", "str", "bool", "int"]
68
68
  },
69
69
  "default": {
70
70
  "title": "default"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: album-solution-api
3
- Version: 0.7.2
3
+ Version: 0.7.3
4
4
  Summary: Album is a framework for unifying computational tools across frameworks, languages, and environments.
5
5
  Author-email: Jan Philipp Albrecht <album@jpalbrecht.de>, Kyle Harrington <album@kyleharrington.com>, Deborah Schmidt <mail@frauzufall.de>
6
6
  Maintainer-email: Jan Philipp Albrecht <album@jpalbrecht.de>, Kyle Harrington <album@kyleharrington.com>, Deborah Schmidt <mail@frauzufall.de>
@@ -23,6 +23,7 @@ Classifier: Programming Language :: Python :: 3.10
23
23
  Classifier: Programming Language :: Python :: 3.11
24
24
  Classifier: Programming Language :: Python :: 3.12
25
25
  Classifier: Programming Language :: Python :: 3.13
26
+ Classifier: Programming Language :: Python :: 3.14
26
27
  Classifier: Programming Language :: Python :: Implementation :: PyPy
27
28
  Classifier: Topic :: Utilities
28
29
  Requires-Python: >=3.6