passiveincome-commonpackage 1.0.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.
- passiveincome_commonpackage-1.0.0/PKG-INFO +19 -0
- passiveincome_commonpackage-1.0.0/README.md +6 -0
- passiveincome_commonpackage-1.0.0/pyproject.toml +33 -0
- passiveincome_commonpackage-1.0.0/setup.cfg +4 -0
- passiveincome_commonpackage-1.0.0/src/common_package/__init__.py +10 -0
- passiveincome_commonpackage-1.0.0/src/common_package/config/__init__.py +4 -0
- passiveincome_commonpackage-1.0.0/src/common_package/config/config.py +26 -0
- passiveincome_commonpackage-1.0.0/src/common_package/config/utils.py +42 -0
- passiveincome_commonpackage-1.0.0/src/common_package/logging/__init__.py +4 -0
- passiveincome_commonpackage-1.0.0/src/common_package/logging/logger.py +50 -0
- passiveincome_commonpackage-1.0.0/src/common_package/logging/settings.py +25 -0
- passiveincome_commonpackage-1.0.0/src/passiveincome_commonpackage.egg-info/PKG-INFO +19 -0
- passiveincome_commonpackage-1.0.0/src/passiveincome_commonpackage.egg-info/SOURCES.txt +16 -0
- passiveincome_commonpackage-1.0.0/src/passiveincome_commonpackage.egg-info/dependency_links.txt +1 -0
- passiveincome_commonpackage-1.0.0/src/passiveincome_commonpackage.egg-info/requires.txt +6 -0
- passiveincome_commonpackage-1.0.0/src/passiveincome_commonpackage.egg-info/top_level.txt +1 -0
- passiveincome_commonpackage-1.0.0/tests/test_config.py +84 -0
- passiveincome_commonpackage-1.0.0/tests/test_logging.py +80 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: passiveincome-commonpackage
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Общий пакет для Python-приложений
|
|
5
|
+
Author-email: Salim <salim.buskunov.2000@gmail.com>
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# PassiveIncome.CommonPackage
|
|
15
|
+
Общий пакет для Python-приложений
|
|
16
|
+
|
|
17
|
+
Пакет предоставляет готовые фнукциональности для создания Python-приложений:
|
|
18
|
+
1. Конфигурирование.
|
|
19
|
+
2. Логирование.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools >= 77.0.3", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "passiveincome-commonpackage"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name="Salim", email="salim.buskunov.2000@gmail.com" },
|
|
10
|
+
]
|
|
11
|
+
description = "Общий пакет для Python-приложений"
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.13"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"pydantic>=2.0",
|
|
16
|
+
"pydantic-settings>=2.0",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
dev = [
|
|
21
|
+
"build>=1.2",
|
|
22
|
+
"pytest>=8.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
package-dir = {"" = "src"}
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
|
30
|
+
include = ["common_package*"]
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from common_package.config import AppSettings, SENSITIVE_KEYWORDS, key_masking
|
|
2
|
+
from common_package.logging import LoggingSettings, configure_logging
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"AppSettings",
|
|
6
|
+
"LoggingSettings",
|
|
7
|
+
"SENSITIVE_KEYWORDS",
|
|
8
|
+
"configure_logging",
|
|
9
|
+
"key_masking",
|
|
10
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from pydantic_settings import BaseSettings
|
|
2
|
+
from .utils import key_masking
|
|
3
|
+
from typing import Any, Final
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
SENSITIVE_KEYWORDS: Final[tuple[str, ...]] = (
|
|
7
|
+
"password",
|
|
8
|
+
"passwd",
|
|
9
|
+
"pwd",
|
|
10
|
+
"secret",
|
|
11
|
+
"token",
|
|
12
|
+
"key",
|
|
13
|
+
"credential",
|
|
14
|
+
"auth",
|
|
15
|
+
"session",
|
|
16
|
+
"cookie",
|
|
17
|
+
"salt",
|
|
18
|
+
"hash",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
class AppSettings(BaseSettings):
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def safe_dump(self) -> dict[str, Any]:
|
|
25
|
+
"""Замаскированный конфиг."""
|
|
26
|
+
return key_masking(self.model_dump(), SENSITIVE_KEYWORDS)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from typing import Any, Iterable
|
|
2
|
+
|
|
3
|
+
from typing import Any, Iterable
|
|
4
|
+
|
|
5
|
+
def key_masking(
|
|
6
|
+
data: dict[str, Any],
|
|
7
|
+
key_words: Iterable[str],
|
|
8
|
+
mask: str = "***",
|
|
9
|
+
) -> dict[str, Any]:
|
|
10
|
+
"""Рекурсивно маскирует значения по совпадению ключей.
|
|
11
|
+
|
|
12
|
+
Ключ считается чувствительным, если любое из `key_words`
|
|
13
|
+
встречается в нём как подстрока (без учёта регистра).
|
|
14
|
+
Обходит вложенные dict и list, исходные данные не мутирует.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
data: исходный словарь.
|
|
18
|
+
key_words: список подстрок для поиска в ключах.
|
|
19
|
+
mask: чем заменять значение.
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Новый словарь с замаскированными значениями.
|
|
23
|
+
"""
|
|
24
|
+
needles = {w.lower() for w in key_words}
|
|
25
|
+
|
|
26
|
+
def _is_sensitive(key: str) -> bool:
|
|
27
|
+
low = key.lower()
|
|
28
|
+
return any(n in low for n in needles)
|
|
29
|
+
|
|
30
|
+
def _walk(value: Any) -> Any:
|
|
31
|
+
if isinstance(value, dict):
|
|
32
|
+
return {
|
|
33
|
+
k: (mask if _is_sensitive(k) else _walk(v))
|
|
34
|
+
for k, v in value.items()
|
|
35
|
+
}
|
|
36
|
+
if isinstance(value, list):
|
|
37
|
+
return [_walk(item) for item in value]
|
|
38
|
+
if isinstance(value, tuple):
|
|
39
|
+
return tuple(_walk(item) for item in value)
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
return _walk(data)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Helpers for configuring application logging."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from logging.handlers import RotatingFileHandler
|
|
5
|
+
|
|
6
|
+
from common_package.logging.settings import LoggingSettings
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def configure_logging(settings: LoggingSettings) -> None:
|
|
10
|
+
"""Configure the root logger using the supplied settings."""
|
|
11
|
+
formatter = logging.Formatter(
|
|
12
|
+
fmt=settings.format,
|
|
13
|
+
datefmt=settings.date_format,
|
|
14
|
+
)
|
|
15
|
+
handlers: list[logging.Handler] = []
|
|
16
|
+
|
|
17
|
+
if settings.stream:
|
|
18
|
+
handlers.append(logging.StreamHandler())
|
|
19
|
+
|
|
20
|
+
if settings.file_path is not None:
|
|
21
|
+
if settings.max_bytes > 0:
|
|
22
|
+
file_handler: logging.Handler = RotatingFileHandler(
|
|
23
|
+
settings.file_path,
|
|
24
|
+
mode=settings.file_mode,
|
|
25
|
+
maxBytes=settings.max_bytes,
|
|
26
|
+
backupCount=settings.backup_count,
|
|
27
|
+
encoding=settings.file_encoding,
|
|
28
|
+
)
|
|
29
|
+
else:
|
|
30
|
+
file_handler = logging.FileHandler(
|
|
31
|
+
settings.file_path,
|
|
32
|
+
mode=settings.file_mode,
|
|
33
|
+
encoding=settings.file_encoding,
|
|
34
|
+
)
|
|
35
|
+
handlers.append(file_handler)
|
|
36
|
+
|
|
37
|
+
if not handlers:
|
|
38
|
+
handlers.append(logging.NullHandler())
|
|
39
|
+
|
|
40
|
+
for handler in handlers:
|
|
41
|
+
handler.setFormatter(formatter)
|
|
42
|
+
|
|
43
|
+
root_logger = logging.getLogger()
|
|
44
|
+
for handler in root_logger.handlers[:]:
|
|
45
|
+
root_logger.removeHandler(handler)
|
|
46
|
+
handler.close()
|
|
47
|
+
|
|
48
|
+
root_logger.setLevel(settings.level)
|
|
49
|
+
for handler in handlers:
|
|
50
|
+
root_logger.addHandler(handler)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Settings for configuring application logging."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LoggingSettings(BaseSettings):
|
|
10
|
+
"""Environment-variable-backed logging configuration."""
|
|
11
|
+
|
|
12
|
+
model_config = SettingsConfigDict(
|
|
13
|
+
env_prefix="LOGGING_",
|
|
14
|
+
extra="ignore",
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
level: Literal["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] = "INFO"
|
|
18
|
+
format: str = "%(asctime)s %(levelname)s [%(name)s] %(message)s"
|
|
19
|
+
date_format: str = "%Y-%m-%d %H:%M:%S"
|
|
20
|
+
stream: bool = True
|
|
21
|
+
file_path: str | None = None
|
|
22
|
+
file_mode: Literal["a", "w"] = "a"
|
|
23
|
+
file_encoding: str = "utf-8"
|
|
24
|
+
max_bytes: int = Field(default=0, ge=0)
|
|
25
|
+
backup_count: int = Field(default=0, ge=0)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: passiveincome-commonpackage
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Общий пакет для Python-приложений
|
|
5
|
+
Author-email: Salim <salim.buskunov.2000@gmail.com>
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: pydantic>=2.0
|
|
9
|
+
Requires-Dist: pydantic-settings>=2.0
|
|
10
|
+
Provides-Extra: dev
|
|
11
|
+
Requires-Dist: build>=1.2; extra == "dev"
|
|
12
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# PassiveIncome.CommonPackage
|
|
15
|
+
Общий пакет для Python-приложений
|
|
16
|
+
|
|
17
|
+
Пакет предоставляет готовые фнукциональности для создания Python-приложений:
|
|
18
|
+
1. Конфигурирование.
|
|
19
|
+
2. Логирование.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/common_package/__init__.py
|
|
4
|
+
src/common_package/config/__init__.py
|
|
5
|
+
src/common_package/config/config.py
|
|
6
|
+
src/common_package/config/utils.py
|
|
7
|
+
src/common_package/logging/__init__.py
|
|
8
|
+
src/common_package/logging/logger.py
|
|
9
|
+
src/common_package/logging/settings.py
|
|
10
|
+
src/passiveincome_commonpackage.egg-info/PKG-INFO
|
|
11
|
+
src/passiveincome_commonpackage.egg-info/SOURCES.txt
|
|
12
|
+
src/passiveincome_commonpackage.egg-info/dependency_links.txt
|
|
13
|
+
src/passiveincome_commonpackage.egg-info/requires.txt
|
|
14
|
+
src/passiveincome_commonpackage.egg-info/top_level.txt
|
|
15
|
+
tests/test_config.py
|
|
16
|
+
tests/test_logging.py
|
passiveincome_commonpackage-1.0.0/src/passiveincome_commonpackage.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
common_package
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from pydantic import ValidationError
|
|
3
|
+
|
|
4
|
+
from common_package.config import AppSettings, key_masking
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_key_masking_recurses_and_does_not_mutate_input() -> None:
|
|
8
|
+
data = {
|
|
9
|
+
"username": "alice",
|
|
10
|
+
"api_token": "token-value",
|
|
11
|
+
"profile": {"PASSWORD": "password-value", "label": "public"},
|
|
12
|
+
"items": [{"access_key": "key-value", "count": 2}],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
result = key_masking(data, ("token", "password", "key"))
|
|
16
|
+
|
|
17
|
+
assert result == {
|
|
18
|
+
"username": "alice",
|
|
19
|
+
"api_token": "***",
|
|
20
|
+
"profile": {"PASSWORD": "***", "label": "public"},
|
|
21
|
+
"items": [{"access_key": "***", "count": 2}],
|
|
22
|
+
}
|
|
23
|
+
assert data["api_token"] == "token-value"
|
|
24
|
+
assert data["profile"]["PASSWORD"] == "password-value"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_key_masking_preserves_tuple_and_supports_custom_mask() -> None:
|
|
28
|
+
result = key_masking(
|
|
29
|
+
{"session": "private", "values": (1, {"secret": "hidden"})},
|
|
30
|
+
("session", "secret"),
|
|
31
|
+
mask="[redacted]",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
assert result == {
|
|
35
|
+
"session": "[redacted]",
|
|
36
|
+
"values": (1, {"secret": "[redacted]"}),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_app_settings_safe_dump_masks_sensitive_fields() -> None:
|
|
41
|
+
class ExampleSettings(AppSettings):
|
|
42
|
+
api_token: str = "token-value"
|
|
43
|
+
database: dict[str, str] = {
|
|
44
|
+
"password": "password-value",
|
|
45
|
+
"host": "localhost",
|
|
46
|
+
}
|
|
47
|
+
public_name: str = "example"
|
|
48
|
+
|
|
49
|
+
settings = ExampleSettings()
|
|
50
|
+
|
|
51
|
+
assert settings.safe_dump() == {
|
|
52
|
+
"api_token": "***",
|
|
53
|
+
"database": {"password": "***", "host": "localhost"},
|
|
54
|
+
"public_name": "example",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_logging_settings_are_loaded_from_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
59
|
+
monkeypatch.setenv("LOGGING_LEVEL", "DEBUG")
|
|
60
|
+
monkeypatch.setenv("LOGGING_STREAM", "false")
|
|
61
|
+
monkeypatch.setenv("LOGGING_MAX_BYTES", "1024")
|
|
62
|
+
|
|
63
|
+
from common_package.logging import LoggingSettings
|
|
64
|
+
|
|
65
|
+
settings = LoggingSettings()
|
|
66
|
+
|
|
67
|
+
assert settings.level == "DEBUG"
|
|
68
|
+
assert settings.stream is False
|
|
69
|
+
assert settings.max_bytes == 1024
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.parametrize(
|
|
73
|
+
"values",
|
|
74
|
+
[
|
|
75
|
+
{"level": "TRACE"},
|
|
76
|
+
{"max_bytes": -1},
|
|
77
|
+
{"backup_count": -1},
|
|
78
|
+
],
|
|
79
|
+
)
|
|
80
|
+
def test_logging_settings_reject_invalid_values(values: dict[str, object]) -> None:
|
|
81
|
+
from common_package.logging import LoggingSettings
|
|
82
|
+
|
|
83
|
+
with pytest.raises(ValidationError):
|
|
84
|
+
LoggingSettings(**values)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from logging.handlers import RotatingFileHandler
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
import common_package.logging.logger as logging_module
|
|
7
|
+
from common_package.logging import LoggingSettings, configure_logging
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def isolated_root_logger(monkeypatch: pytest.MonkeyPatch) -> logging.Logger:
|
|
12
|
+
root_logger = logging.Logger("isolated-root")
|
|
13
|
+
get_logger = logging.getLogger
|
|
14
|
+
|
|
15
|
+
def get_test_logger(name: str | None = None) -> logging.Logger:
|
|
16
|
+
if name is None:
|
|
17
|
+
return root_logger
|
|
18
|
+
return get_logger(name)
|
|
19
|
+
|
|
20
|
+
monkeypatch.setattr(logging_module.logging, "getLogger", get_test_logger)
|
|
21
|
+
return root_logger
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_configure_logging_writes_formatted_file(
|
|
25
|
+
isolated_root_logger: logging.Logger,
|
|
26
|
+
tmp_path,
|
|
27
|
+
) -> None:
|
|
28
|
+
log_path = tmp_path / "application.log"
|
|
29
|
+
settings = LoggingSettings(
|
|
30
|
+
level="DEBUG",
|
|
31
|
+
stream=False,
|
|
32
|
+
file_path=str(log_path),
|
|
33
|
+
format="%(levelname)s:%(name)s:%(message)s",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
configure_logging(settings)
|
|
37
|
+
isolated_root_logger.debug("configured")
|
|
38
|
+
|
|
39
|
+
assert isolated_root_logger.level == logging.DEBUG
|
|
40
|
+
assert log_path.read_text(encoding="utf-8").strip() == "DEBUG:isolated-root:configured"
|
|
41
|
+
assert len(isolated_root_logger.handlers) == 1
|
|
42
|
+
assert isinstance(isolated_root_logger.handlers[0], logging.FileHandler)
|
|
43
|
+
assert isolated_root_logger.handlers[0].formatter is not None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_configure_logging_uses_rotating_file_handler(
|
|
47
|
+
isolated_root_logger: logging.Logger,
|
|
48
|
+
tmp_path,
|
|
49
|
+
) -> None:
|
|
50
|
+
settings = LoggingSettings(
|
|
51
|
+
stream=False,
|
|
52
|
+
file_path=str(tmp_path / "rotating.log"),
|
|
53
|
+
max_bytes=128,
|
|
54
|
+
backup_count=3,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
configure_logging(settings)
|
|
58
|
+
|
|
59
|
+
assert len(isolated_root_logger.handlers) == 1
|
|
60
|
+
assert isinstance(isolated_root_logger.handlers[0], RotatingFileHandler)
|
|
61
|
+
assert isolated_root_logger.handlers[0].maxBytes == 128
|
|
62
|
+
assert isolated_root_logger.handlers[0].backupCount == 3
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_configure_logging_adds_stream_handler_when_enabled(
|
|
66
|
+
isolated_root_logger: logging.Logger,
|
|
67
|
+
) -> None:
|
|
68
|
+
configure_logging(LoggingSettings(stream=True))
|
|
69
|
+
|
|
70
|
+
assert len(isolated_root_logger.handlers) == 1
|
|
71
|
+
assert isinstance(isolated_root_logger.handlers[0], logging.StreamHandler)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_configure_logging_uses_null_handler_when_outputs_disabled(
|
|
75
|
+
isolated_root_logger: logging.Logger,
|
|
76
|
+
) -> None:
|
|
77
|
+
configure_logging(LoggingSettings(stream=False))
|
|
78
|
+
|
|
79
|
+
assert len(isolated_root_logger.handlers) == 1
|
|
80
|
+
assert isinstance(isolated_root_logger.handlers[0], logging.NullHandler)
|