lockss-pybasic 0.2.0.dev4__py3-none-any.whl → 0.2.0.dev6__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.
@@ -36,4 +36,4 @@ ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
36
36
  POSSIBILITY OF SUCH DAMAGE.
37
37
  '''.strip()
38
38
 
39
- __version__ = '0.2.0-dev4'
39
+ __version__ = '0.2.0-dev6'
@@ -0,0 +1,402 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Copyright (c) 2000-2025, Board of Trustees of Leland Stanford Jr. University
4
+ #
5
+ # Redistribution and use in source and binary forms, with or without
6
+ # modification, are permitted provided that the following conditions are met:
7
+ #
8
+ # 1. Redistributions of source code must retain the above copyright notice,
9
+ # this list of conditions and the following disclaimer.
10
+ #
11
+ # 2. Redistributions in binary form must reproduce the above copyright notice,
12
+ # this list of conditions and the following disclaimer in the documentation
13
+ # and/or other materials provided with the distribution.
14
+ #
15
+ # 3. Neither the name of the copyright holder nor the names of its contributors
16
+ # may be used to endorse or promote products derived from this software without
17
+ # specific prior written permission.
18
+ #
19
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
20
+ # AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
21
+ # IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
22
+ # ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
23
+ # LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
24
+ # CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
25
+ # SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
26
+ # INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
27
+ # CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
28
+ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
29
+ # POSSIBILITY OF SUCH DAMAGE.
30
+
31
+ """
32
+ LOCKSS AUID Generator
33
+
34
+ Port of the AUID generation logic from org.lockss.plugin.PluginManager
35
+ and related utility classes in the LOCKSS lockss-core library.
36
+ """
37
+
38
+ import urllib.parse
39
+ from typing import Dict, Optional
40
+
41
+
42
+ class InvalidAuidError(ValueError):
43
+ """Raised when an AUID string is malformed or invalid."""
44
+ pass
45
+
46
+
47
+ class AuidGenerator:
48
+ """
49
+ Generator for LOCKSS Archival Unit Identifiers (AUIDs).
50
+
51
+ This class provides static methods for generating and parsing AUIDs,
52
+ which uniquely identify archival units in the LOCKSS system.
53
+
54
+ AUID Format: pluginKey&auKey
55
+ - pluginKey: Plugin class name with dots replaced by pipes
56
+ - auKey: Canonically encoded definitional parameters
57
+
58
+ Example:
59
+ >>> plugin_id = "org.lockss.plugin.simulated.SimulatedPlugin"
60
+ >>> params = {"base_url": "http://example.com/", "year": "2023"}
61
+ >>> auid = AuidGenerator.generate_auid(plugin_id, params)
62
+ >>> print(auid)
63
+ org|lockss|plugin|simulated|SimulatedPlugin&base_url~http%3A%2F%2Fexample.com%2F&year~2023
64
+ """
65
+
66
+ @staticmethod
67
+ def plugin_key_from_id(plugin_id: str) -> str:
68
+ """
69
+ Convert plugin ID to plugin key by replacing dots with pipes.
70
+
71
+ Port of PluginManager.pluginKeyFromId()
72
+
73
+ Args:
74
+ plugin_id: Plugin class name (e.g., "org.lockss.plugin.simulated.SimulatedPlugin")
75
+
76
+ Returns:
77
+ Plugin key (e.g., "org|lockss|plugin|simulated|SimulatedPlugin")
78
+
79
+ Example:
80
+ >>> AuidGenerator.plugin_key_from_id("org.lockss.plugin.TestPlugin")
81
+ 'org|lockss|plugin|TestPlugin'
82
+ """
83
+ if not plugin_id:
84
+ raise ValueError("plugin_id cannot be empty")
85
+ return plugin_id.replace(".", "|")
86
+
87
+ @staticmethod
88
+ def plugin_id_from_key(plugin_key: str) -> str:
89
+ """
90
+ Convert plugin key back to plugin ID by replacing pipes with dots.
91
+
92
+ Args:
93
+ plugin_key: Plugin key (e.g., "org|lockss|plugin|simulated|SimulatedPlugin")
94
+
95
+ Returns:
96
+ Plugin ID (e.g., "org.lockss.plugin.simulated.SimulatedPlugin")
97
+
98
+ Example:
99
+ >>> AuidGenerator.plugin_id_from_key("org|lockss|plugin|TestPlugin")
100
+ 'org.lockss.plugin.TestPlugin'
101
+ """
102
+ if not plugin_key:
103
+ raise ValueError("plugin_key cannot be empty")
104
+ return plugin_key.replace("|", ".")
105
+
106
+ @staticmethod
107
+ def encode_component(s: str) -> str:
108
+ """
109
+ URL-encode a string component for use in AUID.
110
+
111
+ Port of PropKeyEncoder.encode() from lockss-core.
112
+
113
+ This method encodes strings using URL encoding with the following rules:
114
+ - Alphanumeric characters, hyphens, underscores, periods, and asterisks are not encoded
115
+ - Spaces are encoded as '+'
116
+ - All other characters are percent-encoded with uppercase hex digits
117
+
118
+ Args:
119
+ s: String to encode
120
+
121
+ Returns:
122
+ URL-encoded string with spaces as '+' and uppercase hex digits
123
+
124
+ Example:
125
+ >>> AuidGenerator.encode_component("http://example.com/")
126
+ 'http%3A%2F%2Fexample.com%2F'
127
+ """
128
+ if not s:
129
+ return ""
130
+
131
+ # Use quote_plus which converts spaces to '+'
132
+ # safe='-_.~' preserves unreserved characters per RFC 3986
133
+ # We use safe='' to match Java's PropKeyEncoder behavior more closely
134
+ encoded = urllib.parse.quote_plus(s, safe='')
135
+
136
+ # Convert to uppercase hex (urllib uses lowercase by default)
137
+ result = []
138
+ i = 0
139
+ while i < len(encoded):
140
+ if encoded[i] == '%' and i + 2 < len(encoded):
141
+ result.append('%')
142
+ result.append(encoded[i+1].upper())
143
+ result.append(encoded[i+2].upper())
144
+ i += 3
145
+ else:
146
+ result.append(encoded[i])
147
+ i += 1
148
+
149
+ return ''.join(result)
150
+
151
+ @staticmethod
152
+ def decode_component(s: str) -> str:
153
+ """
154
+ URL-decode a string component from an AUID.
155
+
156
+ Args:
157
+ s: URL-encoded string
158
+
159
+ Returns:
160
+ Decoded string
161
+
162
+ Example:
163
+ >>> AuidGenerator.decode_component('http%3A%2F%2Fexample.com%2F')
164
+ 'http://example.com/'
165
+ """
166
+ if not s:
167
+ return ""
168
+ return urllib.parse.unquote_plus(s)
169
+
170
+ @staticmethod
171
+ def props_to_canonical_encoded_string(props: Dict[str, str]) -> str:
172
+ """
173
+ Convert properties dictionary to canonical encoded string.
174
+
175
+ Port of PropUtil.propsToCanonicalEncodedString() from lockss-core.
176
+
177
+ The canonical form is created by:
178
+ 1. Sorting keys alphabetically
179
+ 2. Encoding each key and value
180
+ 3. Joining with '~' between key and value, '&' between pairs
181
+
182
+ Args:
183
+ props: Dictionary of AU definitional parameters
184
+
185
+ Returns:
186
+ Canonical encoded string (e.g., "key1~val1&key2~val2")
187
+
188
+ Example:
189
+ >>> props = {"year": "2023", "base_url": "http://example.com/"}
190
+ >>> AuidGenerator.props_to_canonical_encoded_string(props)
191
+ 'base_url~http%3A%2F%2Fexample.com%2F&year~2023'
192
+ """
193
+ if not props:
194
+ return ""
195
+
196
+ # Sort keys for canonical ordering (case-sensitive, like Java TreeSet)
197
+ sorted_keys = sorted(props.keys())
198
+
199
+ parts = []
200
+ for key in sorted_keys:
201
+ val = props[key]
202
+ if val is None:
203
+ val = ""
204
+ encoded_key = AuidGenerator.encode_component(str(key))
205
+ encoded_val = AuidGenerator.encode_component(str(val))
206
+ parts.append(f"{encoded_key}~{encoded_val}")
207
+
208
+ return "&".join(parts)
209
+
210
+ @staticmethod
211
+ def canonical_encoded_string_to_props(encoded: str) -> Dict[str, str]:
212
+ """
213
+ Decode a canonical encoded string back to properties dictionary.
214
+
215
+ Args:
216
+ encoded: Canonical encoded string (e.g., "key1~val1&key2~val2")
217
+
218
+ Returns:
219
+ Dictionary of decoded parameters
220
+
221
+ Example:
222
+ >>> encoded = 'base_url~http%3A%2F%2Fexample.com%2F&year~2023'
223
+ >>> AuidGenerator.canonical_encoded_string_to_props(encoded)
224
+ {'base_url': 'http://example.com/', 'year': '2023'}
225
+ """
226
+ if not encoded:
227
+ return {}
228
+
229
+ props = {}
230
+ pairs = encoded.split("&")
231
+
232
+ for pair in pairs:
233
+ if "~" not in pair:
234
+ continue
235
+ key_encoded, val_encoded = pair.split("~", 1)
236
+ key = AuidGenerator.decode_component(key_encoded)
237
+ val = AuidGenerator.decode_component(val_encoded)
238
+ props[key] = val
239
+
240
+ return props
241
+
242
+ @staticmethod
243
+ def generate_auid(plugin_id: str, au_def_props: Dict[str, str]) -> str:
244
+ """
245
+ Generate an AUID from plugin ID and definitional properties.
246
+
247
+ Port of PluginManager.generateAuId() from lockss-core.
248
+
249
+ Args:
250
+ plugin_id: Plugin class name (e.g., "org.lockss.plugin.simulated.SimulatedPlugin")
251
+ au_def_props: Dictionary of AU definitional parameters
252
+
253
+ Returns:
254
+ AUID string (e.g., "org|lockss|plugin|simulated|SimulatedPlugin&base_url~...")
255
+
256
+ Raises:
257
+ ValueError: If plugin_id is empty
258
+
259
+ Example:
260
+ >>> plugin_id = "org.lockss.plugin.simulated.SimulatedPlugin"
261
+ >>> params = {"base_url": "http://example.com/", "year": "2023"}
262
+ >>> AuidGenerator.generate_auid(plugin_id, params)
263
+ 'org|lockss|plugin|simulated|SimulatedPlugin&base_url~http%3A%2F%2Fexample.com%2F&year~2023'
264
+ """
265
+ if not plugin_id:
266
+ raise ValueError("plugin_id cannot be empty")
267
+
268
+ plugin_key = AuidGenerator.plugin_key_from_id(plugin_id)
269
+ au_key = AuidGenerator.props_to_canonical_encoded_string(au_def_props)
270
+ return f"{plugin_key}&{au_key}"
271
+
272
+ @staticmethod
273
+ def plugin_key_from_auid(auid: str) -> str:
274
+ """
275
+ Extract plugin key from AUID.
276
+
277
+ Port of PluginManager.pluginKeyFromAuId() from lockss-core.
278
+
279
+ Args:
280
+ auid: AUID string
281
+
282
+ Returns:
283
+ Plugin key portion
284
+
285
+ Raises:
286
+ InvalidAuidError: If AUID format is invalid
287
+
288
+ Example:
289
+ >>> auid = "org|lockss|plugin|TestPlugin&base_url~http%3A%2F%2Fexample.com%2F"
290
+ >>> AuidGenerator.plugin_key_from_auid(auid)
291
+ 'org|lockss|plugin|TestPlugin'
292
+ """
293
+ if not auid:
294
+ raise InvalidAuidError("AUID cannot be empty")
295
+
296
+ idx = auid.find("&")
297
+ if idx < 0:
298
+ raise InvalidAuidError(f"AUID missing '&' separator: {auid}")
299
+
300
+ return auid[:idx]
301
+
302
+ @staticmethod
303
+ def au_key_from_auid(auid: str) -> str:
304
+ """
305
+ Extract AU key from AUID.
306
+
307
+ Port of PluginManager.auKeyFromAuId() from lockss-core.
308
+
309
+ Args:
310
+ auid: AUID string
311
+
312
+ Returns:
313
+ AU key portion (may be empty string)
314
+
315
+ Raises:
316
+ InvalidAuidError: If AUID format is invalid
317
+
318
+ Example:
319
+ >>> auid = "org|lockss|plugin|TestPlugin&base_url~http%3A%2F%2Fexample.com%2F"
320
+ >>> AuidGenerator.au_key_from_auid(auid)
321
+ 'base_url~http%3A%2F%2Fexample.com%2F'
322
+ """
323
+ if not auid:
324
+ raise InvalidAuidError("AUID cannot be empty")
325
+
326
+ idx = auid.find("&")
327
+ if idx < 0:
328
+ raise InvalidAuidError(f"AUID missing '&' separator: {auid}")
329
+
330
+ return auid[idx + 1:]
331
+
332
+ @staticmethod
333
+ def plugin_id_from_auid(auid: str) -> str:
334
+ """
335
+ Extract plugin ID from AUID.
336
+
337
+ Port of PluginManager.pluginIdFromAuId() from lockss-core.
338
+
339
+ Args:
340
+ auid: AUID string
341
+
342
+ Returns:
343
+ Plugin ID (with dots restored)
344
+
345
+ Raises:
346
+ InvalidAuidError: If AUID format is invalid
347
+
348
+ Example:
349
+ >>> auid = "org|lockss|plugin|TestPlugin&base_url~http%3A%2F%2Fexample.com%2F"
350
+ >>> AuidGenerator.plugin_id_from_auid(auid)
351
+ 'org.lockss.plugin.TestPlugin'
352
+ """
353
+ plugin_key = AuidGenerator.plugin_key_from_auid(auid)
354
+ return AuidGenerator.plugin_id_from_key(plugin_key)
355
+
356
+ @staticmethod
357
+ def decode_auid(auid: str) -> Dict[str, str]:
358
+ """
359
+ Decode an AUID into its definitional parameters.
360
+
361
+ This is a convenience method that extracts the AU key and decodes it.
362
+
363
+ Args:
364
+ auid: AUID string
365
+
366
+ Returns:
367
+ Dictionary of decoded AU parameters
368
+
369
+ Raises:
370
+ InvalidAuidError: If AUID format is invalid
371
+
372
+ Example:
373
+ >>> auid = "org|lockss|plugin|TestPlugin&base_url~http%3A%2F%2Fexample.com%2F&year~2023"
374
+ >>> AuidGenerator.decode_auid(auid)
375
+ {'base_url': 'http://example.com/', 'year': '2023'}
376
+ """
377
+ au_key = AuidGenerator.au_key_from_auid(auid)
378
+ return AuidGenerator.canonical_encoded_string_to_props(au_key)
379
+
380
+ @staticmethod
381
+ def validate_auid(auid: str) -> bool:
382
+ """
383
+ Check if an AUID string is valid.
384
+
385
+ Args:
386
+ auid: AUID string to validate
387
+
388
+ Returns:
389
+ True if valid, False otherwise
390
+
391
+ Example:
392
+ >>> AuidGenerator.validate_auid("org|lockss|plugin|TestPlugin&base_url~http%3A%2F%2Fexample.com%2F")
393
+ True
394
+ >>> AuidGenerator.validate_auid("invalid-auid")
395
+ False
396
+ """
397
+ try:
398
+ AuidGenerator.plugin_key_from_auid(auid)
399
+ AuidGenerator.au_key_from_auid(auid)
400
+ return True
401
+ except (InvalidAuidError, ValueError):
402
+ return False
lockss/pybasic/cliutil.py CHANGED
@@ -34,52 +34,47 @@ Command line utilities.
34
34
 
35
35
  from collections.abc import Callable
36
36
  import sys
37
- from typing import Any, ClassVar, Dict, Generic, Optional, TypeVar, TYPE_CHECKING
37
+ from typing import Any, Dict, Generic, Optional, TypeVar
38
38
 
39
- from pydantic.v1 import BaseModel, PrivateAttr, create_model
40
- from pydantic.v1.fields import FieldInfo
39
+ from pydantic.v1 import BaseModel
41
40
  from pydantic_argparse import ArgumentParser
42
41
  from pydantic_argparse.argparse.actions import SubParsersAction
43
42
  from rich_argparse import RichHelpFormatter
44
43
 
45
- if TYPE_CHECKING:
46
- from _typeshed import SupportsWrite as SupportsWriteStr
47
- else:
48
- SupportsWriteStr = Any
49
44
 
45
+ class ActionCommand(Callable, BaseModel):
46
+ """
47
+ Base class for a pydantic-argparse style command.
48
+ """
49
+ pass
50
50
 
51
- class StringCommand(BaseModel):
52
-
53
- def display(self, file: SupportsWriteStr=sys.stdout) -> None:
54
- print(getattr(self, 'display_string'), file=file)
55
51
 
56
- @staticmethod
57
- def make(model_name: str, option_name: str, description: str, display_string: str):
58
- return create_model(model_name,
59
- __base__=StringCommand,
60
- **{option_name: (Optional[bool], FieldInfo(False, description=description)),
61
- display_string: PrivateAttr(display_string)})
52
+ class StringCommand(ActionCommand):
53
+ """
54
+ A pydantic-argparse style command that prints a string.
62
55
 
56
+ Example of use:
63
57
 
64
- class CopyrightCommand(StringCommand):
58
+ .. code-block:: python
65
59
 
66
- @staticmethod
67
- def make(display_string: str):
68
- return StringCommand.make('CopyrightCommand', 'copyright', 'print the copyright and exit', display_string)
60
+ class MyCliModel(BaseModel):
61
+ copyright: Optional[StringCommand.type(my_copyright_string)] = Field(description=COPYRIGHT_DESCRIPTION)
69
62
 
70
-
71
- class LicenseCommand(StringCommand):
63
+ See also the convenience constants ``COPYRIGHT_DESCRIPTION``,
64
+ ``LICENSE_DESCRIPTION``, and ``VERSION_DESCRIPTION``.
65
+ """
72
66
 
73
67
  @staticmethod
74
- def make(display_string: str):
75
- return StringCommand.make('LicenseCommand', 'license', 'print the software license and exit', display_string)
68
+ def type(display_str: str):
69
+ class _StringCommand(StringCommand):
70
+ def __call__(self, file=sys.stdout, **kwargs):
71
+ print(display_str, file=file)
72
+ return _StringCommand
76
73
 
77
74
 
78
- class VersionCommand(StringCommand):
79
-
80
- @staticmethod
81
- def make(display_string: str):
82
- return StringCommand.make('VersionCommand', 'version', 'print the version number and exit', display_string)
75
+ COPYRIGHT_DESCRIPTION = 'print the copyright and exit'
76
+ LICENSE_DESCRIPTION = 'print the software license and exit'
77
+ VERSION_DESCRIPTION = 'print the version number and exit'
83
78
 
84
79
 
85
80
  BaseModelT = TypeVar('BaseModelT', bound=BaseModel)
@@ -134,25 +129,22 @@ class BaseCli(Generic[BaseModelT]):
134
129
 
135
130
  def dispatch(self) -> None:
136
131
  """
137
- Dispatches from the first field ``x_y_z`` in ``self._args`` that is a
132
+ Dispatches from the first field ``x_y_z`` in ``self.args`` that is a
138
133
  command (i.e. whose value derives from ``BaseModel``) to a method
139
134
  called ``_x_y_z``.
140
135
  """
141
- self._dispatch_recursive(self._args, [])
142
-
143
- def _dispatch_recursive(self, base_model: BaseModel, subcommands: list[str]) -> None:
144
- field_names = base_model.__class__.__fields__.keys()
136
+ field_names = self._args.__class__.__fields__.keys()
145
137
  for field_name in field_names:
146
- field_value = getattr(base_model, field_name)
138
+ field_value = getattr(self._args, field_name)
147
139
  if issubclass(type(field_value), BaseModel):
148
- self._dispatch_recursive(field_value, [*subcommands, field_name])
149
- return
150
- func_name = ''.join(f'_{sub}' for sub in subcommands)
151
- func = getattr(self, func_name)
152
- if callable(func):
153
- func(base_model) # FIXME?
140
+ func = getattr(self, f'_{field_name}')
141
+ if callable(func):
142
+ func(field_value)
143
+ else:
144
+ self._parser.exit(1, f'internal error: no _{field_name} callable for the {field_name} command')
145
+ break
154
146
  else:
155
- self._parser.exit(1, f'internal error: no {func_name} callable for the {" ".join(sub for sub in subcommands)} command')
147
+ self._parser.error(f'unknown command; expected one of {", ".join(field_names)}')
156
148
 
157
149
  def _initialize_rich_argparse(self) -> None:
158
150
  """
@@ -184,7 +176,7 @@ class BaseCli(Generic[BaseModelT]):
184
176
  })
185
177
 
186
178
 
187
- def at_most_one_from_enum(model_cls: type[BaseModel], values: Dict[str, Any], enum_cls) -> Dict[str, Any]:
179
+ def at_most_one_from_enum(model_cls, values: Dict[str, Any], enum_cls) -> Dict[str, Any]:
188
180
  """
189
181
  Among the fields of a Pydantic-Argparse model whose ``Field`` definition is
190
182
  tagged with the ``enum`` keyword set to the given ``Enum`` type, ensures
@@ -200,7 +192,7 @@ def at_most_one_from_enum(model_cls: type[BaseModel], values: Dict[str, Any], en
200
192
  enum_names = [field_name for field_name, model_field in model_cls.__fields__.items() if model_field.field_info.extra.get('enum') == enum_cls]
201
193
  ret = [field_name for field_name in enum_names if values.get(field_name)]
202
194
  if (length := len(ret)) > 1:
203
- raise ValueError(f'at most one of {', '.join([option_name(model_cls, enum_name) for enum_name in enum_names])} allowed; got {length} ({', '.join([option_name(enum_name) for enum_name in ret])})')
195
+ raise ValueError(f'at most one of {", ".join([option_name(enum_name) for enum_name in enum_names])} is allowed, got {length} ({", ".join([option_name(enum_name) for enum_name in ret])})')
204
196
  return values
205
197
 
206
198
 
@@ -224,21 +216,21 @@ def get_from_enum(model_inst, enum_cls, default=None):
224
216
  return default
225
217
 
226
218
 
227
- def at_most_one(model_cls: type[BaseModel], values: Dict[str, Any], *names: str):
219
+ def at_most_one(values: Dict[str, Any], *names: str):
228
220
  if (length := _matchy_length(values, *names)) > 1:
229
- raise ValueError(f'at most one of {', '.join([option_name(model_cls, name) for name in names])} allowed; got {length}')
221
+ raise ValueError(f'at most one of {", ".join([option_name(name) for name in names])} is allowed, got {length}')
230
222
  return values
231
223
 
232
224
 
233
- def exactly_one(model_cls: type[BaseModel], values: Dict[str, Any], *names: str):
225
+ def exactly_one(values: Dict[str, Any], *names: str):
234
226
  if (length := _matchy_length(values, *names)) != 1:
235
- raise ValueError(f'exactly one of {', '.join([option_name(model_cls, name) for name in names])} required; got {length}')
227
+ raise ValueError(f'exactly one of {", ".join([option_name(name) for name in names])} is required, got {length}')
236
228
  return values
237
229
 
238
230
 
239
- def one_or_more(model_cls: type[BaseModel], values: Dict[str, Any], *names: str):
231
+ def one_or_more(values: Dict[str, Any], *names: str):
240
232
  if _matchy_length(values, *names) == 0:
241
- raise ValueError(f'one or more of {', '.join([option_name(model_cls, name) for name in names])} required')
233
+ raise ValueError(f'one or more of {", ".join([option_name(name) for name in names])} is required')
242
234
  return values
243
235
 
244
236
 
@@ -247,7 +239,7 @@ def option_name(model_cls: type[BaseModel], name: str) -> str:
247
239
  raise RuntimeError(f'invalid name: {name}')
248
240
  if alias := info.alias:
249
241
  name = alias
250
- return f'{('-' if len(name) == 1 else '--')}{name.replace('_', '-')}'
242
+ return f'{("-" if len(name) == 1 else "--")}{name.replace("_", "-")}'
251
243
 
252
244
 
253
245
  def _matchy_length(values: Dict[str, Any], *names: str) -> int:
@@ -46,7 +46,7 @@ DEFAULT_OUTPUT_FORMAT = 'simple' # from tabulate
46
46
 
47
47
 
48
48
  class OutputFormatOptions(BaseModel):
49
- output_format: Optional[str] = Field(DEFAULT_OUTPUT_FORMAT, description=f'[output] set the output format; choices: {', '.join(OutputFormat.__members__.keys())}')
49
+ output_format: Optional[str] = Field(DEFAULT_OUTPUT_FORMAT, description=f'[output] set the output format; choices: {", ".join(OutputFormat.__members__.keys())}')
50
50
 
51
51
  @validator('output_format')
52
52
  def _validate_output_format(cls, val: str):
@@ -54,4 +54,4 @@ class OutputFormatOptions(BaseModel):
54
54
  _ = OutputFormat[val]
55
55
  return val
56
56
  except KeyError:
57
- raise ValueError(f'must be one of {', '.join(OutputFormat.__members__.keys())}; got {val}')
57
+ raise ValueError(f'must be one of {", ".join(OutputFormat.__members__.keys())}; got {val}')
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: lockss-pybasic
3
- Version: 0.2.0.dev4
3
+ Version: 0.2.0.dev6
4
4
  Summary: Basic Python utilities
5
5
  License: BSD-3-Clause
6
+ License-File: LICENSE
6
7
  Author: Thib Guicherd-Callin
7
8
  Author-email: thib@cs.stanford.edu
8
9
  Maintainer: Thib Guicherd-Callin
@@ -26,8 +27,8 @@ Description-Content-Type: text/x-rst
26
27
  lockss-pybasic
27
28
  ==============
28
29
 
29
- .. |RELEASE| replace:: 0.1.0
30
- .. |RELEASE_DATE| replace:: 2025-07-01
30
+ .. |RELEASE| replace:: 0.1.1
31
+ .. |RELEASE_DATE| replace:: 2025-10-02
31
32
 
32
33
  **Latest release:** |RELEASE| (|RELEASE_DATE|)
33
34
 
@@ -0,0 +1,10 @@
1
+ lockss/pybasic/__init__.py,sha256=36qFj2gOmi2s3h_V6wwYbwaqcj9bSd41ZEes0hGtRFE,1678
2
+ lockss/pybasic/auidutil.py,sha256=Udqompf_F4li3pfzQPgzjfp_A2s_litsZ7uucBQDHEg,13283
3
+ lockss/pybasic/cliutil.py,sha256=tW6ojnvu2B_GNTmWS6ElV2xHJ3E2YGRDnQ4zQ9dU6uY,10533
4
+ lockss/pybasic/errorutil.py,sha256=XI84PScZ851_-gfoazivJ8ceieMYWaxQr7qih5ltga0,1951
5
+ lockss/pybasic/fileutil.py,sha256=BpdoPWL70xYTuhyQRBEurScRVnPQg0mX-XW8yyKPGjw,2958
6
+ lockss/pybasic/outpututil.py,sha256=8naQEZ1rM6vOFNL-9mWoK4dMBWokHmzQ0FkHaz8dyuM,2345
7
+ lockss_pybasic-0.2.0.dev6.dist-info/METADATA,sha256=1R9WSiC304TcXF7EtMxlVqMLFQAU_bvZ9SH1NjMhSmg,4269
8
+ lockss_pybasic-0.2.0.dev6.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
9
+ lockss_pybasic-0.2.0.dev6.dist-info/licenses/LICENSE,sha256=O9ONND4uDxY_jucI4jZDf2liAk05ScEJaYu-Al7EOdQ,1506
10
+ lockss_pybasic-0.2.0.dev6.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.1.3
2
+ Generator: poetry-core 2.2.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,9 +0,0 @@
1
- lockss/pybasic/__init__.py,sha256=9aaQqxV5mxDgrIUSS4yRxzuDC_DZ5l7eUmSMkILhkUs,1678
2
- lockss/pybasic/cliutil.py,sha256=_jHcYa2ccW94fXb4zdxGFyV3BXgcqdlEOYhIbun-DJ4,11236
3
- lockss/pybasic/errorutil.py,sha256=XI84PScZ851_-gfoazivJ8ceieMYWaxQr7qih5ltga0,1951
4
- lockss/pybasic/fileutil.py,sha256=BpdoPWL70xYTuhyQRBEurScRVnPQg0mX-XW8yyKPGjw,2958
5
- lockss/pybasic/outpututil.py,sha256=JyXKXlkaCcCGvonUvmDGRdI6PxwN5t7DPVIk64S8-2g,2345
6
- lockss_pybasic-0.2.0.dev4.dist-info/LICENSE,sha256=O9ONND4uDxY_jucI4jZDf2liAk05ScEJaYu-Al7EOdQ,1506
7
- lockss_pybasic-0.2.0.dev4.dist-info/METADATA,sha256=8dba_8a1HH-v4-ObNFtcYPLb1X37hEkpQ3aMbl0OTgI,4247
8
- lockss_pybasic-0.2.0.dev4.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
9
- lockss_pybasic-0.2.0.dev4.dist-info/RECORD,,