lockss-pybasic 0.1.0.dev23__py3-none-any.whl → 0.2.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.
@@ -5,7 +5,7 @@ Basic Python utilities.
5
5
  """
6
6
 
7
7
  __copyright__ = '''
8
- Copyright (c) 2000-2025, Board of Trustees of Leland Stanford Jr. University
8
+ Copyright (c) 2000-2026, Board of Trustees of Leland Stanford Jr. University
9
9
  '''.strip()
10
10
 
11
11
  __license__ = __copyright__ + '\n\n' + '''
@@ -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.1.0-dev23'
39
+ __version__ = '0.2.0'
@@ -0,0 +1,417 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Copyright (c) 2000-2026, 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
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%2Ecom%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
+ # Characters that don't need encoding - matches Java PropKeyEncoder exactly
107
+ # See lockss-core PropKeyEncoder.java lines 46-62
108
+ _DONT_NEED_ENCODING = set(
109
+ 'abcdefghijklmnopqrstuvwxyz'
110
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
111
+ '0123456789'
112
+ ' ' # Space is converted to '+' in encode()
113
+ '-'
114
+ '_'
115
+ '*'
116
+ )
117
+
118
+ @staticmethod
119
+ def encode_component(s: str) -> str:
120
+ """
121
+ URL-encode a string component for use in AUID.
122
+
123
+ Port of PropKeyEncoder.encode() from lockss-core.
124
+
125
+ This method encodes strings using URL encoding with the following rules:
126
+ - Alphanumeric characters (a-z, A-Z, 0-9) are not encoded
127
+ - Hyphens (-), underscores (_), and asterisks (*) are not encoded
128
+ - Spaces are encoded as '+'
129
+ - All other characters (including periods) are percent-encoded with uppercase hex digits
130
+
131
+ Note: This differs from standard URL encoding (RFC 3986) which treats
132
+ periods (.) as unreserved. Java's PropKeyEncoder encodes periods.
133
+
134
+ Args:
135
+ s: String to encode
136
+
137
+ Returns:
138
+ URL-encoded string with spaces as '+' and uppercase hex digits
139
+
140
+ Example:
141
+ >>> AuidGenerator.encode_component("http://example.com/")
142
+ 'http%3A%2F%2Fexample%2Ecom%2F'
143
+ """
144
+ if not s:
145
+ return ""
146
+
147
+ result = []
148
+ # Encode string to UTF-8 bytes, matching Java's OutputStreamWriter behavior
149
+ for char in s:
150
+ if char in AuidGenerator._DONT_NEED_ENCODING:
151
+ if char == ' ':
152
+ result.append('+')
153
+ else:
154
+ result.append(char)
155
+ else:
156
+ # Encode character to UTF-8 bytes and percent-encode each byte
157
+ char_bytes = char.encode('utf-8')
158
+ for byte in char_bytes:
159
+ result.append('%')
160
+ result.append(format(byte, '02X'))
161
+
162
+ return ''.join(result)
163
+
164
+ @staticmethod
165
+ def decode_component(s: str) -> str:
166
+ """
167
+ URL-decode a string component from an AUID.
168
+
169
+ Args:
170
+ s: URL-encoded string
171
+
172
+ Returns:
173
+ Decoded string
174
+
175
+ Example:
176
+ >>> AuidGenerator.decode_component('http%3A%2F%2Fexample.com%2F')
177
+ 'http://example.com/'
178
+ """
179
+ if not s:
180
+ return ""
181
+ return urllib.parse.unquote_plus(s, errors="strict")
182
+
183
+ @staticmethod
184
+ def props_to_canonical_encoded_string(props: Dict[str, str]) -> str:
185
+ """
186
+ Convert properties dictionary to canonical encoded string.
187
+
188
+ Port of PropUtil.propsToCanonicalEncodedString() from lockss-core.
189
+
190
+ The canonical form is created by:
191
+ 1. Sorting keys alphabetically
192
+ 2. Encoding each key and value
193
+ 3. Joining with '~' between key and value, '&' between pairs
194
+
195
+ Args:
196
+ props: Dictionary of AU definitional parameters
197
+
198
+ Returns:
199
+ Canonical encoded string (e.g., "key1~val1&key2~val2")
200
+
201
+ Example:
202
+ >>> props = {"year": "2023", "base_url": "http://example.com/"}
203
+ >>> AuidGenerator.props_to_canonical_encoded_string(props)
204
+ 'base_url~http%3A%2F%2Fexample.com%2F&year~2023'
205
+ """
206
+ if not props:
207
+ return ""
208
+
209
+ # Sort keys for canonical ordering (case-sensitive, like Java TreeSet)
210
+ sorted_keys = sorted(props.keys())
211
+
212
+ parts = []
213
+ for key in sorted_keys:
214
+ val = props[key]
215
+ if val is None:
216
+ val = ""
217
+ encoded_key = AuidGenerator.encode_component(str(key))
218
+ encoded_val = AuidGenerator.encode_component(str(val))
219
+ parts.append(f"{encoded_key}~{encoded_val}")
220
+
221
+ return "&".join(parts)
222
+
223
+ @staticmethod
224
+ def canonical_encoded_string_to_props(encoded: str) -> Dict[str, str]:
225
+ """
226
+ Decode a canonical encoded string back to properties dictionary.
227
+
228
+ Args:
229
+ encoded: Canonical encoded string (e.g., "key1~val1&key2~val2")
230
+
231
+ Returns:
232
+ Dictionary of decoded parameters
233
+
234
+ Example:
235
+ >>> encoded = 'base_url~http%3A%2F%2Fexample.com%2F&year~2023'
236
+ >>> AuidGenerator.canonical_encoded_string_to_props(encoded)
237
+ {'base_url': 'http://example.com/', 'year': '2023'}
238
+ """
239
+ if not encoded:
240
+ return {}
241
+
242
+ props = {}
243
+ pairs = encoded.split("&")
244
+
245
+ for pair in pairs:
246
+ if "~" not in pair:
247
+ raise ValueError("Missing tilde in key-value pair")
248
+ key_encoded, val_encoded = pair.split("~", 1)
249
+ if "~" in val_encoded:
250
+ raise ValueError("Additional tilde in key-value pair")
251
+ key = AuidGenerator.decode_component(key_encoded)
252
+ val = AuidGenerator.decode_component(val_encoded)
253
+ props[key] = val
254
+
255
+ return props
256
+
257
+ @staticmethod
258
+ def generate_auid(plugin_id: str, au_def_props: Dict[str, str]) -> str:
259
+ """
260
+ Generate an AUID from plugin ID and definitional properties.
261
+
262
+ Port of PluginManager.generateAuId() from lockss-core.
263
+
264
+ Args:
265
+ plugin_id: Plugin class name (e.g., "org.lockss.plugin.simulated.SimulatedPlugin")
266
+ au_def_props: Dictionary of AU definitional parameters
267
+
268
+ Returns:
269
+ AUID string (e.g., "org|lockss|plugin|simulated|SimulatedPlugin&base_url~...")
270
+
271
+ Raises:
272
+ ValueError: If plugin_id is empty
273
+
274
+ Example:
275
+ >>> plugin_id = "org.lockss.plugin.simulated.SimulatedPlugin"
276
+ >>> params = {"base_url": "http://example.com/", "year": "2023"}
277
+ >>> AuidGenerator.generate_auid(plugin_id, params)
278
+ 'org|lockss|plugin|simulated|SimulatedPlugin&base_url~http%3A%2F%2Fexample.com%2F&year~2023'
279
+ """
280
+ if not plugin_id:
281
+ raise ValueError("plugin_id cannot be empty")
282
+
283
+ plugin_key = AuidGenerator.plugin_key_from_id(plugin_id)
284
+ au_key = AuidGenerator.props_to_canonical_encoded_string(au_def_props)
285
+ return f"{plugin_key}&{au_key}"
286
+
287
+ @staticmethod
288
+ def plugin_key_from_auid(auid: str) -> str:
289
+ """
290
+ Extract plugin key from AUID.
291
+
292
+ Port of PluginManager.pluginKeyFromAuId() from lockss-core.
293
+
294
+ Args:
295
+ auid: AUID string
296
+
297
+ Returns:
298
+ Plugin key portion
299
+
300
+ Raises:
301
+ InvalidAuidError: If AUID format is invalid
302
+
303
+ Example:
304
+ >>> auid = "org|lockss|plugin|TestPlugin&base_url~http%3A%2F%2Fexample.com%2F"
305
+ >>> AuidGenerator.plugin_key_from_auid(auid)
306
+ 'org|lockss|plugin|TestPlugin'
307
+ """
308
+ if not auid:
309
+ raise InvalidAuidError("AUID cannot be empty")
310
+
311
+ idx = auid.find("&")
312
+ if idx < 0:
313
+ raise InvalidAuidError(f"AUID missing '&' separator: {auid}")
314
+
315
+ return auid[:idx]
316
+
317
+ @staticmethod
318
+ def au_key_from_auid(auid: str) -> str:
319
+ """
320
+ Extract AU key from AUID.
321
+
322
+ Port of PluginManager.auKeyFromAuId() from lockss-core.
323
+
324
+ Args:
325
+ auid: AUID string
326
+
327
+ Returns:
328
+ AU key portion (may be empty string)
329
+
330
+ Raises:
331
+ InvalidAuidError: If AUID format is invalid
332
+
333
+ Example:
334
+ >>> auid = "org|lockss|plugin|TestPlugin&base_url~http%3A%2F%2Fexample.com%2F"
335
+ >>> AuidGenerator.au_key_from_auid(auid)
336
+ 'base_url~http%3A%2F%2Fexample.com%2F'
337
+ """
338
+ if not auid:
339
+ raise InvalidAuidError("AUID cannot be empty")
340
+
341
+ idx = auid.find("&")
342
+ if idx < 0:
343
+ raise InvalidAuidError(f"AUID missing '&' separator: {auid}")
344
+
345
+ return auid[idx + 1:]
346
+
347
+ @staticmethod
348
+ def plugin_id_from_auid(auid: str) -> str:
349
+ """
350
+ Extract plugin ID from AUID.
351
+
352
+ Port of PluginManager.pluginIdFromAuId() from lockss-core.
353
+
354
+ Args:
355
+ auid: AUID string
356
+
357
+ Returns:
358
+ Plugin ID (with dots restored)
359
+
360
+ Raises:
361
+ InvalidAuidError: If AUID format is invalid
362
+
363
+ Example:
364
+ >>> auid = "org|lockss|plugin|TestPlugin&base_url~http%3A%2F%2Fexample.com%2F"
365
+ >>> AuidGenerator.plugin_id_from_auid(auid)
366
+ 'org.lockss.plugin.TestPlugin'
367
+ """
368
+ plugin_key = AuidGenerator.plugin_key_from_auid(auid)
369
+ return AuidGenerator.plugin_id_from_key(plugin_key)
370
+
371
+ @staticmethod
372
+ def decode_auid(auid: str) -> Dict[str, str]:
373
+ """
374
+ Decode an AUID into its definitional parameters.
375
+
376
+ This is a convenience method that extracts the AU key and decodes it.
377
+
378
+ Args:
379
+ auid: AUID string
380
+
381
+ Returns:
382
+ Dictionary of decoded AU parameters
383
+
384
+ Raises:
385
+ InvalidAuidError: If AUID format is invalid
386
+
387
+ Example:
388
+ >>> auid = "org|lockss|plugin|TestPlugin&base_url~http%3A%2F%2Fexample.com%2F&year~2023"
389
+ >>> AuidGenerator.decode_auid(auid)
390
+ {'base_url': 'http://example.com/', 'year': '2023'}
391
+ """
392
+ au_key = AuidGenerator.au_key_from_auid(auid)
393
+ return AuidGenerator.canonical_encoded_string_to_props(au_key)
394
+
395
+ @staticmethod
396
+ def validate_auid(auid: str) -> bool:
397
+ """
398
+ Check if an AUID string is valid.
399
+
400
+ Args:
401
+ auid: AUID string to validate
402
+
403
+ Returns:
404
+ True if valid, False otherwise
405
+
406
+ Example:
407
+ >>> AuidGenerator.validate_auid("org|lockss|plugin|TestPlugin&base_url~http%3A%2F%2Fexample.com%2F")
408
+ True
409
+ >>> AuidGenerator.validate_auid("invalid-auid")
410
+ False
411
+ """
412
+ try:
413
+ AuidGenerator.plugin_key_from_auid(auid)
414
+ AuidGenerator.au_key_from_auid(auid)
415
+ return True
416
+ except (InvalidAuidError, ValueError):
417
+ return False
lockss/pybasic/cliutil.py CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
- # Copyright (c) 2000-2025, Board of Trustees of Leland Stanford Jr. University
3
+ # Copyright (c) 2000-2026, Board of Trustees of Leland Stanford Jr. University
4
4
  #
5
5
  # Redistribution and use in source and binary forms, with or without
6
6
  # modification, are permitted provided that the following conditions are met:
@@ -32,211 +32,205 @@
32
32
  Command line utilities.
33
33
  """
34
34
 
35
- from collections.abc import Callable
36
- import sys
37
- from typing import Any, Dict, Generic, Optional, TypeVar
35
+ from pathlib import Path
36
+ from typing import Any, Optional, Union
38
37
 
39
- from pydantic.v1 import BaseModel
40
- from pydantic_argparse import ArgumentParser
41
- from pydantic_argparse.argparse.actions import SubParsersAction
42
- from rich_argparse import RichHelpFormatter
38
+ import click
39
+ from click.types import ParamType, IntRange
40
+ from click_extra import ChoiceSource, EnumChoice, ExtraContext, HelpExtraFormatter, Style, TableFormat, option
41
+ from click_extra.colorize import default_theme
43
42
 
44
43
 
45
- class ActionCommand(Callable, BaseModel):
44
+ def click_path(spec: Optional[str]) -> click.Path:
46
45
  """
47
- Base class for a pydantic-argparse style command.
46
+ Generates a ``click.Path`` based on a specification string.
47
+
48
+ The specification string can contain the following specifier characters:
49
+
50
+ .. list-table::
51
+ :header-rows: 1
52
+
53
+ * * Specifier
54
+ * Present
55
+ * Mutually exclusive with
56
+ * * ``f``
57
+ * Must be a file
58
+ * ``d`` [*]
59
+ * * ``d``
60
+ * Must be a directory
61
+ * ``f`` [*]
62
+ * * ``e``
63
+ * File or directory must exist
64
+ * ``E``
65
+ * * ``E``
66
+ * File or directory may or may not exist, but if it does not exist,
67
+ other checks are skipped (default)
68
+ * ``e``
69
+ * * ``r``
70
+ * File or directory must be readable
71
+ *
72
+ * * ``w``
73
+ * File or directory must be writable
74
+ *
75
+ * * ``x``
76
+ * File or directory must be executable
77
+ *
78
+ * * ``p``
79
+ * Resulting path will be ``pathlib.Path`` (default)
80
+ * ``s``
81
+ * * ``s``
82
+ * Resulting path will be ``str``
83
+ * ``p``
84
+ * * ``-``
85
+ * Path is allowed to be ``-``
86
+ *
87
+ * * ``z``
88
+ * Path will be absolute and resolved, with ``pathlib.Path.resolve``
89
+ *
90
+
91
+ When two mutual exclusive specifiers are present, ``ValueError`` is raised.
92
+
93
+ :param spec: A specification string.
94
+ :type spec: str
95
+ :return: A ``click.Path``.
96
+ :rtype: click.Path
97
+ :raises ValueError: If two mutually exclusive specifiers are present in the
98
+ specification string.
48
99
  """
49
- pass
50
-
51
-
52
- class StringCommand(ActionCommand):
53
- """
54
- A pydantic-argparse style command that prints a string.
55
-
56
- Example of use:
57
-
58
- .. code-block:: python
59
-
60
- class MyCliModel(BaseModel):
61
- copyright: Optional[StringCommand.type(my_copyright_string)] = Field(description=COPYRIGHT_DESCRIPTION)
62
-
63
- See also the convenience constants ``COPYRIGHT_DESCRIPTION``,
64
- ``LICENSE_DESCRIPTION``, and ``VERSION_DESCRIPTION``.
100
+ if spec is None:
101
+ spec = ''
102
+ allow_dash = False
103
+ dir_okay = True
104
+ executable = False
105
+ exists = False
106
+ file_okay = True
107
+ path_type = Path
108
+ readable = True
109
+ resolve_path = False
110
+ writable = False
111
+ for char in spec:
112
+ if char == 'd':
113
+ if 'f' in spec:
114
+ raise ValueError(f'"d" and "f" are mutually exclusive: {spec}')
115
+ dir_okay = True
116
+ file_okay = False
117
+ elif char == 'e':
118
+ if 'E' in spec:
119
+ raise ValueError(f'"e" and "E" are mutually exclusive: {spec}')
120
+ exists = True
121
+ elif char == 'E':
122
+ if 'e' in spec:
123
+ raise ValueError(f'"E" and "e" are mutually exclusive: {spec}')
124
+ exists = False
125
+ elif char == 'f':
126
+ if 'd' in spec:
127
+ raise ValueError(f'"f" and "d" are mutually exclusive: {spec}')
128
+ dir_okay = False
129
+ file_okay = True
130
+ elif char == 'p':
131
+ if 's' in spec:
132
+ raise ValueError(f'"p" and "s" are mutually exclusive: {spec}')
133
+ path_type = Path
134
+ elif char == 'r':
135
+ readable = True
136
+ elif char == 's':
137
+ if 'p' in spec:
138
+ raise ValueError(f'"s" and "p" are mutually exclusive: {spec}')
139
+ path_type = str
140
+ elif char == 'w':
141
+ writable = True
142
+ elif char == 'x':
143
+ executable = True
144
+ elif char == 'z':
145
+ resolve_path = True
146
+ elif char == '-':
147
+ allow_dash = True
148
+ else:
149
+ raise ValueError(f'unknown specification character "{char}": {spec}')
150
+ return click.Path(allow_dash=allow_dash,
151
+ dir_okay=dir_okay,
152
+ executable=executable,
153
+ exists=exists,
154
+ file_okay=file_okay,
155
+ path_type=path_type,
156
+ readable=readable,
157
+ resolve_path=resolve_path,
158
+ writable=writable)
159
+
160
+
161
+ #: Composes the given decorators, so that
162
+ #: @compose_decorators(f, g, h)
163
+ #: def foo():
164
+ #: pass
165
+ #: is equivalent to:
166
+ #: @f
167
+ # @g
168
+ # @h
169
+ #: def foo():
170
+ #: pass
171
+ def compose_decorators(*decorators):
172
+ def wrapped(decorated):
173
+ for dec in reversed(decorators):
174
+ decorated = dec(decorated)
175
+ return decorated
176
+ return wrapped
177
+
178
+
179
+ def make_table_format_option(switches: Union[str, tuple[str, ...]] = ('--table-format', '-T'),
180
+ default: TableFormat = TableFormat.SIMPLE):
65
181
  """
66
-
67
- @staticmethod
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
73
-
74
-
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'
78
-
79
-
80
- BaseModelT = TypeVar('BaseModelT', bound=BaseModel)
81
-
82
-
83
- class BaseCli(Generic[BaseModelT]):
182
+ Makes an equivalent of ``click_Extra.table_format_option`` with the given
183
+ command line switches and the given table format default.
184
+
185
+ The standard ``click_Extra.table_format_option`` attaches to the top-level
186
+ command only.
187
+
188
+ :param switches: A string or tuple of strings for the command line switches.
189
+ :type switches: Union[str, tuple[str, ...]]
190
+ :param default: A ``click_extra.TableFormat`` default.
191
+ :type default: TableFormat
192
+ :return: A remixed ``click_Extra.table_format_option``.
193
+ :rtype:
84
194
  """
85
- Base class for general CLI functionality.
195
+ if type(switches) == str:
196
+ switches = (switches,)
197
+ return option(*switches, type=EnumChoice(TableFormat, choice_source=ChoiceSource.VALUE), default=default, show_default=True, help='Set the rendering of tables to the given style.')
86
198
 
87
- ``BaseModelT`` represents a Pydantic-Argparse model type deriving from
88
- ``BaseModel``.
89
199
 
90
- For each command ``x-y-z`` induced by a Pydantic-Argparse field named
91
- ``x_y_z`` deriving from ``BaseModel`` , the ``dispatch()`` method expects a
92
- method named ``_x_y_z``.
200
+ def make_extra_context_settings() -> dict[str, Any]:
93
201
  """
202
+ Makes a custom ``click_Extra.ExtraContext`` with essential changes.
94
203
 
95
- def __init__(self, **kwargs):
96
- """
97
- Constructs a new ``BaseCli`` instance.
98
-
99
- :param kwargs: Keyword arguments. Must include ``model``, the
100
- ``BaseModel`` type corresponding to ``BaseModelT``. Must
101
- include ``prog``, the command-line name of the program.
102
- Must include ``description``, the description string of
103
- the program.
104
- :type kwargs: Dict[str, Any]
105
- """
106
- super().__init__()
107
- self._args: Optional[BaseModelT] = None
108
- self._parser: Optional[ArgumentParser] = None
109
- self.extra: Dict[str, Any] = dict(**kwargs)
110
-
111
- def run(self) -> None:
112
- """
113
- Runs the command line tool:
114
-
115
- * Creates a Pydantic-Argparse ``ArgumentParser`` with ``model``,
116
- ``prog`` and ``description`` from the constructor keyword
117
- arguments in ``self.parser``.
118
-
119
- * Stores the Pydantic-Argparse parsed arguments in ``self.args``.
120
-
121
- * Calls ``dispatch()``.
122
- """
123
- self._parser: ArgumentParser = ArgumentParser(model=self.extra.get('model'),
124
- prog=self.extra.get('prog'),
125
- description=self.extra.get('description'))
126
- self._initialize_rich_argparse()
127
- self._args = self._parser.parse_typed_args()
128
- self.dispatch()
129
-
130
- def dispatch(self) -> None:
131
- """
132
- Dispatches from the first field ``x_y_z`` in ``self.args`` that is a
133
- command (i.e. whose value derives from ``BaseModel``) to a method
134
- called ``_x_y_z``.
135
- """
136
- field_names = self._args.__class__.__fields__.keys()
137
- for field_name in field_names:
138
- field_value = getattr(self._args, field_name)
139
- if issubclass(type(field_value), BaseModel):
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
146
- else:
147
- self._parser.error(f'unknown command; expected one of {', '.join(field_names)}')
148
-
149
- def _initialize_rich_argparse(self) -> None:
150
- """
151
- Initializes `rich-argparse <https://pypi.org/project/rich-argparse/>`_
152
- for this instance.
153
- """
154
- self._initialize_rich_argparse_styles()
155
- def __add_formatter_class(container):
156
- container.formatter_class = RichHelpFormatter
157
- if hasattr(container, '_actions'):
158
- for action in container._actions:
159
- if issubclass(type(action), SubParsersAction):
160
- for subaction in action.choices.values():
161
- __add_formatter_class(subaction)
162
- __add_formatter_class(self._parser)
163
-
164
- def _initialize_rich_argparse_styles(self) -> None:
165
- # See https://github.com/hamdanal/rich-argparse#customize-the-colors
166
- for cls in [RichHelpFormatter]:
167
- cls.styles.update({
168
- 'argparse.args': 'bold cyan', # for positional-arguments and --options (e.g "--help")
169
- 'argparse.groups': 'underline dark_orange', # for group names (e.g. "positional arguments")
170
- 'argparse.help': 'default', # for argument's help text (e.g. "show this help message and exit")
171
- 'argparse.metavar': 'italic dark_cyan', # for metavariables (e.g. "FILE" in "--file FILE")
172
- 'argparse.prog': 'bold grey50', # for %(prog)s in the usage (e.g. "foo" in "Usage: foo [options]")
173
- 'argparse.syntax': 'bold', # for highlights of back-tick quoted text (e.g. "`some text`")
174
- 'argparse.text': 'default', # for descriptions, epilog, and --version (e.g. "A program to foo")
175
- 'argparse.default': 'italic', # for %(default)s in the help (e.g. "Value" in "(default: Value)")
176
- })
177
-
178
-
179
- def at_most_one_from_enum(model_cls, values: Dict[str, Any], enum_cls) -> Dict[str, Any]:
180
- """
181
- Among the fields of a Pydantic-Argparse model whose ``Field`` definition is
182
- tagged with the ``enum`` keyword set to the given ``Enum`` type, ensures
183
- that at most one of them has a true value in the given Pydantic-Argparse
184
- validator ``values``, or raises a ``ValueError`` otherwise.
185
-
186
- :param model_cls: A Pydantic-Argparse model class.
187
- :param values: The Pydantic-Argparse validator ``values``.
188
- :param enum_cls: The ``Enum`` class the fields of the Pydantic-Argparse
189
- model are tagged with (using the ``enum`` keyword).
190
- :return: The ``values`` argument, if no ``ValueError`` has been raised.
191
- """
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]
193
- ret = [field_name for field_name in enum_names if values.get(field_name)]
194
- if (length := len(ret)) > 1:
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])})')
196
- return values
204
+ Currently, the only change is that the help formatter styles the invoked
205
+ command in bold.
197
206
 
198
-
199
- def get_from_enum(model_inst, enum_cls, default=None):
200
- """
201
- Among the fields of a Pydantic-Argparse model whose ``Field`` definition is
202
- tagged with the ``enum`` keyword set to the given ``Enum`` type, gets the
203
- corresponding enum value of the first with a true value in the model, or
204
- returns the given default value. Assumes the existence of a
205
- ``from_member()`` static method in the ``Enum`` class.
206
-
207
- :param model_inst:
208
- :param enum_cls:
209
- :param default:
210
- :return:
207
+ :return: A custom ``click_Extra.ExtraContext``.
208
+ :rtype: dict[str, Any]
211
209
  """
212
- enum_names = [field_name for field_name, model_field in type(model_inst).__fields__.items() if model_field.field_info.extra.get('enum') == enum_cls]
213
- for field_name in enum_names:
214
- if getattr(model_inst, field_name):
215
- return enum_cls[field_name]
216
- return default
210
+ return ExtraContext.settings(
211
+ formatter_settings=HelpExtraFormatter.settings(
212
+ theme=default_theme.with_(
213
+ invoked_command=Style(bold=True)
214
+ )
215
+ )
216
+ )
217
217
 
218
218
 
219
- def at_most_one(values: Dict[str, Any], *names: str):
220
- if (length := _matchy_length(values, *names)) > 1:
221
- raise ValueError(f'at most one of {', '.join([option_name(name) for name in names])} is allowed, got {length}')
222
- return values
219
+ #: A ``click.ParamType`` for strictly positive integers (1 to infinity).
220
+ PositiveInt: ParamType = IntRange(min=1, max=None)
223
221
 
224
222
 
225
- def exactly_one(values: Dict[str, Any], *names: str):
226
- if (length := _matchy_length(values, *names)) != 1:
227
- raise ValueError(f'exactly one of {', '.join([option_name(name) for name in names])} is required, got {length}')
228
- return values
223
+ #: A ``click.ParamType`` for non-negative integers (0 to infinity).
224
+ NonNegativeInt: ParamType = IntRange(min=0, max=None)
229
225
 
230
226
 
231
- def one_or_more(values: Dict[str, Any], *names: str):
232
- if _matchy_length(values, *names) == 0:
233
- raise ValueError(f'one or more of {', '.join([option_name(name) for name in names])} is required')
234
- return values
227
+ #: A ``click.ParamType`` for strictly negative integers (negative infinity to -1).
228
+ NegativeInt: ParamType = IntRange(min=None, max=-1)
235
229
 
236
230
 
237
- def option_name(name: str) -> str:
238
- return f'{('-' if len(name) == 1 else '--')}{name.replace('_', '-')}'
231
+ #: A ``click.ParamType`` for non-positive integers (negative infinity to 0).
232
+ NonPositiveInt: ParamType = IntRange(min=None, max=0)
239
233
 
240
234
 
241
- def _matchy_length(values: Dict[str, Any], *names: str) -> int:
242
- return len([name for name in names if values.get(name)])
235
+ #: A ``click.ParamType`` for unsigned 16-bit integers (0 to 65535).
236
+ UInt16: ParamType = IntRange(min=0, max=65535)
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
- # Copyright (c) 2000-2025, Board of Trustees of Leland Stanford Jr. University
3
+ # Copyright (c) 2000-2026, Board of Trustees of Leland Stanford Jr. University
4
4
  #
5
5
  # Redistribution and use in source and binary forms, with or without
6
6
  # modification, are permitted provided that the following conditions are met:
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
- # Copyright (c) 2000-2025, Board of Trustees of Leland Stanford Jr. University
3
+ # Copyright (c) 2000-2026, Board of Trustees of Leland Stanford Jr. University
4
4
  #
5
5
  # Redistribution and use in source and binary forms, with or without
6
6
  # modification, are permitted provided that the following conditions are met:
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: lockss-pybasic
3
+ Version: 0.2.0
4
+ Summary: Basic Python utilities
5
+ License: BSD-3-Clause
6
+ License-File: LICENSE
7
+ Author: Thib Guicherd-Callin
8
+ Author-email: thib@cs.stanford.edu
9
+ Maintainer: Thib Guicherd-Callin
10
+ Maintainer-email: thib@cs.stanford.edu
11
+ Requires-Python: >=3.10,<4.0
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Framework :: Pydantic :: 2
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: BSD License
17
+ Classifier: Programming Language :: Python
18
+ Classifier: Topic :: Software Development :: Libraries
19
+ Requires-Dist: click-extra (>=7.5.0,<7.6.0)
20
+ Project-URL: Repository, https://github.com/lockss/lockss-pybasic
21
+ Description-Content-Type: text/x-rst
22
+
23
+ ==============
24
+ lockss-pybasic
25
+ ==============
26
+
27
+ .. |RELEASE| replace:: 0.2.0
28
+ .. |RELEASE_DATE| replace:: 2026-03-18
29
+
30
+ **Latest release:** |RELEASE| (|RELEASE_DATE|)
31
+
32
+ ``lockss-pybasic`` provides basic utilities for various LOCKSS projects written in Python.
33
+
34
+ -------
35
+ Modules
36
+ -------
37
+
38
+ ``lockss.pybasic.cliutil``
39
+ Command line utilities based on `Click Extra <https://kdeldycke.github.io/click-extra>`_, `Cloup <https://cloup.readthedocs.io/>`_ and `Click <https://click.palletsprojects.com/>`_.
40
+
41
+ * ``click_path()``: a ``click.Path`` utility.
42
+
43
+ * ``PositiveInt``, ``NonNegativeInt``, ``NegativeInt``, ``NonPositiveInt``, ``UInt16``: ``click.ParamType`` integer types.
44
+
45
+ * ``compose_decorators()``: a decorator utility.
46
+
47
+ * ``make_table_format_option()``: a remix of ``click_extra.table_format_option`` that is not attached to the top-level command.
48
+
49
+ * ``make_extra_context_settings()``: a custom ``click_extra.ExtraContext``.
50
+
51
+ ``lockss.pybasic.errorutil``
52
+ Error and exception utilities.
53
+
54
+ * ``InternalError`` is a no-arg subclass of ``RuntimeError``.
55
+
56
+ ``lockss.pybasic.fileutil``
57
+ File and path utilities.
58
+
59
+ * ``file_lines`` returns the non-empty lines of a file stripped of comments that begin with ``#`` and run to the end of a line.
60
+
61
+ * ``path`` takes a string or ``PurePath`` and returns a ``Path`` for which ``Path.expanduser()`` and ``Path.resolve()`` have been called.
62
+
63
+ -------------
64
+ Release Notes
65
+ -------------
66
+
67
+ See `<CHANGELOG.rst>`_.
68
+
@@ -0,0 +1,9 @@
1
+ lockss/pybasic/__init__.py,sha256=mfug98GqR17NB7OSpPd9mj_-YSAf_0Ij2Ek8fpOSMqc,1673
2
+ lockss/pybasic/auidutil.py,sha256=Q4vjjGfymiXVwPu35RyyLZBnViv8mDJKCjOyJb-sbS8,13921
3
+ lockss/pybasic/cliutil.py,sha256=F970MhLcQCYS3INLQT1Ij8nKt_ICmttiS3nSnuWuN7s,8106
4
+ lockss/pybasic/errorutil.py,sha256=4EaO0a1yIG1DbWltASeT15bg1bGg5kOYspsW0iJdVLc,1951
5
+ lockss/pybasic/fileutil.py,sha256=IIS2AFDgYtmBLPVHqQi1AkIFj6da04b6NQtjX9bIVqQ,2958
6
+ lockss_pybasic-0.2.0.dist-info/METADATA,sha256=UzEx0lIH3DPlaXzpAVJ5AjTZFNqByz_tdaol1vEp3dc,2229
7
+ lockss_pybasic-0.2.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
8
+ lockss_pybasic-0.2.0.dist-info/licenses/LICENSE,sha256=EOxPunNz3XP6AjgbPFolu-d9BS_AF9TtKn1WXgeYPsE,1506
9
+ lockss_pybasic-0.2.0.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,4 +1,4 @@
1
- Copyright (c) 2000-2025, Board of Trustees of Leland Stanford Jr. University
1
+ Copyright (c) 2000-2026, Board of Trustees of Leland Stanford Jr. University
2
2
 
3
3
  Redistribution and use in source and binary forms, with or without
4
4
  modification, are permitted provided that the following conditions are met:
@@ -1,57 +0,0 @@
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
- Utilities to work with 'tabulate'.
33
- """
34
-
35
- from enum import Enum
36
-
37
- from pydantic.v1 import BaseModel, Field, validator
38
- import tabulate
39
- from typing import Optional
40
-
41
-
42
- OutputFormat = Enum('OutputFormat', tabulate.tabulate_formats)
43
-
44
-
45
- DEFAULT_OUTPUT_FORMAT = 'simple' # from tabulate
46
-
47
-
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())}')
50
-
51
- @validator('output_format')
52
- def _validate_output_format(cls, val: str):
53
- try:
54
- _ = OutputFormat[val]
55
- return val
56
- except KeyError:
57
- raise ValueError(f'must be one of {', '.join(OutputFormat.__members__.keys())}; got {val}')
@@ -1,102 +0,0 @@
1
- Metadata-Version: 2.3
2
- Name: lockss-pybasic
3
- Version: 0.1.0.dev23
4
- Summary: Basic Python utilities
5
- License: BSD-3-Clause
6
- Author: Thib Guicherd-Callin
7
- Author-email: thib@cs.stanford.edu
8
- Maintainer: Thib Guicherd-Callin
9
- Maintainer-email: thib@cs.stanford.edu
10
- Requires-Python: >=3.9,<4.0
11
- Classifier: Development Status :: 4 - Beta
12
- Classifier: Environment :: Console
13
- Classifier: Framework :: Pydantic :: 2
14
- Classifier: Intended Audience :: Developers
15
- Classifier: Intended Audience :: System Administrators
16
- Classifier: License :: OSI Approved :: BSD License
17
- Classifier: Programming Language :: Python
18
- Classifier: Topic :: Software Development :: Libraries
19
- Classifier: Topic :: System :: Archiving
20
- Classifier: Topic :: Utilities
21
- Requires-Dist: pydantic (>=2.11.0,<3.0.0)
22
- Requires-Dist: pydantic-argparse (>=0.10.0,<0.11.0)
23
- Requires-Dist: rich-argparse (>=1.7.0,<1.8.0)
24
- Requires-Dist: tabulate (>=0.9.0,<0.10.0)
25
- Project-URL: Repository, https://github.com/lockss/lockss-pybasic
26
- Description-Content-Type: text/x-rst
27
-
28
- ==============
29
- lockss-pybasic
30
- ==============
31
-
32
- .. |RELEASE| replace:: 0.1.0-dev23
33
- .. |RELEASE_DATE| replace:: ?
34
-
35
- **Latest release:** |RELEASE| (|RELEASE_DATE|)
36
-
37
- ``lockss-pybasic`` provides basic utilities for various LOCKSS projects written in Python.
38
-
39
- -------
40
- Modules
41
- -------
42
-
43
- ``lockss.pybasic.cliutil``
44
- Command line utilities.
45
-
46
- * ``BaseCli`` is a base class for command line interfaces that uses `pydantic-argparse <https://pypi.org/project/pydantic-argparse/>`_ to define arguments and subcommands, and `rich-argparse <https://pypi.org/project/rich-argparse/>`_ to display help messages. For each command ``x-y-z`` induced by a ``pydantic-argparse`` field named ``x_y_z`` deriving from ``BaseModel`` , the ``dispatch()`` method expects a method named ``_x_y_z``.
47
-
48
- * ``StringCommand`` provides a `pydantic-argparse <https://pypi.org/project/pydantic-argparse/>`_ way to define a subcommand whose only purpose is printing a string, with ``COPYRIGHT_DESCRIPTION``, ``LICENSE_DESCRIPTION`` and ``VERSION_DESCRIPTION`` constants provided for convenience. Example:
49
-
50
- .. code-block:: python
51
-
52
- class MyCliModel(BaseModel):
53
- copyright: Optional[StringCommand.type(my_copyright_string)] = Field(description=COPYRIGHT_DESCRIPTION)
54
-
55
- * ``at_most_one_from_enum`` and ``get_from_enum`` provide a facility for defining a `pydantic-argparse <https://pypi.org/project/pydantic-argparse/>`_ model that defines one command line option per constant of an ``Enum``, using an ``enum`` keyword argument in the ``Field`` definition. Example:
56
-
57
- .. code-block:: python
58
-
59
- class Orientation(Enum):
60
- horizontal = 1
61
- vertical = 2
62
- diagonal = 3
63
-
64
- DEFAULT_ORIENTATION = Orientation.horizontal
65
-
66
- class MyCliModel(BaseModel):
67
- diagonal: Optional[bool] = Field(False, description='display diagonally', enum=Orientation)
68
- horizontal: Optional[bool] = Field(False, description='display horizontally', enum=Orientation)
69
- unrelated: Optional[bool] = Field(...)
70
- vertical: Optional[bool] = Field(False, description='display vertically', enum=Orientation)
71
-
72
- @root_validator
73
- def _at_most_one_orientation(cls, values):
74
- return at_most_one_from_enum(cls, values, Orientation)
75
-
76
- def get_orientation(self) -> Orientation:
77
- return get_from_enum(self, Orientation, DEFAULT_ORIENTATION)
78
-
79
- ``lockss.pybasic.errorutil``
80
- Error and exception utilities.
81
-
82
- * ``InternalError`` is a no-arg subclass of ``RuntimeError``.
83
-
84
- ``lockss.pybasic.fileutil``
85
- File and path utilities.
86
-
87
- * ``file_lines`` returns the non-empty lines of a file stripped of comments that begin with ``#`` and run to the end of a line.
88
-
89
- * ``path`` takes a string or ``PurePath`` and returns a ``Path`` for which ``Path.expanduser()`` and ``Path.resolve()`` have been called.
90
-
91
- ``lockss.pybasic.outpututil``
92
- Utilities to work with `tabulate <https://pypi.org/project/tabulate/>`_.
93
-
94
- * ``OutputFormat`` is an ``Enum`` of all the output formats from ``tabulate.tabulate_formats``.
95
-
96
- * ``OutputFormatOptions`` defines a `pydantic-argparse <https://pypi.org/project/pydantic-argparse/>`_ model for setting an output format from ``OutputFormat``.
97
-
98
- -------------
99
- Release Notes
100
- -------------
101
-
102
- See `<CHANGELOG.rst>`_.
@@ -1,9 +0,0 @@
1
- lockss/pybasic/__init__.py,sha256=jmOhJI5PiH3jLMWLGSCBYvv8hV2yG9PTPUfvYGtP1oU,1679
2
- lockss/pybasic/cliutil.py,sha256=Q7WNFbtZzMBfEXjhqsUJlYOdsKYQm0Mgerf7aVe5VK8,10347
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.1.0.dev23.dist-info/LICENSE,sha256=O9ONND4uDxY_jucI4jZDf2liAk05ScEJaYu-Al7EOdQ,1506
7
- lockss_pybasic-0.1.0.dev23.dist-info/METADATA,sha256=viCY_etc3kouRMBzEAcl44-zfDWo2Fkzk767I91jBKM,4372
8
- lockss_pybasic-0.1.0.dev23.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
9
- lockss_pybasic-0.1.0.dev23.dist-info/RECORD,,