transcrypto 2.2.0__tar.gz → 2.3.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.
Files changed (33) hide show
  1. {transcrypto-2.2.0 → transcrypto-2.3.0}/PKG-INFO +4 -4
  2. {transcrypto-2.2.0 → transcrypto-2.3.0}/pyproject.toml +11 -9
  3. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/__init__.py +1 -1
  4. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/hashes.py +7 -7
  5. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/key.py +15 -12
  6. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/base.py +48 -0
  7. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/config.py +232 -0
  8. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/stats.py +16 -16
  9. {transcrypto-2.2.0 → transcrypto-2.3.0}/LICENSE +0 -0
  10. {transcrypto-2.2.0 → transcrypto-2.3.0}/README.md +0 -0
  11. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/cli/__init__.py +0 -0
  12. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/cli/aeshash.py +0 -0
  13. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/cli/bidsecret.py +0 -0
  14. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/cli/clibase.py +0 -0
  15. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/cli/intmath.py +0 -0
  16. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/cli/publicalgos.py +0 -0
  17. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/__init__.py +0 -0
  18. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/aes.py +0 -0
  19. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/bid.py +0 -0
  20. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/constants.py +0 -0
  21. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/dsa.py +0 -0
  22. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/elgamal.py +0 -0
  23. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/modmath.py +0 -0
  24. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/rsa.py +0 -0
  25. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/sss.py +0 -0
  26. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/profiler.py +0 -0
  27. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/py.typed +0 -0
  28. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/transcrypto.py +0 -0
  29. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/__init__.py +0 -0
  30. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/human.py +0 -0
  31. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/logging.py +0 -0
  32. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/saferandom.py +0 -0
  33. {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/timer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: transcrypto
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Basic crypto primitives, not intended for actual use, but as a companion to --Criptografia, Métodos e Algoritmos--
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -16,10 +16,10 @@ Classifier: Operating System :: OS Independent
16
16
  Classifier: Topic :: Utilities
17
17
  Classifier: Topic :: Security :: Cryptography
18
18
  Requires-Dist: cryptography (>=46.0)
19
- Requires-Dist: gmpy2 (>=2.2)
20
- Requires-Dist: platformdirs (>=4.5)
19
+ Requires-Dist: gmpy2 (>=2.3)
20
+ Requires-Dist: platformdirs (>=4.7)
21
21
  Requires-Dist: rich (>=14.3,!=14.3.2)
22
- Requires-Dist: typer (>=0.21)
22
+ Requires-Dist: typer (>=0.23)
23
23
  Requires-Dist: zstandard (>=0.25)
24
24
  Project-URL: Changelog, https://github.com/balparda/transcrypto/blob/main/CHANGELOG.md
25
25
  Project-URL: Homepage, https://github.com/balparda/transcrypto
@@ -12,7 +12,7 @@ build-backend = "poetry.core.masonry.api"
12
12
  [project]
13
13
 
14
14
  name = "transcrypto"
15
- version = "2.2.0" # also update src/transcrypto/__init__.py
15
+ version = "2.3.0" # also update src/transcrypto/__init__.py
16
16
 
17
17
  description = "Basic crypto primitives, not intended for actual use, but as a companion to --Criptografia, Métodos e Algoritmos--"
18
18
  license = "Apache-2.0"
@@ -59,15 +59,15 @@ license-files = [ "LICENSE" ]
59
59
 
60
60
  dependencies = [
61
61
 
62
- "typer>=0.21", # if this changes, also change: [tool.poetry.dependencies]
62
+ "typer>=0.23", # if this changes, also change: [tool.poetry.dependencies]
63
63
  "rich>=14.3,!=14.3.2", # 14.3.2 hangs GitHub CI pipeline
64
- "platformdirs>=4.5",
64
+ "platformdirs>=4.7",
65
65
 
66
66
  # best place for project dependencies, if possible
67
67
  # development-only dependencies go in the [tool.poetry.group.dev.dependencies] section below
68
68
 
69
69
  "cryptography>=46.0",
70
- "gmpy2>=2.2",
70
+ "gmpy2>=2.3",
71
71
  "zstandard>=0.25",
72
72
 
73
73
  ]
@@ -117,11 +117,11 @@ include = [
117
117
  # prefer to add dependencies inside the [project.dependencies] section
118
118
 
119
119
  python = "^3.12" # if version changes, remember to change README.md
120
- typer = { version = "^0.21", extras = ["all"] }
120
+ typer = { version = "^0.23", extras = ["all"] }
121
121
 
122
122
  [tool.poetry.group.dev.dependencies]
123
123
 
124
- ruff = "~0.14.14"
124
+ ruff = "~0.15.1"
125
125
  mypy = "~1.19.1"
126
126
  pytest = "^9.0"
127
127
  pytest-cov = "^7.0"
@@ -173,7 +173,7 @@ ignore-overlong-task-comments = true
173
173
 
174
174
  select = [
175
175
  "ALL", # ALL rules --- if too strict comment this out and pick the ones you need below
176
- # "E", # pycodestyle errors (basic correctness-ish style checks)
176
+ # "E", # pycodestyle "errors" (basic correctness-ish style checks)
177
177
  # "F", # pyflakes (unused imports/vars, undefined names, etc.)
178
178
  # "I", # isort rules (import sorting)
179
179
  # "UP", # pyupgrade (suggest modern Python syntax)
@@ -203,6 +203,7 @@ ignore = [
203
203
 
204
204
  "E731", # Do not assign a `lambda` expression, use a `def`
205
205
  "FBT001", # Boolean-typed positional argument in function definition
206
+ "FBT002", # Boolean default positional argument in function definition
206
207
  "FBT003", # allows boolean positional value in function call
207
208
  "PLR0913", # Too many arguments in function definition (N > 5)
208
209
  "PLR0917", # Too many positional arguments (N/5)
@@ -238,11 +239,12 @@ preview = true # if you want new features being previewed
238
239
  [tool.mypy]
239
240
 
240
241
  python_version = "3.12"
241
- mypy_path = [ "src" ]
242
+ mypy_path = [ "src", "typings" ]
243
+
244
+ warn_unused_ignores = false # VSCode always has more to say, so this is important
242
245
 
243
246
  warn_return_any = true
244
247
  warn_unused_configs = true
245
- warn_unused_ignores = true
246
248
  disallow_untyped_defs = true
247
249
  no_implicit_optional = true
248
250
  warn_redundant_casts = true
@@ -3,5 +3,5 @@
3
3
  """Basic cryptography primitives implementation."""
4
4
 
5
5
  __all__: list[str] = ['__author__', '__version__']
6
- __version__ = '2.2.0' # remember to also update pyproject.toml
6
+ __version__ = '2.3.0' # remember to also update pyproject.toml
7
7
  __author__ = 'Daniel Balparda <balparda@github.com>'
@@ -41,11 +41,11 @@ def Hash512(data: bytes, /) -> bytes:
41
41
  return hashlib.sha512(data).digest()
42
42
 
43
43
 
44
- def FileHash(full_path: str, /, *, digest: str = 'sha256') -> bytes:
44
+ def FileHash(full_path: str | pathlib.Path, /, *, digest: str = 'sha256') -> bytes:
45
45
  """SHA-256 hex hash of file on disk. Always a length of 32 bytes (if default digest=='sha256').
46
46
 
47
47
  Args:
48
- full_path (str): Path to existing file on disk
48
+ full_path (str | pathlib.Path): Path to existing file on disk
49
49
  digest (str, optional): Hash method to use, accepts 'sha256' (default) or 'sha512'
50
50
 
51
51
  Returns:
@@ -61,12 +61,12 @@ def FileHash(full_path: str, /, *, digest: str = 'sha256') -> bytes:
61
61
  digest = digest.lower().strip().replace('-', '') # normalize so we can accept e.g. "SHA-256"
62
62
  if digest not in {'sha256', 'sha512'}:
63
63
  raise base.InputError(f'unrecognized digest: {digest!r}')
64
- full_path = full_path.strip()
65
- if not full_path or not pathlib.Path(full_path).exists():
66
- raise base.InputError(f'file {full_path!r} not found for hashing')
64
+ full_path = pathlib.Path(full_path)
65
+ if not full_path.exists():
66
+ raise base.InputError(f'file {str(full_path)!r} not found for hashing')
67
67
  # compute hash
68
- logging.info(f'Hashing file {full_path!r}')
69
- with pathlib.Path(full_path).open('rb') as file_obj:
68
+ logging.info(f'Hashing file {str(full_path)!r}')
69
+ with full_path.open('rb') as file_obj:
70
70
  return hashlib.file_digest(file_obj, digest).digest()
71
71
 
72
72
 
@@ -573,7 +573,7 @@ def Serialize[T](
573
573
  python_obj: T,
574
574
  /,
575
575
  *,
576
- file_path: str | None = None,
576
+ file_path: str | pathlib.Path | None = None,
577
577
  compress: int | None = 3,
578
578
  encryption_key: Encryptor | None = None,
579
579
  silent: bool = False,
@@ -601,7 +601,7 @@ def Serialize[T](
601
601
 
602
602
  Args:
603
603
  python_obj (Any): serializable Python object
604
- file_path (str, optional): full path to optionally save the data to
604
+ file_path (str | pathlib.Path | None, optional): full path to optionally save the data to
605
605
  compress (int | None, optional): Compress level before encrypting/saving; -22 ≤ compress ≤ 22;
606
606
  None is no compression; default is 3, which is fast, see table above for other values
607
607
  encryption_key (Encryptor, optional): if given will encryption_key.Encrypt() data before save
@@ -636,11 +636,12 @@ def Serialize[T](
636
636
  if not silent:
637
637
  messages.append(f' {tm_crypto}, {human.HumanizedBytes(len(obj))}')
638
638
  # optionally save to disk
639
+ file_path = pathlib.Path(file_path) if file_path else None
639
640
  if file_path is not None:
640
641
  with timer.Timer('SAVE', emit_log=False) as tm_save:
641
- pathlib.Path(file_path).write_bytes(obj)
642
+ file_path.write_bytes(obj)
642
643
  if not silent:
643
- messages.append(f' {tm_save}, to {file_path!r}')
644
+ messages.append(f' {tm_save}, to {str(file_path)!r}')
644
645
  # log and return
645
646
  if not silent:
646
647
  logging.info(f'{tm_all}; parts:\n{"\n".join(messages)}')
@@ -650,7 +651,7 @@ def Serialize[T](
650
651
  def DeSerialize[T]( # noqa: C901
651
652
  *,
652
653
  data: bytes | None = None,
653
- file_path: str | None = None,
654
+ file_path: str | pathlib.Path | None = None,
654
655
  decryption_key: Decryptor | None = None,
655
656
  silent: bool = False,
656
657
  unpickler: abc.Callable[[bytes], T] = UnpickleGeneric,
@@ -668,8 +669,9 @@ def DeSerialize[T]( # noqa: C901
668
669
  Args:
669
670
  data (bytes | None, optional): if given, use this as binary data string (input);
670
671
  if you use this option, `file_path` will be ignored
671
- file_path (str | None, optional): if given, use this as file path to load binary data
672
- string (input); if you use this option, `data` will be ignored. Defaults to None.
672
+ file_path (str | pathlib.Path | None, optional): if given, use this as file path to
673
+ load binary data string (input); if you use this option, `data` will be ignored.
674
+ Defaults to None.
673
675
  decryption_key (Decryptor | None, optional): if given will decryption_key.Decrypt() data before
674
676
  decompressing/loading. Defaults to None.
675
677
  silent (bool, optional): if True will not log; default is False (will log). Defaults to False.
@@ -689,8 +691,9 @@ def DeSerialize[T]( # noqa: C901
689
691
  # test inputs
690
692
  if (data is None and file_path is None) or (data is not None and file_path is not None):
691
693
  raise base.InputError('you must provide only one of either `data` or `file_path`')
692
- if file_path and not pathlib.Path(file_path).exists():
693
- raise base.InputError(f'invalid file_path: {file_path!r}')
694
+ file_path = pathlib.Path(file_path) if file_path else None
695
+ if file_path is not None and not file_path.exists():
696
+ raise base.InputError(f'invalid file_path: {str(file_path)!r}')
694
697
  if data and len(data) < 4: # noqa: PLR2004
695
698
  raise base.InputError('invalid data: too small')
696
699
  # start the pipeline
@@ -698,12 +701,12 @@ def DeSerialize[T]( # noqa: C901
698
701
  messages: list[str] = [f'DATA: {human.HumanizedBytes(len(obj))}'] if data and not silent else []
699
702
  with timer.Timer('De-Serialization complete', emit_log=False) as tm_all:
700
703
  # optionally load from disk
701
- if file_path:
704
+ if file_path is not None:
702
705
  assert not obj, 'should never happen: if we have a file obj should be empty' # noqa: S101
703
706
  with timer.Timer('LOAD', emit_log=False) as tm_load:
704
- obj = pathlib.Path(file_path).read_bytes()
707
+ obj = file_path.read_bytes()
705
708
  if not silent:
706
- messages.append(f' {tm_load}, {human.HumanizedBytes(len(obj))}, from {file_path!r}')
709
+ messages.append(f' {tm_load}, {human.HumanizedBytes(len(obj))}, from {str(file_path)!r}')
707
710
  # decrypt, if needed
708
711
  if decryption_key is not None:
709
712
  with timer.Timer('DECRYPT', emit_log=False) as tm_crypto:
@@ -6,6 +6,8 @@ from __future__ import annotations
6
6
 
7
7
  import base64
8
8
  import codecs
9
+ import pathlib
10
+ import subprocess # noqa: S404
9
11
  from collections import abc
10
12
 
11
13
  # Data conversion utils
@@ -35,6 +37,10 @@ class InputError(Error):
35
37
  """Input exception (TransCrypto)."""
36
38
 
37
39
 
40
+ class NotFoundError(Error, FileNotFoundError):
41
+ """File not found (TransCrypto)."""
42
+
43
+
38
44
  class ImplementationError(Error, NotImplementedError):
39
45
  """Feature is not implemented yet (TransCrypto)."""
40
46
 
@@ -70,3 +76,45 @@ def RawToBytes(s: str, /) -> bytes:
70
76
  s = s[1:-1]
71
77
  # decode backslash escapes to code points, then map 0..255 -> bytes
72
78
  return codecs.decode(s, 'unicode_escape').encode('latin1')
79
+
80
+
81
+ def Run(
82
+ cmd: list[str],
83
+ /,
84
+ *,
85
+ cwd: pathlib.Path | None = None,
86
+ env: dict[str, str] | None = None,
87
+ ) -> subprocess.CompletedProcess[str]:
88
+ """Run a command; return the completed process; assert success with useful diagnostics.
89
+
90
+ Useful for testing CLI commands where we want to assert success and capture
91
+ stdout/stderr for diagnostics on failure.
92
+
93
+ Args:
94
+ cmd (list[str]): command
95
+ cwd (Path | None, optional): Path. Defaults to None.
96
+ env (dict[str, str] | None, optional): Environment. Defaults to None.
97
+
98
+ Raises:
99
+ AssertionError: invalid return code
100
+
101
+ Returns:
102
+ subprocess.CompletedProcess[str]: result
103
+
104
+ """
105
+ result: subprocess.CompletedProcess[str] = subprocess.run( # noqa: S603
106
+ cmd,
107
+ cwd=str(cwd) if cwd is not None else None,
108
+ env=env,
109
+ text=True,
110
+ capture_output=True,
111
+ check=False,
112
+ )
113
+ if result.returncode != 0:
114
+ details: str = (
115
+ f'Command failed (exit={result.returncode}): {cmd}\n\n'
116
+ f'--- stdout ---\n{result.stdout}\n'
117
+ f'--- stderr ---\n{result.stderr}\n'
118
+ )
119
+ raise AssertionError(details)
120
+ return result
@@ -7,8 +7,11 @@ from __future__ import annotations
7
7
  import logging
8
8
  import pathlib
9
9
  import shutil
10
+ import sys
10
11
  import tempfile
11
12
  import threading
13
+ import venv
14
+ import zipfile
12
15
  from collections import abc
13
16
 
14
17
  import platformdirs
@@ -306,3 +309,232 @@ class AppConfig:
306
309
  silent=silent,
307
310
  unpickler=unpickler,
308
311
  )
312
+
313
+
314
+ def VenvPaths(venv_dir: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path]:
315
+ """Return virtual environment paths for python and /bin.
316
+
317
+ Useful for testing installed CLIs in a clean environment.
318
+
319
+ Args:
320
+ venv_dir (pathlib.Path): path to venv
321
+
322
+ Returns:
323
+ tuple[Path, Path]: (venv_python, venv_bin_dir)
324
+
325
+ """
326
+ is_win: bool = sys.platform.startswith('win')
327
+ bin_dir: pathlib.Path = venv_dir / 'Scripts' if is_win else venv_dir / 'bin'
328
+ return (bin_dir / 'python.exe' if is_win else bin_dir / 'python', bin_dir)
329
+
330
+
331
+ def FindConsoleScript(bin_dir: pathlib.Path, name: str) -> pathlib.Path:
332
+ """Find the installed console script in the venv (platform-specific).
333
+
334
+ Useful for testing installed CLIs in a clean environment.
335
+
336
+ Args:
337
+ bin_dir (pathlib.Path): directory containing the console scripts
338
+ name (str): name of the console script to find
339
+
340
+ Returns:
341
+ pathlib.Path: path to the console script
342
+
343
+ Raises:
344
+ base.NotFoundError: if the console script is not found
345
+
346
+ """
347
+ # go through possible script names based on platform conventions; return the first one that exists
348
+ for p in (
349
+ bin_dir / name, # *nix is typically just the name
350
+ bin_dir / f'{name}.exe', # Windows may have .exe/.cmd
351
+ bin_dir / f'{name}.cmd',
352
+ ):
353
+ if p.exists():
354
+ return p
355
+ raise base.NotFoundError(f'Could not find console script {name!r} in {bin_dir}')
356
+
357
+
358
+ def WheelHasConsoleScripts(wheel: pathlib.Path, scripts: set[str]) -> bool:
359
+ """Return True if the wheel defines the given console scripts.
360
+
361
+ Args:
362
+ wheel (pathlib.Path): wheel path
363
+ scripts (set[str]): set of console script names to check for; case sensitive
364
+
365
+ Returns:
366
+ bool: True if all specified console scripts are found in the wheel
367
+
368
+ """
369
+ # open the wheel as a zip file and look for entry_points.txt; read
370
+ try:
371
+ with zipfile.ZipFile(wheel) as zf:
372
+ entry_points: list[str] = [n for n in zf.namelist() if n.endswith('entry_points.txt')]
373
+ if not entry_points:
374
+ return False
375
+ data: str = zf.read(entry_points[0]).decode('utf-8', errors='replace')
376
+ except (OSError, zipfile.BadZipFile):
377
+ return False
378
+ # Minimal parse: ensure the [console_scripts] section contains the required names.
379
+ in_console_scripts: bool = False
380
+ found: set[str] = set()
381
+ for raw_line in data.splitlines():
382
+ line: str = raw_line.strip()
383
+ if not line or line.startswith(('#', ';')):
384
+ continue
385
+ if line.startswith('[') and line.endswith(']'):
386
+ in_console_scripts = line == '[console_scripts]'
387
+ continue
388
+ if in_console_scripts and '=' in line:
389
+ name: str = line.split('=', 1)[0].strip()
390
+ found.add(name)
391
+ return scripts.issubset(found)
392
+
393
+
394
+ def EnsureWheel(repo: pathlib.Path, expected_version: str, scripts: set[str], /) -> pathlib.Path:
395
+ """Build a wheel if needed; return path to the newest wheel in dist/.
396
+
397
+ Args:
398
+ repo (Path): path to the repository root
399
+ expected_version (str): expected version string to match in the wheel filename
400
+ scripts (set[str]): set of console script names to check for; case sensitive
401
+
402
+ Raises:
403
+ base.Error: if no wheel is found after building or not finding couldn't build a wheel
404
+
405
+ Returns:
406
+ Path: path to the newest wheel in dist/
407
+
408
+ """
409
+ dist_dir: pathlib.Path = repo / 'dist'
410
+ dist_dir.mkdir(exist_ok=True)
411
+
412
+ def _NewestWheel() -> pathlib.Path | None:
413
+ # discover existing wheels
414
+ wheels: list[pathlib.Path] = sorted(dist_dir.glob('*.whl'), key=lambda p: p.stat().st_mtime)
415
+ # prefer an existing wheel that matches the current source version; otherwise build a new one.
416
+ matching: list[pathlib.Path] = [w for w in wheels if f'-{expected_version}-' in w.name]
417
+ if matching:
418
+ newest: pathlib.Path = matching[-1]
419
+ # if a stale wheel exists (e.g., built before console scripts were configured), rebuild.
420
+ if WheelHasConsoleScripts(newest, scripts):
421
+ return newest
422
+ return None
423
+
424
+ # try to find an existing wheel
425
+ wheel: pathlib.Path | None = _NewestWheel()
426
+ if wheel is not None:
427
+ return wheel # found
428
+ # not found: build a new wheel
429
+ poetry: str | None = shutil.which('poetry')
430
+ if poetry is None:
431
+ raise base.Error('`poetry` not found on PATH; cannot build wheel')
432
+ base.Run([poetry, 'build', '-f', 'wheel'], cwd=repo)
433
+ # now we must have a wheel!
434
+ wheel = _NewestWheel()
435
+ if wheel is not None:
436
+ return wheel # found
437
+ raise base.Error(f'Wheel build succeeded but no `.whl` found in {str(dist_dir)!r}')
438
+
439
+
440
+ def EnsureAndInstallWheel(
441
+ repository_root_dir: pathlib.Path,
442
+ temporary_dir: pathlib.Path,
443
+ expected_version: str,
444
+ scripts: set[str],
445
+ /,
446
+ ) -> tuple[pathlib.Path, pathlib.Path]:
447
+ """Ensure wheel exists (build if needed), create a `venv`, install the wheel.
448
+
449
+ Args:
450
+ repository_root_dir (pathlib.Path): path to the repository root
451
+ temporary_dir (pathlib.Path): path to a temporary directory to use for the venv
452
+ expected_version (str): expected version string to match in the wheel filename
453
+ scripts (set[str]): set of console script names to check for; case sensitive
454
+
455
+ Returns:
456
+ tuple[pathlib.Path, pathlib.Path]: (venv_python, venv_bin_dir)
457
+
458
+ Raises:
459
+ base.InputError: if the wheel cannot be found or built, if the venv cannot be created,
460
+ or if the wheel cannot be installed into the venv
461
+
462
+ """
463
+ # check to make sure directories exist, then ensure wheel exists (build if needed)
464
+ if not repository_root_dir.is_dir() or not temporary_dir.is_dir():
465
+ raise base.InputError('`repository_root_dir` and `temporary_dir` must be existing directories')
466
+ if not scripts or not expected_version:
467
+ raise base.InputError('`expected_version` and `scripts` must be non-empty')
468
+ wheel: pathlib.Path = EnsureWheel(repository_root_dir, expected_version, scripts)
469
+ # create an isolated venv (not using Poetry's .venv on purpose)
470
+ venv_dir: pathlib.Path = temporary_dir / 'venv'
471
+ venv.EnvBuilder(with_pip=True, clear=True).create(venv_dir)
472
+ venv_python: pathlib.Path
473
+ venv_bin_dir: pathlib.Path
474
+ venv_python, venv_bin_dir = VenvPaths(venv_dir)
475
+ # install the wheel into the venv
476
+ base.Run([str(venv_python), '-m', 'pip', 'install', '--upgrade', 'pip'])
477
+ base.Run([str(venv_python), '-m', 'pip', 'install', str(wheel)])
478
+ return (venv_python, venv_bin_dir)
479
+
480
+
481
+ def EnsureConsoleScriptsPrintExpectedVersion(
482
+ venv_python: pathlib.Path, venv_bin_dir: pathlib.Path, expected_version: str, scripts: set[str], /
483
+ ) -> dict[str, pathlib.Path]:
484
+ """Ensure the console scripts print the expected version; return their paths.
485
+
486
+ Useful for testing installed CLIs in a clean environment.
487
+
488
+ Args:
489
+ venv_python (pathlib.Path): path to the venv python executable
490
+ venv_bin_dir (pathlib.Path): directory containing the console scripts
491
+ expected_version (str): expected version string to match in the console script output
492
+ scripts (set[str]): set of console script names to check for; case sensitive
493
+
494
+ Returns:
495
+ dict[str, pathlib.Path]: mapping of console script name to its path,
496
+ including a 'python' key for the venv python executable
497
+
498
+ Raises:
499
+ base.Error: a console script does not print the expected version or if script is not found
500
+
501
+ """
502
+ cli_paths: dict[str, pathlib.Path] = {}
503
+ for script in scripts:
504
+ cli: pathlib.Path = FindConsoleScript(venv_bin_dir, script)
505
+ result = base.Run([str(cli), '--version'])
506
+ if (actual := result.stdout.strip()) != expected_version:
507
+ raise base.Error(
508
+ f'Console script {script!r} did not print version {expected_version!r}; got {actual!r}'
509
+ )
510
+ cli_paths[script] = cli
511
+ cli_paths['python'] = venv_python
512
+ return cli_paths
513
+
514
+
515
+ def CallGetConfigDirFromVEnv(venv_python: pathlib.Path, app_name: str) -> pathlib.Path:
516
+ """Call a Python command in the venv to get the config dir path for the given app name.
517
+
518
+ Args:
519
+ venv_python (pathlib.Path): path to the venv python executable
520
+ app_name (str): The name of the application.
521
+
522
+ Returns:
523
+ pathlib.Path: the config dir path returned by the command
524
+
525
+ Raises:
526
+ base.InputError: if the venv python executable does not exist or if `app_name` is empty
527
+
528
+ """
529
+ if not venv_python.exists():
530
+ raise base.InputError(f'venv python not found at {str(venv_python)!r}')
531
+ if not app_name:
532
+ raise base.InputError('`app_name` must be a non-empty string')
533
+ r2 = base.Run(
534
+ [
535
+ str(venv_python),
536
+ '-c',
537
+ f'from transcrypto.utils import config; print(config.GetConfigDir("{app_name}"))',
538
+ ]
539
+ )
540
+ return pathlib.Path(r2.stdout.strip())
@@ -22,13 +22,15 @@ _LANCZOS_COEFF: tuple[float, ...] = (
22
22
  9.9843695780195716e-6,
23
23
  1.5056327351493116e-7,
24
24
  )
25
- _TINY: float = 1e-30
26
- _NSG: abc.Callable[[float], float] = (
27
- lambda z: _TINY if abs(z) < _TINY else z # numerical stability guard
25
+ TINY: float = 1e-30
26
+ NSG: abc.Callable[[float], float] = (
27
+ lambda z: TINY if abs(z) < TINY else z # numerical stability guard
28
28
  )
29
+ IS_EQUAL: abc.Callable[[float, float], bool] = lambda a, b: abs(a - b) < TINY
29
30
  _BETA_INCOMPLETE_MAX_ITER: int = 200
30
31
  _BETA_INCOMPLETE_TOL: float = 1e-14
31
32
  _STUDENT_SMALL: float = 1e-12
33
+ IS_STUDENT_EQUAL: abc.Callable[[float, float], bool] = lambda a, b: abs(a - b) < _STUDENT_SMALL
32
34
 
33
35
 
34
36
  def GammaLanczos(z: float, /) -> float:
@@ -87,7 +89,7 @@ def BetaIncompleteCF(a: float, b: float, x: float, /) -> float:
87
89
  qap: float = a + 1.0
88
90
  qam: float = a - 1.0
89
91
  c: float = 1.0
90
- d: float = 1.0 / _NSG(1.0 - qab * x / qap)
92
+ d: float = 1.0 / NSG(1.0 - qab * x / qap)
91
93
  h: float = d
92
94
  aa: float
93
95
  delta: float
@@ -96,11 +98,11 @@ def BetaIncompleteCF(a: float, b: float, x: float, /) -> float:
96
98
  m2 = 2 * m
97
99
  # even step
98
100
  aa = m * (b - m) * x / ((qam + m2) * (a + m2))
99
- c, d = _NSG(1.0 + aa / c), 1.0 / _NSG(1.0 + aa * d)
101
+ c, d = NSG(1.0 + aa / c), 1.0 / NSG(1.0 + aa * d)
100
102
  h *= d * c
101
103
  # odd step
102
104
  aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
103
- c, d = _NSG(1.0 + aa / c), 1.0 / _NSG(1.0 + aa * d)
105
+ c, d = NSG(1.0 + aa / c), 1.0 / NSG(1.0 + aa * d)
104
106
  delta = d * c
105
107
  h *= delta
106
108
  if abs(delta - 1.0) < _BETA_INCOMPLETE_TOL:
@@ -133,12 +135,12 @@ def BetaIncomplete(a: float, b: float, x: float, /) -> float:
133
135
  x > (a + 1) / (a + b + 2).
134
136
 
135
137
  """
136
- if x < 0.0 or x > 1.0:
137
- raise base.InputError(f'x must be in [0, 1], got {x}')
138
- if x == 0.0:
138
+ if IS_EQUAL(x, 0.0):
139
139
  return 0.0
140
- if x == 1.0:
140
+ if IS_EQUAL(x, 1.0):
141
141
  return 1.0
142
+ if x < 0.0 or x > 1.0:
143
+ raise base.InputError(f'x must be in [0, 1], got {x}')
142
144
  log_beta: float = math.lgamma(a) + math.lgamma(b) - math.lgamma(a + b)
143
145
  front: float = math.exp(math.log(x) * a + math.log(1.0 - x) * b - log_beta) / a
144
146
  if x < (a + 1.0) / (a + b + 2.0):
@@ -197,7 +199,7 @@ def StudentTPPF(q: float, df: float, /) -> float:
197
199
  if not 0.0 < q < 1.0:
198
200
  raise base.InputError(f'q must be in (0, 1), got {q}')
199
201
  # Special case: q=0.5 is exactly 0 by symmetry
200
- if q == 0.5: # noqa: PLR2004
202
+ if IS_EQUAL(q, 0.5):
201
203
  return 0.0
202
204
  # Initial guess using inverse normal approximation (Abramowitz & Stegun 26.2.23)
203
205
  if q < 0.5: # noqa: PLR2004
@@ -206,9 +208,7 @@ def StudentTPPF(q: float, df: float, /) -> float:
206
208
  else:
207
209
  sign = 1.0
208
210
  p = 1.0 - q
209
- # Protect against log(0) when p is very close to 0
210
- p = max(p, 1e-300)
211
- t_approx: float = math.sqrt(-2.0 * math.log(p))
211
+ t_approx: float = math.sqrt(-2.0 * math.log(NSG(p))) # protect against log(0) for tiny p
212
212
  c0 = 2.515517
213
213
  c1 = 0.802853
214
214
  c2 = 0.010328
@@ -230,9 +230,9 @@ def StudentTPPF(q: float, df: float, /) -> float:
230
230
  - math.lgamma(df / 2)
231
231
  - ((df + 1) / 2) * math.log(1 + x0**2 / df)
232
232
  )
233
- pdf_val: float = _NSG(math.exp(log_pdf))
233
+ pdf_val: float = NSG(math.exp(log_pdf))
234
234
  x1: float = x0 - (cdf_val - q) / pdf_val
235
- if abs(x1 - x0) < _STUDENT_SMALL:
235
+ if IS_STUDENT_EQUAL(x1, x0):
236
236
  return x1
237
237
  x0 = x1
238
238
  return x0 # pragma: no cover - Newton-Raphson always converges for t-distribution
File without changes
File without changes