lockss-pybasic 0.2.0.dev6__py3-none-any.whl → 0.2.0.dev8__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-dev6'
39
+ __version__ = '0.2.0-dev8'
@@ -36,7 +36,7 @@ and related utility classes in the LOCKSS lockss-core library.
36
36
  """
37
37
 
38
38
  import urllib.parse
39
- from typing import Dict, Optional
39
+ from typing import Dict
40
40
 
41
41
 
42
42
  class InvalidAuidError(ValueError):
@@ -60,7 +60,7 @@ class AuidGenerator:
60
60
  >>> params = {"base_url": "http://example.com/", "year": "2023"}
61
61
  >>> auid = AuidGenerator.generate_auid(plugin_id, params)
62
62
  >>> print(auid)
63
- org|lockss|plugin|simulated|SimulatedPlugin&base_url~http%3A%2F%2Fexample.com%2F&year~2023
63
+ org|lockss|plugin|simulated|SimulatedPlugin&base_url~http%3A%2F%2Fexample%2Ecom%2F&year~2023
64
64
  """
65
65
 
66
66
  @staticmethod
@@ -103,6 +103,18 @@ class AuidGenerator:
103
103
  raise ValueError("plugin_key cannot be empty")
104
104
  return plugin_key.replace("|", ".")
105
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
+
106
118
  @staticmethod
107
119
  def encode_component(s: str) -> str:
108
120
  """
@@ -111,9 +123,13 @@ class AuidGenerator:
111
123
  Port of PropKeyEncoder.encode() from lockss-core.
112
124
 
113
125
  This method encodes strings using URL encoding with the following rules:
114
- - Alphanumeric characters, hyphens, underscores, periods, and asterisks are not encoded
126
+ - Alphanumeric characters (a-z, A-Z, 0-9) are not encoded
127
+ - Hyphens (-), underscores (_), and asterisks (*) are not encoded
115
128
  - Spaces are encoded as '+'
116
- - All other characters are percent-encoded with uppercase hex digits
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.
117
133
 
118
134
  Args:
119
135
  s: String to encode
@@ -123,28 +139,25 @@ class AuidGenerator:
123
139
 
124
140
  Example:
125
141
  >>> AuidGenerator.encode_component("http://example.com/")
126
- 'http%3A%2F%2Fexample.com%2F'
142
+ 'http%3A%2F%2Fexample%2Ecom%2F'
127
143
  """
128
144
  if not s:
129
145
  return ""
130
146
 
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
147
  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
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)
145
155
  else:
146
- result.append(encoded[i])
147
- i += 1
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'))
148
161
 
149
162
  return ''.join(result)
150
163
 
@@ -165,7 +178,7 @@ class AuidGenerator:
165
178
  """
166
179
  if not s:
167
180
  return ""
168
- return urllib.parse.unquote_plus(s)
181
+ return urllib.parse.unquote_plus(s, errors="strict")
169
182
 
170
183
  @staticmethod
171
184
  def props_to_canonical_encoded_string(props: Dict[str, str]) -> str:
@@ -231,8 +244,10 @@ class AuidGenerator:
231
244
 
232
245
  for pair in pairs:
233
246
  if "~" not in pair:
234
- continue
247
+ raise ValueError("Missing tilde in key-value pair")
235
248
  key_encoded, val_encoded = pair.split("~", 1)
249
+ if "~" in val_encoded:
250
+ raise ValueError("Additional tilde in key-value pair")
236
251
  key = AuidGenerator.decode_component(key_encoded)
237
252
  val = AuidGenerator.decode_component(val_encoded)
238
253
  props[key] = val
lockss/pybasic/cliutil.py CHANGED
@@ -31,18 +31,21 @@
31
31
  """
32
32
  Command line utilities.
33
33
  """
34
-
34
+ import pathlib
35
35
  from collections.abc import Callable
36
36
  import sys
37
37
  from typing import Any, Dict, Generic, Optional, TypeVar
38
38
 
39
- from pydantic.v1 import BaseModel
39
+ import click
40
+ from pydantic.v1 import BaseModel as BaseModel1
40
41
  from pydantic_argparse import ArgumentParser
41
42
  from pydantic_argparse.argparse.actions import SubParsersAction
42
43
  from rich_argparse import RichHelpFormatter
43
44
 
45
+ from .fileutil import path
46
+
44
47
 
45
- class ActionCommand(Callable, BaseModel):
48
+ class ActionCommand(Callable, BaseModel1):
46
49
  """
47
50
  Base class for a pydantic-argparse style command.
48
51
  """
@@ -77,7 +80,7 @@ LICENSE_DESCRIPTION = 'print the software license and exit'
77
80
  VERSION_DESCRIPTION = 'print the version number and exit'
78
81
 
79
82
 
80
- BaseModelT = TypeVar('BaseModelT', bound=BaseModel)
83
+ BaseModelT = TypeVar('BaseModelT', bound=BaseModel1)
81
84
 
82
85
 
83
86
  class BaseCli(Generic[BaseModelT]):
@@ -129,22 +132,25 @@ class BaseCli(Generic[BaseModelT]):
129
132
 
130
133
  def dispatch(self) -> None:
131
134
  """
132
- Dispatches from the first field ``x_y_z`` in ``self.args`` that is a
135
+ Dispatches from the first field ``x_y_z`` in ``self._args`` that is a
133
136
  command (i.e. whose value derives from ``BaseModel``) to a method
134
137
  called ``_x_y_z``.
135
138
  """
136
- field_names = self._args.__class__.__fields__.keys()
139
+ self._dispatch_recursive(self._args, [])
140
+
141
+ def _dispatch_recursive(self, base_model: BaseModel1, subcommands: list[str]) -> None:
142
+ field_names = base_model.__class__.__fields__.keys()
137
143
  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
144
+ field_value = getattr(base_model, field_name)
145
+ if issubclass(type(field_value), BaseModel1):
146
+ self._dispatch_recursive(field_value, [*subcommands, field_name])
147
+ return
148
+ func_name = ''.join(f'_{sub}' for sub in subcommands)
149
+ func = getattr(self, func_name)
150
+ if callable(func):
151
+ func(base_model) # FIXME?
146
152
  else:
147
- self._parser.error(f'unknown command; expected one of {", ".join(field_names)}')
153
+ self._parser.exit(1, f'internal error: no {func_name} callable for the {" ".join(sub for sub in subcommands)} command')
148
154
 
149
155
  def _initialize_rich_argparse(self) -> None:
150
156
  """
@@ -234,7 +240,7 @@ def one_or_more(values: Dict[str, Any], *names: str):
234
240
  return values
235
241
 
236
242
 
237
- def option_name(model_cls: type[BaseModel], name: str) -> str:
243
+ def option_name(model_cls: type[BaseModel1], name: str) -> str:
238
244
  if (info := model_cls.__fields__.get(name)) is None:
239
245
  raise RuntimeError(f'invalid name: {name}')
240
246
  if alias := info.alias:
@@ -244,3 +250,63 @@ def option_name(model_cls: type[BaseModel], name: str) -> str:
244
250
 
245
251
  def _matchy_length(values: Dict[str, Any], *names: str) -> int:
246
252
  return len([name for name in names if values.get(name)])
253
+
254
+
255
+ def click_path(spec: Optional[str]) -> click.Path:
256
+ if spec is None:
257
+ spec = ''
258
+ allow_dash = False
259
+ dir_okay = True
260
+ executable = False
261
+ exists = False
262
+ file_okay = True
263
+ path_type = path
264
+ readable = True
265
+ resolve_path = False
266
+ writable = False
267
+ for char in spec:
268
+ if char == 'd':
269
+ if 'f' in spec:
270
+ raise ValueError(f'"d" and "f" are mutually exclusive: {spec}')
271
+ dir_okay = True
272
+ file_okay = False
273
+ elif char == 'e':
274
+ exists = True
275
+ elif char == 'f':
276
+ if 'd' in spec:
277
+ raise ValueError(f'"f" and "d" are mutually exclusive: {spec}')
278
+ dir_okay = False
279
+ file_okay = True
280
+ elif char == 'p':
281
+ if 'P' in spec or 's' in spec:
282
+ raise ValueError(f'"p", "P", and "s" are mutually exclusive: {spec}')
283
+ path_type = path
284
+ elif char == 'P':
285
+ if 'p' in spec or 's' in spec:
286
+ raise ValueError(f'"P", "p", and "s" are mutually exclusive: {spec}')
287
+ path_type = pathlib.Path
288
+ elif char == 'r':
289
+ readable = True
290
+ elif char == 's':
291
+ if 'p' in spec or 'P' in spec:
292
+ raise ValueError(f'"s", "p", and "P" are mutually exclusive: {spec}')
293
+ path_type = str
294
+ elif char == 'w':
295
+ writable = True
296
+ elif char == 'x':
297
+ executable = True
298
+ elif char == 'z':
299
+ resolve_path = True
300
+ elif char == '-':
301
+ allow_dash = True
302
+ else:
303
+ raise ValueError(f'unknown specification character "{char}": {spec}')
304
+ return click.Path(allow_dash=allow_dash,
305
+ dir_okay=dir_okay,
306
+ executable=executable,
307
+ exists=exists,
308
+ file_okay=file_okay,
309
+ path_type=path_type,
310
+ readable=readable,
311
+ resolve_path=resolve_path,
312
+ writable=writable)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lockss-pybasic
3
- Version: 0.2.0.dev6
3
+ Version: 0.2.0.dev8
4
4
  Summary: Basic Python utilities
5
5
  License: BSD-3-Clause
6
6
  License-File: LICENSE
@@ -8,7 +8,7 @@ Author: Thib Guicherd-Callin
8
8
  Author-email: thib@cs.stanford.edu
9
9
  Maintainer: Thib Guicherd-Callin
10
10
  Maintainer-email: thib@cs.stanford.edu
11
- Requires-Python: >=3.9,<4.0
11
+ Requires-Python: >=3.10,<4.0
12
12
  Classifier: Development Status :: 4 - Beta
13
13
  Classifier: Environment :: Console
14
14
  Classifier: Framework :: Pydantic :: 2
@@ -16,7 +16,8 @@ Classifier: Intended Audience :: Developers
16
16
  Classifier: License :: OSI Approved :: BSD License
17
17
  Classifier: Programming Language :: Python
18
18
  Classifier: Topic :: Software Development :: Libraries
19
- Requires-Dist: pydantic (>=2.11.0,<3.0.0)
19
+ Requires-Dist: click-extra (>=7.5.0,<7.6.0)
20
+ Requires-Dist: pydantic (>=2.11.0,<2.12.0)
20
21
  Requires-Dist: pydantic-argparse (>=0.10.0,<0.11.0)
21
22
  Requires-Dist: rich-argparse (>=1.7.0,<1.8.0)
22
23
  Requires-Dist: tabulate (>=0.9.0,<0.10.0)
@@ -27,8 +28,8 @@ Description-Content-Type: text/x-rst
27
28
  lockss-pybasic
28
29
  ==============
29
30
 
30
- .. |RELEASE| replace:: 0.1.1
31
- .. |RELEASE_DATE| replace:: 2025-10-02
31
+ .. |RELEASE| replace:: 0.2.0-dev8
32
+ .. |RELEASE_DATE| replace:: NOT YET RELEASED
32
33
 
33
34
  **Latest release:** |RELEASE| (|RELEASE_DATE|)
34
35
 
@@ -0,0 +1,10 @@
1
+ lockss/pybasic/__init__.py,sha256=A-Y9xGDnzeI7ZG2J4KT3DCP7lDVCAzlvJ1dJD87420E,1678
2
+ lockss/pybasic/auidutil.py,sha256=o5IsRLEYROXRVS6oTO1VFtdzw7SImYSR5VcqAMHY4To,13921
3
+ lockss/pybasic/cliutil.py,sha256=vf8lxc-m6p6if5D7uWIBynIxvCiG6uZg44pHCMSiSzs,12866
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.dev8.dist-info/METADATA,sha256=xucIusdQQSQBwgcbNN2uS-6nb4hHDzBMxh_0m1_B7rs,4326
8
+ lockss_pybasic-0.2.0.dev8.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
9
+ lockss_pybasic-0.2.0.dev8.dist-info/licenses/LICENSE,sha256=O9ONND4uDxY_jucI4jZDf2liAk05ScEJaYu-Al7EOdQ,1506
10
+ lockss_pybasic-0.2.0.dev8.dist-info/RECORD,,
@@ -1,10 +0,0 @@
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,,