python-ppa 0.1.9__tar.gz → 0.1.10__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.
- {python_ppa-0.1.9 → python_ppa-0.1.10}/PKG-INFO +16 -2
- {python_ppa-0.1.9 → python_ppa-0.1.10}/README.md +14 -0
- {python_ppa-0.1.9 → python_ppa-0.1.10}/ppa/config.py +73 -28
- {python_ppa-0.1.9 → python_ppa-0.1.10}/pyproject.toml +2 -2
- {python_ppa-0.1.9 → python_ppa-0.1.10}/python_ppa.egg-info/PKG-INFO +16 -2
- {python_ppa-0.1.9 → python_ppa-0.1.10}/python_ppa.egg-info/SOURCES.txt +2 -1
- {python_ppa-0.1.9 → python_ppa-0.1.10}/python_ppa.egg-info/requires.txt +1 -1
- python_ppa-0.1.10/tests/test_config.py +109 -0
- {python_ppa-0.1.9 → python_ppa-0.1.10}/LICENSE +0 -0
- {python_ppa-0.1.9 → python_ppa-0.1.10}/ppa/__init__.py +0 -0
- {python_ppa-0.1.9 → python_ppa-0.1.10}/ppa/mongo/__init__.py +0 -0
- {python_ppa-0.1.9 → python_ppa-0.1.10}/ppa/mongo/interface.py +0 -0
- {python_ppa-0.1.9 → python_ppa-0.1.10}/ppa/postgres/__init__.py +0 -0
- {python_ppa-0.1.9 → python_ppa-0.1.10}/ppa/postgres/interface.py +0 -0
- {python_ppa-0.1.9 → python_ppa-0.1.10}/python_ppa.egg-info/dependency_links.txt +0 -0
- {python_ppa-0.1.9 → python_ppa-0.1.10}/python_ppa.egg-info/top_level.txt +0 -0
- {python_ppa-0.1.9 → python_ppa-0.1.10}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-ppa
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.10
|
|
4
4
|
Summary: JPA-like ORM for Python
|
|
5
5
|
Author: Pratyush Kumar
|
|
6
6
|
License-Expression: MIT
|
|
@@ -14,7 +14,7 @@ Requires-Dist: idna==3.18
|
|
|
14
14
|
Requires-Dist: pydantic==2.13.4
|
|
15
15
|
Requires-Dist: pydantic_core==2.46.4
|
|
16
16
|
Requires-Dist: psycopg[binary]>=3.2.0
|
|
17
|
-
Requires-Dist: pymongo==4.18.
|
|
17
|
+
Requires-Dist: pymongo==4.18.2
|
|
18
18
|
Requires-Dist: python-dotenv==1.2.2
|
|
19
19
|
Requires-Dist: PyYAML==6.0.3
|
|
20
20
|
Requires-Dist: typing-inspection==0.4.2
|
|
@@ -83,6 +83,20 @@ pip install -r requirements.txt
|
|
|
83
83
|
|
|
84
84
|
## ⚙️ Configuration Management
|
|
85
85
|
|
|
86
|
+
Start with the root `settings.yaml` and copy `.env.example` to `.env`. Set
|
|
87
|
+
`mongoURI` (or `MONGO_URI`) and `MONGO_DB_NAME`. Secrets stay outside YAML.
|
|
88
|
+
The loader accepts a single `settings.yaml`, with optional recursively merged
|
|
89
|
+
`settings-<profile>.yaml` overrides. Existing `.yml` files under `resources/`
|
|
90
|
+
remain supported. Use `PROJECT_ROOT` to select an application's configuration
|
|
91
|
+
when several applications share a checkout.
|
|
92
|
+
|
|
93
|
+
MongoDB pool sizes, timeouts and driver retries are configured in
|
|
94
|
+
`mongodb.options` using PyMongo option names. Call `ping_mongo()` from
|
|
95
|
+
`ppa.config` to verify readiness and call `close_db_connection()` at shutdown
|
|
96
|
+
after requests finish. PyMongo handles transient disconnects; requests during
|
|
97
|
+
an outage can still fail. See [the configuration and lifecycle design](DESIGN.md)
|
|
98
|
+
for API integration, error handling and shutdown behavior.
|
|
99
|
+
|
|
86
100
|
The framework supports multi-profile YAML configurations with real-time environment substitution syntax (e.g., ${ENV_VAR:default_value}).
|
|
87
101
|
Create a `resources/` directory at the root of your execution workspace and include your application sheets.
|
|
88
102
|
|
|
@@ -60,6 +60,20 @@ pip install -r requirements.txt
|
|
|
60
60
|
|
|
61
61
|
## ⚙️ Configuration Management
|
|
62
62
|
|
|
63
|
+
Start with the root `settings.yaml` and copy `.env.example` to `.env`. Set
|
|
64
|
+
`mongoURI` (or `MONGO_URI`) and `MONGO_DB_NAME`. Secrets stay outside YAML.
|
|
65
|
+
The loader accepts a single `settings.yaml`, with optional recursively merged
|
|
66
|
+
`settings-<profile>.yaml` overrides. Existing `.yml` files under `resources/`
|
|
67
|
+
remain supported. Use `PROJECT_ROOT` to select an application's configuration
|
|
68
|
+
when several applications share a checkout.
|
|
69
|
+
|
|
70
|
+
MongoDB pool sizes, timeouts and driver retries are configured in
|
|
71
|
+
`mongodb.options` using PyMongo option names. Call `ping_mongo()` from
|
|
72
|
+
`ppa.config` to verify readiness and call `close_db_connection()` at shutdown
|
|
73
|
+
after requests finish. PyMongo handles transient disconnects; requests during
|
|
74
|
+
an outage can still fail. See [the configuration and lifecycle design](DESIGN.md)
|
|
75
|
+
for API integration, error handling and shutdown behavior.
|
|
76
|
+
|
|
63
77
|
The framework supports multi-profile YAML configurations with real-time environment substitution syntax (e.g., ${ENV_VAR:default_value}).
|
|
64
78
|
Create a `resources/` directory at the root of your execution workspace and include your application sheets.
|
|
65
79
|
|
|
@@ -2,6 +2,7 @@ import logging
|
|
|
2
2
|
import os
|
|
3
3
|
import re
|
|
4
4
|
from pathlib import Path
|
|
5
|
+
from threading import Lock
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
import yaml
|
|
@@ -19,23 +20,32 @@ def _detect_resources_dir() -> Path:
|
|
|
19
20
|
"""
|
|
20
21
|
# 1. Honor explicit override if provided
|
|
21
22
|
if env_root := os.getenv("PROJECT_ROOT"):
|
|
22
|
-
|
|
23
|
+
root = Path(env_root)
|
|
24
|
+
return root if _settings_file(root).exists() else root / "resources"
|
|
23
25
|
|
|
24
26
|
# 2. Implicit Discovery: Look at where the application is being executed from
|
|
25
27
|
cwd = Path.cwd()
|
|
26
28
|
for parent in [cwd, *cwd.parents]:
|
|
27
|
-
candidate_dir
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return candidate_dir
|
|
29
|
+
for candidate_dir in (parent, parent / "resources"):
|
|
30
|
+
if _settings_file(candidate_dir).exists():
|
|
31
|
+
return candidate_dir
|
|
31
32
|
|
|
32
33
|
# 3. Fallback to package-relative directory if all else fails
|
|
33
34
|
return Path(__file__).resolve().parent / "resources"
|
|
34
35
|
|
|
35
36
|
|
|
37
|
+
def _settings_file(directory: Path, stem: str = "settings") -> Path:
|
|
38
|
+
"""Prefer .yaml while retaining support for existing .yml applications."""
|
|
39
|
+
yaml_path = directory / f"{stem}.yaml"
|
|
40
|
+
return yaml_path if yaml_path.exists() else directory / f"{stem}.yml"
|
|
41
|
+
|
|
42
|
+
|
|
36
43
|
# Establish dynamic path routing based on runtime context
|
|
37
44
|
RESOURCES_DIR = _detect_resources_dir()
|
|
38
45
|
BASE_DIR = RESOURCES_DIR.parent
|
|
46
|
+
# Also load .env beside the discovered application settings, without overriding exports.
|
|
47
|
+
load_dotenv(RESOURCES_DIR / ".env")
|
|
48
|
+
load_dotenv(BASE_DIR / ".env")
|
|
39
49
|
FRAMEWORK_LOGGER_NAME = "framework"
|
|
40
50
|
DEFAULT_FRAMEWORK_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
|
|
41
51
|
DEFAULT_FRAMEWORK_LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
@@ -61,7 +71,7 @@ def _as_dict(value: Any, *, context: str) -> dict[str, Any]:
|
|
|
61
71
|
|
|
62
72
|
|
|
63
73
|
def _load_and_interpolate_yaml(file_path: Path) -> dict[str, Any]:
|
|
64
|
-
"""
|
|
74
|
+
"""Parse YAML before substitution so environment values cannot change its structure."""
|
|
65
75
|
if not file_path.exists():
|
|
66
76
|
raise FileNotFoundError(f"Configuration file missing at: {file_path}")
|
|
67
77
|
|
|
@@ -74,16 +84,38 @@ def _load_and_interpolate_yaml(file_path: Path) -> dict[str, Any]:
|
|
|
74
84
|
# Pull from live environment parameters; defer to fallback if empty
|
|
75
85
|
return os.getenv(env_var_name, fallback_value)
|
|
76
86
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
def interpolate(node: Any) -> Any:
|
|
88
|
+
if isinstance(node, dict):
|
|
89
|
+
return {key: interpolate(value) for key, value in node.items()}
|
|
90
|
+
if isinstance(node, list):
|
|
91
|
+
return [interpolate(value) for value in node]
|
|
92
|
+
if isinstance(node, str):
|
|
93
|
+
result = ENV_VAR_PATTERN.sub(match_replacer, node)
|
|
94
|
+
if ENV_VAR_PATTERN.fullmatch(node):
|
|
95
|
+
if result.lower() in ("true", "false"):
|
|
96
|
+
return result.lower() == "true"
|
|
97
|
+
if re.fullmatch(r"-?\d+", result):
|
|
98
|
+
return int(result)
|
|
99
|
+
return result
|
|
100
|
+
return node
|
|
101
|
+
|
|
102
|
+
return _as_dict(interpolate(yaml.safe_load(raw_content)), context=str(file_path))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _merge_config(base: dict, override: dict) -> dict:
|
|
106
|
+
merged = dict(base)
|
|
107
|
+
for key, value in override.items():
|
|
108
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
109
|
+
merged[key] = _merge_config(merged[key], value)
|
|
110
|
+
else:
|
|
111
|
+
merged[key] = value
|
|
112
|
+
return merged
|
|
81
113
|
|
|
82
114
|
|
|
83
115
|
class ConfigSettings:
|
|
84
116
|
def __init__(self):
|
|
85
117
|
# 1. Bootstrap: Read the master settings.yml to find the active profile
|
|
86
|
-
master_config_path = RESOURCES_DIR
|
|
118
|
+
master_config_path = _settings_file(RESOURCES_DIR)
|
|
87
119
|
master_data = _load_and_interpolate_yaml(master_config_path)
|
|
88
120
|
app_config = _as_dict(master_data.get("app"), context="settings.app")
|
|
89
121
|
|
|
@@ -91,10 +123,13 @@ class ConfigSettings:
|
|
|
91
123
|
self.profile = str(app_config.get("profile", "dev"))
|
|
92
124
|
|
|
93
125
|
# 2. Profile Loading: Read the target profile file (e.g., settings-dev.yml)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
126
|
+
if not re.fullmatch(r"[\w-]+", self.profile):
|
|
127
|
+
raise ValueError("app.profile must contain only letters, numbers, underscores or hyphens")
|
|
128
|
+
profile_config_path = _settings_file(RESOURCES_DIR, f"settings-{self.profile}")
|
|
129
|
+
profile_filename = profile_config_path.name
|
|
130
|
+
self._config_data = master_data
|
|
131
|
+
if profile_config_path.exists():
|
|
132
|
+
self._config_data = _merge_config(master_data, _load_and_interpolate_yaml(profile_config_path))
|
|
98
133
|
mongodb_config = _as_dict(self._config_data.get("mongodb"), context=f"{profile_filename}.mongodb")
|
|
99
134
|
postgres_config = _as_dict(
|
|
100
135
|
self._config_data.get("postgresql", self._config_data.get("postgres")),
|
|
@@ -102,8 +137,9 @@ class ConfigSettings:
|
|
|
102
137
|
)
|
|
103
138
|
|
|
104
139
|
# 3. Expose parameters to the framework
|
|
105
|
-
self.mongo_uri = mongodb_config.get("uri")
|
|
140
|
+
self.mongo_uri = mongodb_config.get("uri") or os.getenv("mongoURI") or os.getenv("MONGO_URI")
|
|
106
141
|
self.database_name = mongodb_config.get("database")
|
|
142
|
+
self.mongo_options = _as_dict(mongodb_config.get("options"), context="mongodb.options")
|
|
107
143
|
self.postgres_dsn = self._resolve_postgres_dsn(postgres_config)
|
|
108
144
|
self.postgres_database_name = postgres_config.get("database") or postgres_config.get("dbname")
|
|
109
145
|
|
|
@@ -188,6 +224,7 @@ settings = ConfigSettings()
|
|
|
188
224
|
framework_logger = _configure_framework_logger()
|
|
189
225
|
framework_logger.info("Bootstrapping framework with active profile [%s]", settings.profile)
|
|
190
226
|
_mongo_client: MongoClient | None = None
|
|
227
|
+
_mongo_lock = Lock()
|
|
191
228
|
db = None
|
|
192
229
|
|
|
193
230
|
|
|
@@ -198,12 +235,12 @@ def _get_mongo_client() -> MongoClient:
|
|
|
198
235
|
if not isinstance(settings.database_name, str) or not settings.database_name:
|
|
199
236
|
raise ValueError("MongoDB database name must be provided before using Mongo repositories")
|
|
200
237
|
|
|
201
|
-
|
|
202
|
-
_mongo_client
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
238
|
+
with _mongo_lock:
|
|
239
|
+
if _mongo_client is None:
|
|
240
|
+
_mongo_client = MongoClient(settings.mongo_uri, **settings.mongo_options)
|
|
241
|
+
db = _mongo_client[settings.database_name]
|
|
242
|
+
framework_logger.debug("Mongo client initialized for database [%s]", settings.database_name)
|
|
243
|
+
return _mongo_client
|
|
207
244
|
|
|
208
245
|
|
|
209
246
|
def value(place_holder: str) -> Any:
|
|
@@ -251,8 +288,12 @@ def value(place_holder: str) -> Any:
|
|
|
251
288
|
|
|
252
289
|
def get_collection(collection_name: str):
|
|
253
290
|
"""Provides repositories access to the shared single client pool."""
|
|
254
|
-
_get_mongo_client()
|
|
255
|
-
|
|
291
|
+
return _get_mongo_client()[settings.database_name][collection_name]
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def ping_mongo() -> None:
|
|
295
|
+
"""Check readiness; propagate PyMongo errors for the application to handle."""
|
|
296
|
+
_get_mongo_client().admin.command("ping")
|
|
256
297
|
|
|
257
298
|
|
|
258
299
|
def get_postgres_connection():
|
|
@@ -270,7 +311,11 @@ def get_postgres_connection():
|
|
|
270
311
|
|
|
271
312
|
|
|
272
313
|
def close_db_connection():
|
|
273
|
-
"""
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
_mongo_client
|
|
314
|
+
"""Close at application shutdown, after in-flight requests have completed."""
|
|
315
|
+
global _mongo_client, db
|
|
316
|
+
with _mongo_lock:
|
|
317
|
+
if _mongo_client is not None:
|
|
318
|
+
framework_logger.debug("Closing MongoDB client connection pool")
|
|
319
|
+
_mongo_client.close()
|
|
320
|
+
_mongo_client = None
|
|
321
|
+
db = None
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-ppa"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.10"
|
|
8
8
|
description = "JPA-like ORM for Python"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.13"
|
|
@@ -21,7 +21,7 @@ dependencies = [
|
|
|
21
21
|
"pydantic==2.13.4",
|
|
22
22
|
"pydantic_core==2.46.4",
|
|
23
23
|
"psycopg[binary]>=3.2.0",
|
|
24
|
-
"pymongo==4.18.
|
|
24
|
+
"pymongo==4.18.2",
|
|
25
25
|
"python-dotenv==1.2.2",
|
|
26
26
|
"PyYAML==6.0.3",
|
|
27
27
|
"typing-inspection==0.4.2",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-ppa
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.10
|
|
4
4
|
Summary: JPA-like ORM for Python
|
|
5
5
|
Author: Pratyush Kumar
|
|
6
6
|
License-Expression: MIT
|
|
@@ -14,7 +14,7 @@ Requires-Dist: idna==3.18
|
|
|
14
14
|
Requires-Dist: pydantic==2.13.4
|
|
15
15
|
Requires-Dist: pydantic_core==2.46.4
|
|
16
16
|
Requires-Dist: psycopg[binary]>=3.2.0
|
|
17
|
-
Requires-Dist: pymongo==4.18.
|
|
17
|
+
Requires-Dist: pymongo==4.18.2
|
|
18
18
|
Requires-Dist: python-dotenv==1.2.2
|
|
19
19
|
Requires-Dist: PyYAML==6.0.3
|
|
20
20
|
Requires-Dist: typing-inspection==0.4.2
|
|
@@ -83,6 +83,20 @@ pip install -r requirements.txt
|
|
|
83
83
|
|
|
84
84
|
## ⚙️ Configuration Management
|
|
85
85
|
|
|
86
|
+
Start with the root `settings.yaml` and copy `.env.example` to `.env`. Set
|
|
87
|
+
`mongoURI` (or `MONGO_URI`) and `MONGO_DB_NAME`. Secrets stay outside YAML.
|
|
88
|
+
The loader accepts a single `settings.yaml`, with optional recursively merged
|
|
89
|
+
`settings-<profile>.yaml` overrides. Existing `.yml` files under `resources/`
|
|
90
|
+
remain supported. Use `PROJECT_ROOT` to select an application's configuration
|
|
91
|
+
when several applications share a checkout.
|
|
92
|
+
|
|
93
|
+
MongoDB pool sizes, timeouts and driver retries are configured in
|
|
94
|
+
`mongodb.options` using PyMongo option names. Call `ping_mongo()` from
|
|
95
|
+
`ppa.config` to verify readiness and call `close_db_connection()` at shutdown
|
|
96
|
+
after requests finish. PyMongo handles transient disconnects; requests during
|
|
97
|
+
an outage can still fail. See [the configuration and lifecycle design](DESIGN.md)
|
|
98
|
+
for API integration, error handling and shutdown behavior.
|
|
99
|
+
|
|
86
100
|
The framework supports multi-profile YAML configurations with real-time environment substitution syntax (e.g., ${ENV_VAR:default_value}).
|
|
87
101
|
Create a `resources/` directory at the root of your execution workspace and include your application sheets.
|
|
88
102
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from tempfile import TemporaryDirectory
|
|
5
|
+
import unittest
|
|
6
|
+
from unittest.mock import MagicMock, patch
|
|
7
|
+
|
|
8
|
+
# Bootstrapping configuration never needs a live database.
|
|
9
|
+
with patch.dict(os.environ, {"mongoURI": "mongodb://localhost:27017"}):
|
|
10
|
+
from ppa import config
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ConfigTests(unittest.TestCase):
|
|
14
|
+
def setUp(self):
|
|
15
|
+
self.temp = TemporaryDirectory()
|
|
16
|
+
self.addCleanup(self.temp.cleanup)
|
|
17
|
+
self.root = Path(self.temp.name)
|
|
18
|
+
self.resources = patch.object(config, "RESOURCES_DIR", self.root)
|
|
19
|
+
self.resources.start()
|
|
20
|
+
self.addCleanup(self.resources.stop)
|
|
21
|
+
|
|
22
|
+
def write(self, filename, content):
|
|
23
|
+
(self.root / filename).write_text(content)
|
|
24
|
+
|
|
25
|
+
def test_single_yaml_and_mongo_uri_alias(self):
|
|
26
|
+
self.write("settings.yaml", "mongodb:\n database: test\n")
|
|
27
|
+
with patch.dict(os.environ, {"mongoURI": "mongodb://localhost:27017"}):
|
|
28
|
+
settings = config.ConfigSettings()
|
|
29
|
+
self.assertEqual(settings.database_name, "test")
|
|
30
|
+
self.assertEqual(settings.mongo_uri, "mongodb://localhost:27017")
|
|
31
|
+
|
|
32
|
+
def test_legacy_postgres_profile(self):
|
|
33
|
+
self.write("settings.yml", "app:\n profile: dev\n")
|
|
34
|
+
self.write("settings-dev.yml", "postgresql:\n dsn: postgresql://localhost/test\n")
|
|
35
|
+
settings = config.ConfigSettings()
|
|
36
|
+
self.assertEqual(settings.postgres_dsn, "postgresql://localhost/test")
|
|
37
|
+
|
|
38
|
+
def test_recursive_profile_override_and_yaml_precedence(self):
|
|
39
|
+
self.write("settings.yaml", "mongodb:\n uri: mongodb://localhost\n database: test\n options:\n maxPoolSize: 10\n retryReads: true\n")
|
|
40
|
+
self.write("settings.yml", "invalid: legacy file should not be selected\n")
|
|
41
|
+
self.write("settings-dev.yaml", "mongodb:\n options:\n maxPoolSize: 20\n")
|
|
42
|
+
settings = config.ConfigSettings()
|
|
43
|
+
self.assertEqual(settings.database_name, "test")
|
|
44
|
+
self.assertEqual(settings.mongo_options, {"maxPoolSize": 20, "retryReads": True})
|
|
45
|
+
|
|
46
|
+
def test_environment_cannot_inject_yaml_keys(self):
|
|
47
|
+
self.write("settings.yaml", "secret: ${TEST_SECRET}\noptions:\n enabled: ${TEST_ENABLED:false}\n size: ${TEST_SIZE:10}\n")
|
|
48
|
+
secret = 'quotes: "\nadmin: true # preserved as data'
|
|
49
|
+
with patch.dict(os.environ, {"TEST_SECRET": secret, "TEST_ENABLED": "false"}):
|
|
50
|
+
result = config._load_and_interpolate_yaml(self.root / "settings.yaml")
|
|
51
|
+
self.assertEqual(result, {"secret": secret, "options": {"enabled": False, "size": 10}})
|
|
52
|
+
|
|
53
|
+
def test_incomplete_mongo_config_fails(self):
|
|
54
|
+
self.write("settings.yaml", "mongodb:\n uri: mongodb://localhost\n")
|
|
55
|
+
with self.assertRaises(KeyError):
|
|
56
|
+
config.ConfigSettings()
|
|
57
|
+
|
|
58
|
+
def test_explicit_root_supports_both_layouts(self):
|
|
59
|
+
with patch.dict(os.environ, {"PROJECT_ROOT": str(self.root)}):
|
|
60
|
+
self.assertEqual(config._detect_resources_dir(), self.root / "resources")
|
|
61
|
+
self.write("settings.yaml", "app: {}\n")
|
|
62
|
+
self.assertEqual(config._detect_resources_dir(), self.root)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MongoLifecycleTests(unittest.TestCase):
|
|
66
|
+
def setUp(self):
|
|
67
|
+
for name, value in (("_mongo_client", None), ("db", None)):
|
|
68
|
+
patcher = patch.object(config, name, value)
|
|
69
|
+
patcher.start()
|
|
70
|
+
self.addCleanup(patcher.stop)
|
|
71
|
+
self.factory = patch.object(config, "MongoClient")
|
|
72
|
+
self.client_factory = self.factory.start()
|
|
73
|
+
self.addCleanup(self.factory.stop)
|
|
74
|
+
self.options = patch.object(config.settings, "mongo_options", {"serverSelectionTimeoutMS": 123})
|
|
75
|
+
self.options.start()
|
|
76
|
+
self.addCleanup(self.options.stop)
|
|
77
|
+
|
|
78
|
+
def test_concurrent_initialization_uses_one_pool_and_options(self):
|
|
79
|
+
with ThreadPoolExecutor(max_workers=8) as workers:
|
|
80
|
+
clients = list(workers.map(lambda _: config._get_mongo_client(), range(32)))
|
|
81
|
+
self.client_factory.assert_called_once_with(config.settings.mongo_uri, serverSelectionTimeoutMS=123)
|
|
82
|
+
self.assertTrue(all(client is clients[0] for client in clients))
|
|
83
|
+
|
|
84
|
+
def test_close_is_idempotent_and_new_collection_uses_fresh_client(self):
|
|
85
|
+
first, second = MagicMock(), MagicMock()
|
|
86
|
+
self.client_factory.side_effect = [first, second]
|
|
87
|
+
config.get_collection("users")
|
|
88
|
+
config.close_db_connection()
|
|
89
|
+
config.close_db_connection()
|
|
90
|
+
first.close.assert_called_once()
|
|
91
|
+
self.assertIsNone(config.db)
|
|
92
|
+
config.get_collection("users")
|
|
93
|
+
self.assertIs(config._mongo_client, second)
|
|
94
|
+
self.assertEqual(self.client_factory.call_count, 2)
|
|
95
|
+
|
|
96
|
+
def test_ping_propagates_outage_and_reuses_client_after_recovery(self):
|
|
97
|
+
from pymongo.errors import AutoReconnect
|
|
98
|
+
|
|
99
|
+
client = self.client_factory.return_value
|
|
100
|
+
client.admin.command.side_effect = [AutoReconnect("offline"), {"ok": 1}]
|
|
101
|
+
with self.assertRaises(AutoReconnect):
|
|
102
|
+
config.ping_mongo()
|
|
103
|
+
config.ping_mongo()
|
|
104
|
+
self.client_factory.assert_called_once()
|
|
105
|
+
self.assertEqual(client.admin.command.call_count, 2)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
unittest.main()
|
|
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
|