mm-std 0.4.17__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mm_std/__init__.py +15 -56
- mm_std/{date.py → date_utils.py} +11 -18
- mm_std/dict_utils.py +63 -0
- mm_std/json_utils.py +112 -0
- mm_std/random_utils.py +72 -0
- mm_std-0.5.0.dist-info/METADATA +4 -0
- mm_std-0.5.0.dist-info/RECORD +9 -0
- mm_std/command.py +0 -35
- mm_std/concurrency/__init__.py +0 -0
- mm_std/concurrency/async_decorators.py +0 -54
- mm_std/concurrency/async_scheduler.py +0 -172
- mm_std/concurrency/async_task_runner.py +0 -126
- mm_std/concurrency/sync_decorators.py +0 -35
- mm_std/concurrency/sync_scheduler.py +0 -73
- mm_std/concurrency/sync_task_runner.py +0 -45
- mm_std/config.py +0 -81
- mm_std/crypto/__init__.py +0 -0
- mm_std/crypto/fernet.py +0 -13
- mm_std/crypto/openssl.py +0 -207
- mm_std/dict.py +0 -49
- mm_std/env.py +0 -9
- mm_std/fs.py +0 -13
- mm_std/http/__init__.py +0 -0
- mm_std/http/http_request.py +0 -126
- mm_std/http/http_request_sync.py +0 -63
- mm_std/http/http_response.py +0 -119
- mm_std/json_.py +0 -38
- mm_std/log.py +0 -85
- mm_std/net.py +0 -22
- mm_std/print_.py +0 -58
- mm_std/random_.py +0 -35
- mm_std/result.py +0 -315
- mm_std/str.py +0 -105
- mm_std/toml.py +0 -22
- mm_std/types_.py +0 -8
- mm_std/zip.py +0 -9
- mm_std-0.4.17.dist-info/METADATA +0 -14
- mm_std-0.4.17.dist-info/RECORD +0 -35
- {mm_std-0.4.17.dist-info → mm_std-0.5.0.dist-info}/WHEEL +0 -0
@@ -1,126 +0,0 @@
|
|
1
|
-
from __future__ import annotations
|
2
|
-
|
3
|
-
import asyncio
|
4
|
-
import logging
|
5
|
-
from collections.abc import Awaitable
|
6
|
-
from dataclasses import dataclass
|
7
|
-
from typing import Any
|
8
|
-
|
9
|
-
logger = logging.getLogger(__name__)
|
10
|
-
|
11
|
-
|
12
|
-
class AsyncTaskRunner:
|
13
|
-
"""
|
14
|
-
AsyncTaskRunner executes a batch of asynchronous tasks with controlled concurrency.
|
15
|
-
Note: This runner is designed for one-time use. Create a new instance for each batch of tasks.
|
16
|
-
"""
|
17
|
-
|
18
|
-
@dataclass
|
19
|
-
class Result:
|
20
|
-
results: dict[str, Any] # Maps task_id to result
|
21
|
-
exceptions: dict[str, Any] # Maps task_id to exception (if any)
|
22
|
-
is_ok: bool # True if no exception and no timeout occurred
|
23
|
-
is_timeout: bool # True if at least one task was cancelled due to timeout
|
24
|
-
|
25
|
-
@dataclass
|
26
|
-
class Task:
|
27
|
-
"""Individual task representation"""
|
28
|
-
|
29
|
-
task_id: str
|
30
|
-
awaitable: Awaitable[Any]
|
31
|
-
|
32
|
-
def __init__(
|
33
|
-
self, max_concurrent_tasks: int, timeout: float | None = None, name: str | None = None, no_logging: bool = False
|
34
|
-
) -> None:
|
35
|
-
"""
|
36
|
-
:param max_concurrent_tasks: Maximum number of tasks that can run concurrently.
|
37
|
-
:param timeout: Optional overall timeout in seconds for running all tasks.
|
38
|
-
:param name: Optional name for the runner.
|
39
|
-
:param no_logging: If True, suppresses logging for task exception.
|
40
|
-
"""
|
41
|
-
if timeout is not None and timeout <= 0:
|
42
|
-
raise ValueError("Timeout must be positive if specified.")
|
43
|
-
self.max_concurrent_tasks: int = max_concurrent_tasks
|
44
|
-
self.timeout: float | None = timeout
|
45
|
-
self.name = name
|
46
|
-
self.no_logging = no_logging
|
47
|
-
self.semaphore: asyncio.Semaphore = asyncio.Semaphore(max_concurrent_tasks)
|
48
|
-
self._tasks: list[AsyncTaskRunner.Task] = []
|
49
|
-
self._was_run: bool = False
|
50
|
-
self._task_ids: set[str] = set()
|
51
|
-
|
52
|
-
def add_task(
|
53
|
-
self,
|
54
|
-
task_id: str,
|
55
|
-
awaitable: Awaitable[Any],
|
56
|
-
) -> None:
|
57
|
-
"""
|
58
|
-
Adds a task to the runner that will be executed when run() is called.
|
59
|
-
|
60
|
-
:param task_id: Unique identifier for the task.
|
61
|
-
:param awaitable: The awaitable (coroutine) to execute.
|
62
|
-
:raises RuntimeError: If the runner has already been used.
|
63
|
-
:raises ValueError: If task_id is empty or already exists.
|
64
|
-
"""
|
65
|
-
if self._was_run:
|
66
|
-
raise RuntimeError("This AsyncTaskRunner has already been used. Create a new instance for new tasks.")
|
67
|
-
|
68
|
-
if not task_id:
|
69
|
-
raise ValueError("Task ID cannot be empty")
|
70
|
-
|
71
|
-
if task_id in self._task_ids:
|
72
|
-
raise ValueError(f"Task ID '{task_id}' already exists. All task IDs must be unique.")
|
73
|
-
|
74
|
-
self._task_ids.add(task_id)
|
75
|
-
self._tasks.append(AsyncTaskRunner.Task(task_id, awaitable))
|
76
|
-
|
77
|
-
def _task_name(self, task_id: str) -> str:
|
78
|
-
return f"{self.name}-{task_id}" if self.name else task_id
|
79
|
-
|
80
|
-
async def run(self) -> AsyncTaskRunner.Result:
|
81
|
-
"""
|
82
|
-
Executes all added tasks with concurrency limited by the semaphore.
|
83
|
-
If a timeout is specified, non-finished tasks are cancelled.
|
84
|
-
|
85
|
-
:return: AsyncTaskRunner.Result containing task results, exceptions, and flags indicating overall status.
|
86
|
-
:raises RuntimeError: If the runner has already been used.
|
87
|
-
"""
|
88
|
-
if self._was_run:
|
89
|
-
raise RuntimeError("This AsyncTaskRunner instance can only be run once. Create a new instance for new tasks.")
|
90
|
-
|
91
|
-
self._was_run = True
|
92
|
-
results: dict[str, Any] = {}
|
93
|
-
exceptions: dict[str, Any] = {}
|
94
|
-
is_timeout: bool = False
|
95
|
-
|
96
|
-
async def run_task(task: AsyncTaskRunner.Task) -> None:
|
97
|
-
async with self.semaphore:
|
98
|
-
try:
|
99
|
-
res: Any = await task.awaitable
|
100
|
-
results[task.task_id] = res
|
101
|
-
except Exception as e:
|
102
|
-
if not self.no_logging:
|
103
|
-
logger.exception("Task raised an exception", extra={"task_id": task.task_id})
|
104
|
-
exceptions[task.task_id] = e
|
105
|
-
|
106
|
-
# Create asyncio tasks for all runner tasks
|
107
|
-
tasks = [asyncio.create_task(run_task(task), name=self._task_name(task.task_id)) for task in self._tasks]
|
108
|
-
|
109
|
-
try:
|
110
|
-
if self.timeout is not None:
|
111
|
-
# Run with timeout
|
112
|
-
await asyncio.wait_for(asyncio.gather(*tasks), timeout=self.timeout)
|
113
|
-
else:
|
114
|
-
# Run without timeout
|
115
|
-
await asyncio.gather(*tasks)
|
116
|
-
except TimeoutError:
|
117
|
-
# Cancel all running tasks on timeout
|
118
|
-
for task in tasks:
|
119
|
-
if not task.done():
|
120
|
-
task.cancel()
|
121
|
-
# Wait for tasks to complete cancellation
|
122
|
-
await asyncio.gather(*tasks, return_exceptions=True)
|
123
|
-
is_timeout = True
|
124
|
-
|
125
|
-
is_ok: bool = (not exceptions) and (not is_timeout)
|
126
|
-
return AsyncTaskRunner.Result(results=results, exceptions=exceptions, is_ok=is_ok, is_timeout=is_timeout)
|
@@ -1,35 +0,0 @@
|
|
1
|
-
import functools
|
2
|
-
from collections import defaultdict
|
3
|
-
from collections.abc import Callable
|
4
|
-
from threading import Lock
|
5
|
-
|
6
|
-
|
7
|
-
def synchronized_parameter[T, **P](arg_index: int = 0, skip_if_locked: bool = False) -> Callable[..., Callable[P, T | None]]:
|
8
|
-
locks: dict[object, Lock] = defaultdict(Lock)
|
9
|
-
|
10
|
-
def outer(func: Callable[P, T]) -> Callable[P, T | None]:
|
11
|
-
@functools.wraps(func)
|
12
|
-
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | None:
|
13
|
-
if skip_if_locked and locks[args[arg_index]].locked():
|
14
|
-
return None
|
15
|
-
try:
|
16
|
-
with locks[args[arg_index]]:
|
17
|
-
return func(*args, **kwargs)
|
18
|
-
finally:
|
19
|
-
locks.pop(args[arg_index], None)
|
20
|
-
|
21
|
-
wrapper.locks = locks # type: ignore[attr-defined]
|
22
|
-
return wrapper
|
23
|
-
|
24
|
-
return outer
|
25
|
-
|
26
|
-
|
27
|
-
def synchronized[T, **P](fn: Callable[P, T]) -> Callable[P, T]:
|
28
|
-
lock = Lock()
|
29
|
-
|
30
|
-
@functools.wraps(fn)
|
31
|
-
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
|
32
|
-
with lock:
|
33
|
-
return fn(*args, **kwargs)
|
34
|
-
|
35
|
-
return wrapper
|
@@ -1,73 +0,0 @@
|
|
1
|
-
import time
|
2
|
-
from dataclasses import dataclass, field
|
3
|
-
from datetime import datetime
|
4
|
-
from logging import Logger
|
5
|
-
from threading import Thread
|
6
|
-
|
7
|
-
from mm_std.date import is_too_old, utc_now
|
8
|
-
from mm_std.types_ import Func
|
9
|
-
|
10
|
-
|
11
|
-
class Scheduler:
|
12
|
-
def __init__(self, log: Logger, loop_delay: float = 0.5, debug: bool = False) -> None:
|
13
|
-
self.log = log
|
14
|
-
self.debug = debug
|
15
|
-
self.loop_delay = loop_delay
|
16
|
-
self.stopped = False
|
17
|
-
self.jobs: list[Scheduler.Job] = []
|
18
|
-
self.run_immediately_jobs: list[Scheduler.Job] = []
|
19
|
-
self._debug("init")
|
20
|
-
|
21
|
-
@dataclass
|
22
|
-
class Job:
|
23
|
-
func: Func
|
24
|
-
args: tuple[object, ...]
|
25
|
-
interval: int
|
26
|
-
is_running: bool = False
|
27
|
-
last_at: datetime = field(default_factory=utc_now)
|
28
|
-
|
29
|
-
def __str__(self) -> str:
|
30
|
-
return str(self.func)
|
31
|
-
|
32
|
-
def add_job(self, func: Func, interval: int, args: tuple[object, ...] = (), run_immediately: bool = False) -> None:
|
33
|
-
job = Scheduler.Job(func, args, interval)
|
34
|
-
self.jobs.append(job)
|
35
|
-
if run_immediately:
|
36
|
-
self.run_immediately_jobs.append(job)
|
37
|
-
|
38
|
-
def _run_job(self, job: Job) -> None:
|
39
|
-
self._debug(f"_run_job: {job}")
|
40
|
-
if self.stopped:
|
41
|
-
return
|
42
|
-
try:
|
43
|
-
job.func(*job.args)
|
44
|
-
self._debug(f"_run_job: {job} done")
|
45
|
-
except Exception:
|
46
|
-
self.log.exception("scheduler error")
|
47
|
-
self._debug(f"_run_job: {job} error")
|
48
|
-
finally:
|
49
|
-
job.is_running = False
|
50
|
-
|
51
|
-
def _start(self) -> None:
|
52
|
-
self._debug(f"_start: jobs={len(self.jobs)}, run_immediately_jobs={len(self.run_immediately_jobs)}")
|
53
|
-
for j in self.run_immediately_jobs:
|
54
|
-
j.is_running = True
|
55
|
-
j.last_at = utc_now()
|
56
|
-
Thread(target=self._run_job, args=(j,)).start()
|
57
|
-
while not self.stopped:
|
58
|
-
for j in self.jobs:
|
59
|
-
if not j.is_running and is_too_old(j.last_at, j.interval):
|
60
|
-
j.is_running = True
|
61
|
-
j.last_at = utc_now()
|
62
|
-
Thread(target=self._run_job, args=(j,)).start()
|
63
|
-
time.sleep(self.loop_delay)
|
64
|
-
|
65
|
-
def _debug(self, message: str) -> None:
|
66
|
-
if self.debug:
|
67
|
-
self.log.debug("Scheduler: %s", message)
|
68
|
-
|
69
|
-
def start(self) -> None:
|
70
|
-
Thread(target=self._start).start()
|
71
|
-
|
72
|
-
def stop(self) -> None:
|
73
|
-
self.stopped = True
|
@@ -1,45 +0,0 @@
|
|
1
|
-
import concurrent
|
2
|
-
from concurrent.futures import ThreadPoolExecutor
|
3
|
-
from dataclasses import dataclass
|
4
|
-
|
5
|
-
from mm_std.types_ import Args, Func, Kwargs
|
6
|
-
|
7
|
-
|
8
|
-
class ConcurrentTasks:
|
9
|
-
def __init__(self, max_workers: int = 5, timeout: int | None = None, thread_name_prefix: str = "concurrent_tasks") -> None:
|
10
|
-
self.max_workers = max_workers
|
11
|
-
self.timeout = timeout
|
12
|
-
self.thread_name_prefix = thread_name_prefix
|
13
|
-
self.tasks: list[ConcurrentTasks.Task] = []
|
14
|
-
self.exceptions: dict[str, Exception] = {}
|
15
|
-
self.error = False
|
16
|
-
self.timeout_error = False
|
17
|
-
self.result: dict[str, object] = {}
|
18
|
-
|
19
|
-
@dataclass
|
20
|
-
class Task:
|
21
|
-
key: str
|
22
|
-
func: Func
|
23
|
-
args: Args
|
24
|
-
kwargs: Kwargs
|
25
|
-
|
26
|
-
def add_task(self, key: str, func: Func, args: Args = (), kwargs: Kwargs | None = None) -> None:
|
27
|
-
if kwargs is None:
|
28
|
-
kwargs = {}
|
29
|
-
self.tasks.append(ConcurrentTasks.Task(key, func, args, kwargs))
|
30
|
-
|
31
|
-
def execute(self) -> None:
|
32
|
-
with ThreadPoolExecutor(self.max_workers, thread_name_prefix=self.thread_name_prefix) as executor:
|
33
|
-
future_to_key = {executor.submit(task.func, *task.args, **task.kwargs): task.key for task in self.tasks}
|
34
|
-
try:
|
35
|
-
result_map = concurrent.futures.as_completed(future_to_key, timeout=self.timeout)
|
36
|
-
for future in result_map:
|
37
|
-
key = future_to_key[future]
|
38
|
-
try:
|
39
|
-
self.result[key] = future.result()
|
40
|
-
except Exception as err:
|
41
|
-
self.error = True
|
42
|
-
self.exceptions[key] = err
|
43
|
-
except concurrent.futures.TimeoutError:
|
44
|
-
self.error = True
|
45
|
-
self.timeout_error = True
|
mm_std/config.py
DELETED
@@ -1,81 +0,0 @@
|
|
1
|
-
import sys
|
2
|
-
import tomllib
|
3
|
-
from pathlib import Path
|
4
|
-
from typing import Any, NoReturn, Self, TypeVar
|
5
|
-
|
6
|
-
from pydantic import BaseModel, ConfigDict, ValidationError
|
7
|
-
|
8
|
-
from .print_ import print_json, print_plain
|
9
|
-
from .result import Result
|
10
|
-
from .zip import read_text_from_zip_archive
|
11
|
-
|
12
|
-
T = TypeVar("T", bound="BaseConfig")
|
13
|
-
|
14
|
-
|
15
|
-
class BaseConfig(BaseModel):
|
16
|
-
model_config = ConfigDict(extra="forbid")
|
17
|
-
|
18
|
-
def print_and_exit(self, exclude: set[str] | None = None, count: set[str] | None = None) -> NoReturn:
|
19
|
-
data = self.model_dump(exclude=exclude)
|
20
|
-
if count:
|
21
|
-
for k in count:
|
22
|
-
data[k] = len(data[k])
|
23
|
-
print_json(data)
|
24
|
-
sys.exit(0)
|
25
|
-
|
26
|
-
@classmethod
|
27
|
-
def read_toml_config_or_exit(cls, config_path: Path, zip_password: str = "") -> Self: # nosec
|
28
|
-
res: Result[Self] = cls.read_toml_config(config_path, zip_password)
|
29
|
-
if res.is_ok():
|
30
|
-
return res.unwrap()
|
31
|
-
cls._print_error_and_exit(res)
|
32
|
-
|
33
|
-
@classmethod
|
34
|
-
async def read_toml_config_or_exit_async(cls, config_path: Path, zip_password: str = "") -> Self: # nosec
|
35
|
-
res: Result[Self] = await cls.read_toml_config_async(config_path, zip_password)
|
36
|
-
if res.is_ok():
|
37
|
-
return res.unwrap()
|
38
|
-
cls._print_error_and_exit(res)
|
39
|
-
|
40
|
-
@classmethod
|
41
|
-
def read_toml_config(cls, config_path: Path, zip_password: str = "") -> Result[Self]: # nosec
|
42
|
-
try:
|
43
|
-
config_path = config_path.expanduser()
|
44
|
-
if config_path.name.endswith(".zip"):
|
45
|
-
data = tomllib.loads(read_text_from_zip_archive(config_path, password=zip_password))
|
46
|
-
else:
|
47
|
-
with config_path.open("rb") as f:
|
48
|
-
data = tomllib.load(f)
|
49
|
-
return Result.ok(cls(**data))
|
50
|
-
except ValidationError as e:
|
51
|
-
return Result.err(("validator_error", e), extra={"errors": e.errors()})
|
52
|
-
except Exception as e:
|
53
|
-
return Result.err(e)
|
54
|
-
|
55
|
-
@classmethod
|
56
|
-
async def read_toml_config_async(cls, config_path: Path, zip_password: str = "") -> Result[Self]: # nosec
|
57
|
-
try:
|
58
|
-
config_path = config_path.expanduser()
|
59
|
-
if config_path.name.endswith(".zip"):
|
60
|
-
data = tomllib.loads(read_text_from_zip_archive(config_path, password=zip_password))
|
61
|
-
else:
|
62
|
-
with config_path.open("rb") as f:
|
63
|
-
data = tomllib.load(f)
|
64
|
-
model = await cls.model_validate(data) # type:ignore[misc]
|
65
|
-
return Result.ok(model)
|
66
|
-
except ValidationError as e:
|
67
|
-
return Result.err(("validator_error", e), extra={"errors": e.errors()})
|
68
|
-
except Exception as e:
|
69
|
-
return Result.err(e)
|
70
|
-
|
71
|
-
@classmethod
|
72
|
-
def _print_error_and_exit(cls, res: Result[Any]) -> NoReturn:
|
73
|
-
if res.error == "validator_error" and res.extra:
|
74
|
-
print_plain("config validation errors")
|
75
|
-
for e in res.extra["errors"]:
|
76
|
-
loc = e["loc"]
|
77
|
-
field = ".".join(str(lo) for lo in loc) if len(loc) > 0 else ""
|
78
|
-
print_plain(f"{field} {e['msg']}")
|
79
|
-
else:
|
80
|
-
print_plain(f"can't parse config file: {res.error} {res.exception}")
|
81
|
-
sys.exit(1)
|
mm_std/crypto/__init__.py
DELETED
File without changes
|
mm_std/crypto/fernet.py
DELETED
@@ -1,13 +0,0 @@
|
|
1
|
-
from cryptography.fernet import Fernet
|
2
|
-
|
3
|
-
|
4
|
-
def fernet_generate_key() -> str:
|
5
|
-
return Fernet.generate_key().decode()
|
6
|
-
|
7
|
-
|
8
|
-
def fernet_encrypt(*, data: str, key: str) -> str:
|
9
|
-
return Fernet(key).encrypt(data.encode()).decode()
|
10
|
-
|
11
|
-
|
12
|
-
def fernet_decrypt(*, encoded_data: str, key: str) -> str:
|
13
|
-
return Fernet(key).decrypt(encoded_data).decode()
|
mm_std/crypto/openssl.py
DELETED
@@ -1,207 +0,0 @@
|
|
1
|
-
from base64 import b64decode, b64encode
|
2
|
-
from hashlib import pbkdf2_hmac
|
3
|
-
from os import urandom
|
4
|
-
from pathlib import Path
|
5
|
-
|
6
|
-
from cryptography.hazmat.primitives import padding
|
7
|
-
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
8
|
-
|
9
|
-
MAGIC = b"Salted__"
|
10
|
-
SALT_SIZE = 8
|
11
|
-
KEY_SIZE = 32 # for AES-256
|
12
|
-
IV_SIZE = 16
|
13
|
-
|
14
|
-
|
15
|
-
def openssl_encrypt(input_path: Path, output_path: Path, password: str, iterations: int = 1_000_000) -> None:
|
16
|
-
"""
|
17
|
-
Encrypt a file using OpenSSL-compatible AES-256-CBC with PBKDF2 and output in binary format.
|
18
|
-
|
19
|
-
This function creates encrypted files that are fully compatible with OpenSSL command:
|
20
|
-
openssl enc -aes-256-cbc -pbkdf2 -iter 1000000 -salt -in message.txt -out message.enc
|
21
|
-
|
22
|
-
Args:
|
23
|
-
input_path: Path to the input file to encrypt
|
24
|
-
output_path: Path where the encrypted binary file will be saved
|
25
|
-
password: Password for encryption
|
26
|
-
iterations: Number of PBKDF2 iterations (minimum 1000, default 1,000,000)
|
27
|
-
|
28
|
-
Raises:
|
29
|
-
ValueError: If iterations < 1000
|
30
|
-
|
31
|
-
Example:
|
32
|
-
>>> from pathlib import Path
|
33
|
-
>>> openssl_encrypt(Path("secret.txt"), Path("secret.enc"), "mypassword")
|
34
|
-
|
35
|
-
# Decrypt with OpenSSL:
|
36
|
-
# openssl enc -d -aes-256-cbc -pbkdf2 -iter 1000000 -in secret.enc -out secret_decrypted.txt -pass pass:mypassword
|
37
|
-
"""
|
38
|
-
if iterations < 1000:
|
39
|
-
raise ValueError("Iteration count must be at least 1000 for security")
|
40
|
-
|
41
|
-
data: bytes = input_path.read_bytes()
|
42
|
-
salt: bytes = urandom(SALT_SIZE)
|
43
|
-
|
44
|
-
key_iv: bytes = pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations, KEY_SIZE + IV_SIZE)
|
45
|
-
key: bytes = key_iv[:KEY_SIZE]
|
46
|
-
iv: bytes = key_iv[KEY_SIZE:]
|
47
|
-
|
48
|
-
padder = padding.PKCS7(algorithms.AES.block_size).padder()
|
49
|
-
padded_data: bytes = padder.update(data) + padder.finalize()
|
50
|
-
|
51
|
-
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
52
|
-
encryptor = cipher.encryptor()
|
53
|
-
ciphertext: bytes = encryptor.update(padded_data) + encryptor.finalize()
|
54
|
-
|
55
|
-
output_path.write_bytes(MAGIC + salt + ciphertext)
|
56
|
-
|
57
|
-
|
58
|
-
def openssl_decrypt(input_path: Path, output_path: Path, password: str, iterations: int = 1_000_000) -> None:
|
59
|
-
"""
|
60
|
-
Decrypt a binary file created by OpenSSL-compatible AES-256-CBC with PBKDF2.
|
61
|
-
|
62
|
-
This function decrypts files that were encrypted with OpenSSL command:
|
63
|
-
openssl enc -aes-256-cbc -pbkdf2 -iter 1000000 -salt -in message.txt -out message.enc
|
64
|
-
|
65
|
-
Args:
|
66
|
-
input_path: Path to the encrypted binary file
|
67
|
-
output_path: Path where the decrypted file will be saved
|
68
|
-
password: Password for decryption
|
69
|
-
iterations: Number of PBKDF2 iterations (minimum 1000, default 1,000,000)
|
70
|
-
|
71
|
-
Raises:
|
72
|
-
ValueError: If iterations < 1000, invalid file format, or wrong password
|
73
|
-
|
74
|
-
Example:
|
75
|
-
>>> from pathlib import Path
|
76
|
-
>>> openssl_decrypt(Path("secret.enc"), Path("secret_decrypted.txt"), "mypassword")
|
77
|
-
|
78
|
-
# Encrypt with OpenSSL:
|
79
|
-
# openssl enc -aes-256-cbc -pbkdf2 -iter 1000000 -salt -in secret.txt -out secret.enc -pass pass:mypassword
|
80
|
-
"""
|
81
|
-
if iterations < 1000:
|
82
|
-
raise ValueError("Iteration count must be at least 1000 for security")
|
83
|
-
|
84
|
-
raw: bytes = input_path.read_bytes()
|
85
|
-
if not raw.startswith(MAGIC):
|
86
|
-
raise ValueError("Invalid file format: missing OpenSSL Salted header")
|
87
|
-
|
88
|
-
salt: bytes = raw[8:16]
|
89
|
-
ciphertext: bytes = raw[16:]
|
90
|
-
|
91
|
-
key_iv: bytes = pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations, KEY_SIZE + IV_SIZE)
|
92
|
-
key: bytes = key_iv[:KEY_SIZE]
|
93
|
-
iv: bytes = key_iv[KEY_SIZE:]
|
94
|
-
|
95
|
-
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
96
|
-
decryptor = cipher.decryptor()
|
97
|
-
padded_plaintext: bytes = decryptor.update(ciphertext) + decryptor.finalize()
|
98
|
-
|
99
|
-
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
|
100
|
-
try:
|
101
|
-
plaintext: bytes = unpadder.update(padded_plaintext) + unpadder.finalize()
|
102
|
-
except ValueError as e:
|
103
|
-
raise ValueError("Decryption failed: invalid padding or wrong password") from e
|
104
|
-
|
105
|
-
output_path.write_bytes(plaintext)
|
106
|
-
|
107
|
-
|
108
|
-
def openssl_encrypt_base64(input_path: Path, output_path: Path, password: str, iterations: int = 1_000_000) -> None:
|
109
|
-
"""
|
110
|
-
Encrypt a file using OpenSSL-compatible AES-256-CBC with PBKDF2 and output in base64 format.
|
111
|
-
|
112
|
-
This function creates encrypted files that are fully compatible with OpenSSL command:
|
113
|
-
openssl enc -aes-256-cbc -pbkdf2 -iter 1000000 -salt -base64 -in message.txt -out message.enc
|
114
|
-
|
115
|
-
Args:
|
116
|
-
input_path: Path to the input file to encrypt
|
117
|
-
output_path: Path where the encrypted base64 file will be saved
|
118
|
-
password: Password for encryption
|
119
|
-
iterations: Number of PBKDF2 iterations (minimum 1000, default 1,000,000)
|
120
|
-
|
121
|
-
Raises:
|
122
|
-
ValueError: If iterations < 1000
|
123
|
-
|
124
|
-
Example:
|
125
|
-
>>> from pathlib import Path
|
126
|
-
>>> openssl_encrypt_base64(Path("secret.txt"), Path("secret.enc"), "mypassword")
|
127
|
-
|
128
|
-
# Decrypt with OpenSSL:
|
129
|
-
# openssl enc -d -aes-256-cbc -pbkdf2 -iter 1000000 -base64 -in secret.enc -out secret_decrypted.txt -pass pass:mypassword
|
130
|
-
"""
|
131
|
-
if iterations < 1000:
|
132
|
-
raise ValueError("Iteration count must be at least 1000 for security")
|
133
|
-
|
134
|
-
data: bytes = input_path.read_bytes()
|
135
|
-
salt: bytes = urandom(SALT_SIZE)
|
136
|
-
|
137
|
-
key_iv: bytes = pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations, KEY_SIZE + IV_SIZE)
|
138
|
-
key: bytes = key_iv[:KEY_SIZE]
|
139
|
-
iv: bytes = key_iv[KEY_SIZE:]
|
140
|
-
|
141
|
-
padder = padding.PKCS7(algorithms.AES.block_size).padder()
|
142
|
-
padded_data: bytes = padder.update(data) + padder.finalize()
|
143
|
-
|
144
|
-
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
145
|
-
encryptor = cipher.encryptor()
|
146
|
-
ciphertext: bytes = encryptor.update(padded_data) + encryptor.finalize()
|
147
|
-
|
148
|
-
# Encode binary data to base64
|
149
|
-
binary_data: bytes = MAGIC + salt + ciphertext
|
150
|
-
base64_data: str = b64encode(binary_data).decode("ascii")
|
151
|
-
output_path.write_text(base64_data)
|
152
|
-
|
153
|
-
|
154
|
-
def openssl_decrypt_base64(input_path: Path, output_path: Path, password: str, iterations: int = 1_000_000) -> None:
|
155
|
-
"""
|
156
|
-
Decrypt a base64-encoded file created by OpenSSL-compatible AES-256-CBC with PBKDF2.
|
157
|
-
|
158
|
-
This function decrypts files that were encrypted with OpenSSL command:
|
159
|
-
openssl enc -aes-256-cbc -pbkdf2 -iter 1000000 -salt -base64 -in message.txt -out message.enc
|
160
|
-
|
161
|
-
Args:
|
162
|
-
input_path: Path to the encrypted base64 file
|
163
|
-
output_path: Path where the decrypted file will be saved
|
164
|
-
password: Password for decryption
|
165
|
-
iterations: Number of PBKDF2 iterations (minimum 1000, default 1,000,000)
|
166
|
-
|
167
|
-
Raises:
|
168
|
-
ValueError: If iterations < 1000, invalid file format, or wrong password
|
169
|
-
|
170
|
-
Example:
|
171
|
-
>>> from pathlib import Path
|
172
|
-
>>> openssl_decrypt_base64(Path("secret.enc"), Path("secret_decrypted.txt"), "mypassword")
|
173
|
-
|
174
|
-
# Encrypt with OpenSSL:
|
175
|
-
# openssl enc -aes-256-cbc -pbkdf2 -iter 1000000 -salt -base64 -in secret.txt -out secret.enc -pass pass:mypassword
|
176
|
-
"""
|
177
|
-
if iterations < 1000:
|
178
|
-
raise ValueError("Iteration count must be at least 1000 for security")
|
179
|
-
|
180
|
-
# Decode base64 to binary data
|
181
|
-
try:
|
182
|
-
base64_data: str = input_path.read_text().strip()
|
183
|
-
raw: bytes = b64decode(base64_data)
|
184
|
-
except Exception as e:
|
185
|
-
raise ValueError("Invalid base64 format") from e
|
186
|
-
|
187
|
-
if not raw.startswith(MAGIC):
|
188
|
-
raise ValueError("Invalid file format: missing OpenSSL Salted header")
|
189
|
-
|
190
|
-
salt: bytes = raw[8:16]
|
191
|
-
ciphertext: bytes = raw[16:]
|
192
|
-
|
193
|
-
key_iv: bytes = pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations, KEY_SIZE + IV_SIZE)
|
194
|
-
key: bytes = key_iv[:KEY_SIZE]
|
195
|
-
iv: bytes = key_iv[KEY_SIZE:]
|
196
|
-
|
197
|
-
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
|
198
|
-
decryptor = cipher.decryptor()
|
199
|
-
padded_plaintext: bytes = decryptor.update(ciphertext) + decryptor.finalize()
|
200
|
-
|
201
|
-
unpadder = padding.PKCS7(algorithms.AES.block_size).unpadder()
|
202
|
-
try:
|
203
|
-
plaintext: bytes = unpadder.update(padded_plaintext) + unpadder.finalize()
|
204
|
-
except ValueError as e:
|
205
|
-
raise ValueError("Decryption failed: invalid padding or wrong password") from e
|
206
|
-
|
207
|
-
output_path.write_bytes(plaintext)
|
mm_std/dict.py
DELETED
@@ -1,49 +0,0 @@
|
|
1
|
-
from collections import defaultdict
|
2
|
-
from collections.abc import Mapping, MutableMapping
|
3
|
-
from typing import TypeVar, cast
|
4
|
-
|
5
|
-
K = TypeVar("K")
|
6
|
-
V = TypeVar("V")
|
7
|
-
DictType = TypeVar("DictType", bound=MutableMapping[K, V]) # type: ignore[valid-type]
|
8
|
-
|
9
|
-
|
10
|
-
def replace_empty_dict_entries(
|
11
|
-
data: DictType,
|
12
|
-
defaults: Mapping[K, V] | None = None,
|
13
|
-
zero_is_empty: bool = False,
|
14
|
-
false_is_empty: bool = False,
|
15
|
-
empty_string_is_empty: bool = True,
|
16
|
-
) -> DictType:
|
17
|
-
"""
|
18
|
-
Replace empty entries in a dictionary with provided default values,
|
19
|
-
or remove them if no default is available. Returns the same type as the input dictionary.
|
20
|
-
"""
|
21
|
-
if defaults is None:
|
22
|
-
defaults = {}
|
23
|
-
|
24
|
-
try:
|
25
|
-
if isinstance(data, defaultdict):
|
26
|
-
result: MutableMapping[K, V] = defaultdict(data.default_factory)
|
27
|
-
else:
|
28
|
-
result = data.__class__()
|
29
|
-
except Exception:
|
30
|
-
result = {}
|
31
|
-
|
32
|
-
for key, value in data.items():
|
33
|
-
should_replace = (
|
34
|
-
value is None
|
35
|
-
or (empty_string_is_empty and value == "")
|
36
|
-
or (zero_is_empty and value == 0)
|
37
|
-
or (false_is_empty and value is False)
|
38
|
-
)
|
39
|
-
|
40
|
-
if should_replace:
|
41
|
-
if key in defaults:
|
42
|
-
new_value = defaults[key]
|
43
|
-
else:
|
44
|
-
continue # Skip the key if no default is available
|
45
|
-
else:
|
46
|
-
new_value = value
|
47
|
-
|
48
|
-
result[key] = new_value
|
49
|
-
return cast(DictType, result)
|
mm_std/env.py
DELETED
mm_std/fs.py
DELETED
@@ -1,13 +0,0 @@
|
|
1
|
-
from pathlib import Path
|
2
|
-
|
3
|
-
|
4
|
-
def read_text(path: str | Path) -> str:
|
5
|
-
if isinstance(path, str):
|
6
|
-
path = Path(path)
|
7
|
-
return path.read_text()
|
8
|
-
|
9
|
-
|
10
|
-
def get_filename_without_extension(path: str | Path) -> str:
|
11
|
-
if isinstance(path, str):
|
12
|
-
path = Path(path)
|
13
|
-
return path.stem
|
mm_std/http/__init__.py
DELETED
File without changes
|