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.
@@ -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,6 @@
1
+ # PassiveIncome.CommonPackage
2
+ Общий пакет для Python-приложений
3
+
4
+ Пакет предоставляет готовые фнукциональности для создания Python-приложений:
5
+ 1. Конфигурирование.
6
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,4 @@
1
+ from common_package.config.config import AppSettings, SENSITIVE_KEYWORDS
2
+ from common_package.config.utils import key_masking
3
+
4
+ __all__ = ["AppSettings", "SENSITIVE_KEYWORDS", "key_masking"]
@@ -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,4 @@
1
+ from common_package.logging.logger import configure_logging
2
+ from common_package.logging.settings import LoggingSettings
3
+
4
+ __all__ = ["LoggingSettings", "configure_logging"]
@@ -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
@@ -0,0 +1,6 @@
1
+ pydantic>=2.0
2
+ pydantic-settings>=2.0
3
+
4
+ [dev]
5
+ build>=1.2
6
+ pytest>=8.0
@@ -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)