lockss-pybasic 0.1.0.dev23__tar.gz → 0.2.0__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.
@@ -0,0 +1,53 @@
1
+ =============
2
+ Release Notes
3
+ =============
4
+
5
+ -----
6
+ 0.2.0
7
+ -----
8
+
9
+ Released: 2026-03-18
10
+
11
+ Requires Python 3.10.
12
+
13
+ * **Features**
14
+
15
+ * ``lockss.pybasic.cliutil`` has been replaced with utilities based on `Click Extra <https://kdeldycke.github.io/click-extra>`_, `Cloup <https://cloup.readthedocs.io/>`_ and `Click <https://click.palletsprojects.com/>`_:
16
+
17
+ * ``click_path()``: a ``click.Path`` utility.
18
+
19
+ * ``PositiveInt``, ``NonNegativeInt``, ``NegativeInt``, ``NonPositiveInt``, ``UInt16``: ``click.ParamType`` integer types.
20
+
21
+ * ``compose_decorators()``: a decorator utility.
22
+
23
+ * ``make_table_format_option()``: a remix of ``click_extra.table_format_option`` that is not attached to the top-level command.
24
+
25
+ * ``make_extra_context_settings()``: a custom ``click_extra.ExtraContext``.
26
+
27
+ ``lockss.pybasic.outpututil`` has been removed.
28
+
29
+ -----
30
+ 0.1.1
31
+ -----
32
+
33
+ Released: 2025-10-02
34
+
35
+ * **Bug Fixes**
36
+
37
+ * Remove Python 3.12+ f-string quote reuse (lockss-pybasic is Python 3.9+).
38
+
39
+ -----
40
+ 0.1.0
41
+ -----
42
+
43
+ Released: 2025-07-01
44
+
45
+ Initial release, including:
46
+
47
+ * ``cliutil``
48
+
49
+ * ``errorutil``
50
+
51
+ * ``fileutil``
52
+
53
+ * ``outpututil``
@@ -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:
@@ -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,45 @@
1
+ ==============
2
+ lockss-pybasic
3
+ ==============
4
+
5
+ .. |RELEASE| replace:: 0.2.0
6
+ .. |RELEASE_DATE| replace:: 2026-03-18
7
+
8
+ **Latest release:** |RELEASE| (|RELEASE_DATE|)
9
+
10
+ ``lockss-pybasic`` provides basic utilities for various LOCKSS projects written in Python.
11
+
12
+ -------
13
+ Modules
14
+ -------
15
+
16
+ ``lockss.pybasic.cliutil``
17
+ Command line utilities based on `Click Extra <https://kdeldycke.github.io/click-extra>`_, `Cloup <https://cloup.readthedocs.io/>`_ and `Click <https://click.palletsprojects.com/>`_.
18
+
19
+ * ``click_path()``: a ``click.Path`` utility.
20
+
21
+ * ``PositiveInt``, ``NonNegativeInt``, ``NegativeInt``, ``NonPositiveInt``, ``UInt16``: ``click.ParamType`` integer types.
22
+
23
+ * ``compose_decorators()``: a decorator utility.
24
+
25
+ * ``make_table_format_option()``: a remix of ``click_extra.table_format_option`` that is not attached to the top-level command.
26
+
27
+ * ``make_extra_context_settings()``: a custom ``click_extra.ExtraContext``.
28
+
29
+ ``lockss.pybasic.errorutil``
30
+ Error and exception utilities.
31
+
32
+ * ``InternalError`` is a no-arg subclass of ``RuntimeError``.
33
+
34
+ ``lockss.pybasic.fileutil``
35
+ File and path utilities.
36
+
37
+ * ``file_lines`` returns the non-empty lines of a file stripped of comments that begin with ``#`` and run to the end of a line.
38
+
39
+ * ``path`` takes a string or ``PurePath`` and returns a ``Path`` for which ``Path.expanduser()`` and ``Path.resolve()`` have been called.
40
+
41
+ -------------
42
+ Release Notes
43
+ -------------
44
+
45
+ See `<CHANGELOG.rst>`_.
@@ -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:
@@ -28,11 +28,11 @@
28
28
 
29
29
  [project]
30
30
  name = "lockss-pybasic"
31
- version = "0.1.0-dev23" # Always change in __init__.py, and at release time in README.rst and CHANGELOG.rst
31
+ version = "0.2.0" # Always change in __init__.py, and at release time in README.rst and CHANGELOG.rst
32
32
  description = "Basic Python utilities"
33
33
  license = { text = "BSD-3-Clause" }
34
34
  readme = "README.rst"
35
- requires-python = ">=3.9,<4.0"
35
+ requires-python = ">=3.10,<4.0"
36
36
  authors = [
37
37
  { name = "Thib Guicherd-Callin", email = "thib@cs.stanford.edu" },
38
38
  ]
@@ -40,22 +40,16 @@ maintainers = [
40
40
  { name = "Thib Guicherd-Callin", email = "thib@cs.stanford.edu" },
41
41
  ]
42
42
  dependencies = [
43
- "pydantic (>=2.11.0,<3.0.0)",
44
- "pydantic-argparse (>=0.10.0,<0.11.0)",
45
- "rich-argparse (>=1.7.0,<1.8.0)",
46
- "tabulate (>=0.9.0,<0.10.0)",
43
+ "click-extra (>=7.5.0,<7.6.0)",
47
44
  ]
48
45
  classifiers = [
49
46
  "Development Status :: 4 - Beta",
50
47
  "Environment :: Console",
51
48
  "Framework :: Pydantic :: 2",
52
49
  "Intended Audience :: Developers",
53
- "Intended Audience :: System Administrators",
54
50
  "License :: OSI Approved :: BSD License",
55
51
  "Programming Language :: Python",
56
52
  "Topic :: Software Development :: Libraries",
57
- "Topic :: System :: Archiving",
58
- "Topic :: Utilities",
59
53
  ]
60
54
 
61
55
  [project.urls]
@@ -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