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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-ppa
3
- Version: 0.1.9
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.1
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
- return Path(env_root) / "resources"
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 = parent / "resources"
28
- # Verify this directory exists and contains your master settings file
29
- if candidate_dir.is_dir() and (candidate_dir / "settings.yml").exists():
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
- """Reads a configuration file as raw text, swaps environment placeholders, and parses YAML."""
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
- # Perform a global substitution across the text body
78
- interpolated_content = ENV_VAR_PATTERN.sub(match_replacer, raw_content)
79
- loaded = yaml.safe_load(interpolated_content)
80
- return _as_dict(loaded, context=str(file_path))
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 / "settings.yml"
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
- profile_filename = f"settings-{self.profile}.yml" if self.profile != "prod" else "settings-prod.yml"
95
- profile_config_path = RESOURCES_DIR / profile_filename
96
-
97
- self._config_data = _load_and_interpolate_yaml(profile_config_path)
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
- if _mongo_client is None:
202
- _mongo_client = MongoClient(settings.mongo_uri)
203
- db = _mongo_client[settings.database_name]
204
- framework_logger.debug("Mongo client initialized for database [%s]", settings.database_name)
205
-
206
- return _mongo_client
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
- return db[collection_name]
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
- """Gracefully terminates open framework connection pools."""
274
- if _mongo_client is not None:
275
- framework_logger.debug("Closing MongoDB client connection pool")
276
- _mongo_client.close()
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.9"
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.1",
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.9
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.1
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
 
@@ -11,4 +11,5 @@ python_ppa.egg-info/PKG-INFO
11
11
  python_ppa.egg-info/SOURCES.txt
12
12
  python_ppa.egg-info/dependency_links.txt
13
13
  python_ppa.egg-info/requires.txt
14
- python_ppa.egg-info/top_level.txt
14
+ python_ppa.egg-info/top_level.txt
15
+ tests/test_config.py
@@ -5,7 +5,7 @@ idna==3.18
5
5
  pydantic==2.13.4
6
6
  pydantic_core==2.46.4
7
7
  psycopg[binary]>=3.2.0
8
- pymongo==4.18.1
8
+ pymongo==4.18.2
9
9
  python-dotenv==1.2.2
10
10
  PyYAML==6.0.3
11
11
  typing-inspection==0.4.2
@@ -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