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.
- {transcrypto-2.2.0 → transcrypto-2.3.0}/PKG-INFO +4 -4
- {transcrypto-2.2.0 → transcrypto-2.3.0}/pyproject.toml +11 -9
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/__init__.py +1 -1
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/hashes.py +7 -7
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/key.py +15 -12
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/base.py +48 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/config.py +232 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/stats.py +16 -16
- {transcrypto-2.2.0 → transcrypto-2.3.0}/LICENSE +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/README.md +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/cli/__init__.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/cli/aeshash.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/cli/bidsecret.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/cli/clibase.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/cli/intmath.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/cli/publicalgos.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/__init__.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/aes.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/bid.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/constants.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/dsa.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/elgamal.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/modmath.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/rsa.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/core/sss.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/profiler.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/py.typed +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/transcrypto.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/__init__.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/human.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/logging.py +0 -0
- {transcrypto-2.2.0 → transcrypto-2.3.0}/src/transcrypto/utils/saferandom.py +0 -0
- {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.
|
|
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.
|
|
20
|
-
Requires-Dist: platformdirs (>=4.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
120
|
+
typer = { version = "^0.23", extras = ["all"] }
|
|
121
121
|
|
|
122
122
|
[tool.poetry.group.dev.dependencies]
|
|
123
123
|
|
|
124
|
-
ruff = "~0.
|
|
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
|
|
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.
|
|
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 =
|
|
65
|
-
if not full_path
|
|
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
|
|
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
|
-
|
|
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
|
|
672
|
-
string (input); if you use this option, `data` will be ignored.
|
|
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
|
-
|
|
693
|
-
|
|
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 =
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
lambda z:
|
|
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 /
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
233
|
+
pdf_val: float = NSG(math.exp(log_pdf))
|
|
234
234
|
x1: float = x0 - (cdf_val - q) / pdf_val
|
|
235
|
-
if
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|