python-ppa 0.0.1__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.
- jpa/__init__.py +2 -0
- jpa/config.py +205 -0
- jpa/mongo/__init__.py +3 -0
- jpa/mongo/interface.py +296 -0
- python_ppa-0.0.1.dist-info/METADATA +127 -0
- python_ppa-0.0.1.dist-info/RECORD +9 -0
- python_ppa-0.0.1.dist-info/WHEEL +5 -0
- python_ppa-0.0.1.dist-info/licenses/LICENSE +21 -0
- python_ppa-0.0.1.dist-info/top_level.txt +1 -0
jpa/__init__.py
ADDED
jpa/config.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from pymongo import MongoClient
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
|
|
11
|
+
# Load local .env variables into system memory at the very beginning
|
|
12
|
+
load_dotenv()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _detect_resources_dir() -> Path:
|
|
16
|
+
"""
|
|
17
|
+
Implicitly finds the resources directory by checking the execution context (CWD).
|
|
18
|
+
Falls back to environment variables or the file location if not found.
|
|
19
|
+
"""
|
|
20
|
+
# 1. Honor explicit override if provided
|
|
21
|
+
if env_root := os.getenv("PROJECT_ROOT"):
|
|
22
|
+
return Path(env_root) / "resources"
|
|
23
|
+
|
|
24
|
+
# 2. Implicit Discovery: Look at where the application is being executed from
|
|
25
|
+
cwd = Path.cwd()
|
|
26
|
+
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
|
|
31
|
+
|
|
32
|
+
# 3. Fallback to package-relative directory if all else fails
|
|
33
|
+
return Path(__file__).resolve().parent / "resources"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Establish dynamic path routing based on runtime context
|
|
37
|
+
RESOURCES_DIR = _detect_resources_dir()
|
|
38
|
+
BASE_DIR = RESOURCES_DIR.parent
|
|
39
|
+
FRAMEWORK_LOGGER_NAME = "framework"
|
|
40
|
+
DEFAULT_FRAMEWORK_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
|
|
41
|
+
DEFAULT_FRAMEWORK_LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
|
42
|
+
|
|
43
|
+
# Regular expression pattern to match ${VAR_NAME} or ${VAR_NAME:fallback_value}
|
|
44
|
+
ENV_VAR_PATTERN = re.compile(r"\$\{(\w+)(?::([^}]+))?}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_log_level(level_name: str) -> int:
|
|
48
|
+
normalized_level = str(level_name).upper()
|
|
49
|
+
level = getattr(logging, normalized_level, None)
|
|
50
|
+
if not isinstance(level, int):
|
|
51
|
+
raise ValueError(f"Invalid logging level configured for framework.logging.level: {level_name}")
|
|
52
|
+
return level
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _as_dict(value: Any, *, context: str) -> dict[str, Any]:
|
|
56
|
+
if value is None:
|
|
57
|
+
return {}
|
|
58
|
+
if not isinstance(value, dict):
|
|
59
|
+
raise TypeError(f"Expected a mapping for {context}, but got {type(value).__name__}.")
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
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."""
|
|
65
|
+
if not file_path.exists():
|
|
66
|
+
raise FileNotFoundError(f"Configuration file missing at: {file_path}")
|
|
67
|
+
|
|
68
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
69
|
+
raw_content = f.read()
|
|
70
|
+
|
|
71
|
+
def match_replacer(match: re.Match[str]) -> str:
|
|
72
|
+
env_var_name = match.group(1)
|
|
73
|
+
fallback_value = match.group(2) or ""
|
|
74
|
+
# Pull from live environment parameters; defer to fallback if empty
|
|
75
|
+
return os.getenv(env_var_name, fallback_value)
|
|
76
|
+
|
|
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))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ConfigSettings:
|
|
84
|
+
def __init__(self):
|
|
85
|
+
# 1. Bootstrap: Read the master settings.yml to find the active profile
|
|
86
|
+
master_config_path = RESOURCES_DIR / "settings.yml"
|
|
87
|
+
master_data = _load_and_interpolate_yaml(master_config_path)
|
|
88
|
+
app_config = _as_dict(master_data.get("app"), context="settings.app")
|
|
89
|
+
|
|
90
|
+
# Safely extract app.profile, defaulting to 'dev' if missing
|
|
91
|
+
self.profile = str(app_config.get("profile", "dev"))
|
|
92
|
+
|
|
93
|
+
# 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)
|
|
98
|
+
mongodb_config = _as_dict(self._config_data.get("mongodb"), context=f"{profile_filename}.mongodb")
|
|
99
|
+
|
|
100
|
+
# 3. Expose parameters to the framework
|
|
101
|
+
self.mongo_uri = mongodb_config.get("uri")
|
|
102
|
+
self.database_name = mongodb_config.get("database")
|
|
103
|
+
|
|
104
|
+
if not isinstance(self.mongo_uri, str) or not self.mongo_uri or not isinstance(self.database_name,
|
|
105
|
+
str) or not self.database_name:
|
|
106
|
+
raise KeyError(
|
|
107
|
+
f"Invalid configuration format in {profile_filename}. Missing 'mongodb.uri' or 'mongodb.database'.")
|
|
108
|
+
|
|
109
|
+
framework_config = _as_dict(self._config_data.get("framework"), context=f"{profile_filename}.framework")
|
|
110
|
+
framework_logging_config = _as_dict(framework_config.get("logging"),
|
|
111
|
+
context=f"{profile_filename}.framework.logging")
|
|
112
|
+
self.framework_logs_enabled = bool(framework_logging_config.get("enabled", False))
|
|
113
|
+
self.framework_log_level = _parse_log_level(str(framework_logging_config.get("level", "INFO")))
|
|
114
|
+
self.framework_log_format = str(framework_logging_config.get("format", DEFAULT_FRAMEWORK_LOG_FORMAT))
|
|
115
|
+
self.framework_log_date_format = str(
|
|
116
|
+
framework_logging_config.get("date_format", DEFAULT_FRAMEWORK_LOG_DATE_FORMAT))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _configure_framework_logger() -> logging.Logger:
|
|
120
|
+
logger = logging.getLogger(FRAMEWORK_LOGGER_NAME)
|
|
121
|
+
logger.handlers.clear()
|
|
122
|
+
logger.propagate = False
|
|
123
|
+
|
|
124
|
+
if settings.framework_logs_enabled:
|
|
125
|
+
handler = logging.StreamHandler()
|
|
126
|
+
handler.setFormatter(logging.Formatter(settings.framework_log_format, settings.framework_log_date_format))
|
|
127
|
+
logger.addHandler(handler)
|
|
128
|
+
logger.setLevel(settings.framework_log_level)
|
|
129
|
+
else:
|
|
130
|
+
logger.addHandler(logging.NullHandler())
|
|
131
|
+
logger.setLevel(logging.CRITICAL + 1)
|
|
132
|
+
|
|
133
|
+
return logger
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_framework_logger(name: str | None = None) -> logging.Logger:
|
|
137
|
+
if not name:
|
|
138
|
+
return logging.getLogger(FRAMEWORK_LOGGER_NAME)
|
|
139
|
+
return logging.getLogger(f"{FRAMEWORK_LOGGER_NAME}.{name}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --- SINGLETON INITIALIZATION LAYER ---
|
|
143
|
+
# This block triggers exactly once when the app imports this file
|
|
144
|
+
settings = ConfigSettings()
|
|
145
|
+
framework_logger = _configure_framework_logger()
|
|
146
|
+
framework_logger.info("Bootstrapping framework with active profile [%s]", settings.profile)
|
|
147
|
+
_mongo_client = MongoClient(settings.mongo_uri)
|
|
148
|
+
if not settings or not settings.database_name and not isinstance(settings.database_name, str):
|
|
149
|
+
raise ValueError("Database name must be provided and it must be a string")
|
|
150
|
+
db = _mongo_client[settings.database_name]
|
|
151
|
+
framework_logger.debug("Mongo client initialized for database [%s]", settings.database_name)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def value(place_holder: str) -> Any:
|
|
155
|
+
"""
|
|
156
|
+
Spring Boot-inspired property extraction tool.
|
|
157
|
+
Always extracts and returns the raw value immediately from the active configuration sheet.
|
|
158
|
+
"""
|
|
159
|
+
# 1. Enforce your validation constraint
|
|
160
|
+
if place_holder.startswith("${") and not place_holder.endswith("}"):
|
|
161
|
+
raise ValueError(f"Invalid place holder: {place_holder}")
|
|
162
|
+
|
|
163
|
+
# 2. Extract key path and optional fallback default value
|
|
164
|
+
if place_holder.startswith("${") and place_holder.endswith("}"):
|
|
165
|
+
raw_content = place_holder[2:-1] # Strip '${' and '}'
|
|
166
|
+
|
|
167
|
+
# Support default fallbacks split by a colon (e.g., ${mongodb.uri:mongodb://localhost})
|
|
168
|
+
if ":" in raw_content:
|
|
169
|
+
path, default_val = raw_content.split(":", 1)
|
|
170
|
+
# Basic primitive type-casting
|
|
171
|
+
if default_val.isdigit():
|
|
172
|
+
default_val = int(default_val)
|
|
173
|
+
elif default_val.lower() == "true":
|
|
174
|
+
default_val = True
|
|
175
|
+
elif default_val.lower() == "false":
|
|
176
|
+
default_val = False
|
|
177
|
+
else:
|
|
178
|
+
path = raw_content
|
|
179
|
+
default_val = None
|
|
180
|
+
else:
|
|
181
|
+
path = place_holder
|
|
182
|
+
default_val = None
|
|
183
|
+
|
|
184
|
+
# 3. Direct, eager lookup against the live dictionary
|
|
185
|
+
keys = path.split(".")
|
|
186
|
+
current_node = settings._config_data
|
|
187
|
+
|
|
188
|
+
for key in keys:
|
|
189
|
+
if isinstance(current_node, dict) and key in current_node:
|
|
190
|
+
current_node = current_node[key]
|
|
191
|
+
else:
|
|
192
|
+
return default_val
|
|
193
|
+
|
|
194
|
+
return current_node
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_collection(collection_name: str):
|
|
198
|
+
"""Provides repositories access to the shared single client pool."""
|
|
199
|
+
return db[collection_name]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def close_db_connection():
|
|
203
|
+
"""Gracefully terminates connection pool sockets on shutdown."""
|
|
204
|
+
framework_logger.debug("Closing MongoDB client connection pool")
|
|
205
|
+
_mongo_client.close()
|
jpa/mongo/__init__.py
ADDED
jpa/mongo/interface.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, Callable, ClassVar, Generic, TypeVar, cast, get_args, get_origin, get_type_hints, List
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from bson import ObjectId, errors
|
|
7
|
+
|
|
8
|
+
from jpa.config import get_collection, get_framework_logger
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T", bound=BaseModel)
|
|
11
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
12
|
+
framework_logger = get_framework_logger("repository")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DocumentModel(BaseModel):
|
|
16
|
+
__collection_name__: ClassVar[str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def query(definition: dict[str, Any]) -> Callable[[F], F]:
|
|
20
|
+
"""Annotation to specify a custom MongoDB query template."""
|
|
21
|
+
|
|
22
|
+
def decorator(func: F) -> F:
|
|
23
|
+
func.__query_template__ = definition
|
|
24
|
+
return func
|
|
25
|
+
|
|
26
|
+
return decorator
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def document(name: str) -> Callable[[type[DocumentModel]], type[DocumentModel]]:
|
|
30
|
+
"""Class decorator to bind a Pydantic model to a MongoDB collection."""
|
|
31
|
+
|
|
32
|
+
def decorator(cls):
|
|
33
|
+
cls.__collection_name__ = name
|
|
34
|
+
return cls
|
|
35
|
+
|
|
36
|
+
return decorator
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class RepositoryMeta(type):
|
|
40
|
+
def __new__(mcs, name, bases, namespace):
|
|
41
|
+
cls = super().__new__(mcs, name, bases, namespace)
|
|
42
|
+
|
|
43
|
+
if name == "IRepository":
|
|
44
|
+
return cls
|
|
45
|
+
|
|
46
|
+
entity_cls = mcs._extract_entity_cls(cls)
|
|
47
|
+
if not entity_cls:
|
|
48
|
+
return cls
|
|
49
|
+
|
|
50
|
+
typed_entity_cls = cast(type[DocumentModel], entity_cls)
|
|
51
|
+
|
|
52
|
+
if not hasattr(typed_entity_cls, "__collection_name__"):
|
|
53
|
+
raise TypeError(
|
|
54
|
+
f"RepositoryDefinitionError: The model '{entity_cls.__name__}' "
|
|
55
|
+
f"used by '{name}' must be annotated with @document(name='...')"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
cls._entity_cls = typed_entity_cls
|
|
59
|
+
valid_fields = set(typed_entity_cls.model_fields.keys())
|
|
60
|
+
|
|
61
|
+
for attr_name, attr_value in list(namespace.items()):
|
|
62
|
+
if inspect.isfunction(attr_value) and not attr_name.startswith("_"):
|
|
63
|
+
|
|
64
|
+
query_template = getattr(attr_value, "__query_template__", None)
|
|
65
|
+
is_convention = re.match(r"(find_by|find_all_by|exists_by|count_by)_(.*)", attr_name)
|
|
66
|
+
|
|
67
|
+
if not is_convention:
|
|
68
|
+
raise ValueError("Convention does not exists")
|
|
69
|
+
|
|
70
|
+
if query_template or is_convention is not None:
|
|
71
|
+
hints = get_type_hints(attr_value)
|
|
72
|
+
return_type = hints.get("return")
|
|
73
|
+
|
|
74
|
+
if isinstance(query_template, dict):
|
|
75
|
+
generated_method = mcs._build_annotated_method(attr_name, attr_value, query_template,
|
|
76
|
+
return_type, typed_entity_cls)
|
|
77
|
+
else:
|
|
78
|
+
generated_method = mcs._build_convention_method(
|
|
79
|
+
attr_name,
|
|
80
|
+
is_convention,
|
|
81
|
+
attr_value,
|
|
82
|
+
valid_fields,
|
|
83
|
+
return_type,
|
|
84
|
+
typed_entity_cls,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
setattr(cls, attr_name, generated_method)
|
|
88
|
+
|
|
89
|
+
return cls
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _extract_entity_cls(cls) -> type[BaseModel] | None:
|
|
93
|
+
for base in getattr(cls, "__orig_bases__", []):
|
|
94
|
+
if get_origin(base) is IRepository:
|
|
95
|
+
args = get_args(base)
|
|
96
|
+
if args and issubclass(args[0], BaseModel):
|
|
97
|
+
return args[0]
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def _build_annotated_method(
|
|
102
|
+
name: str,
|
|
103
|
+
func: Callable[..., Any],
|
|
104
|
+
template: dict[str, Any],
|
|
105
|
+
return_type: Any,
|
|
106
|
+
entity_cls: type[DocumentModel],
|
|
107
|
+
) -> Callable[..., Any]:
|
|
108
|
+
sig = inspect.signature(func)
|
|
109
|
+
param_names = [p for p in sig.parameters if p != "self"]
|
|
110
|
+
|
|
111
|
+
def wrapper(self, *args: Any, **kwargs: Any) -> Any:
|
|
112
|
+
bound = sig.bind(self, *args, **kwargs)
|
|
113
|
+
bound.apply_defaults()
|
|
114
|
+
|
|
115
|
+
ordered_args: list[Any] = []
|
|
116
|
+
for p_name in param_names:
|
|
117
|
+
if p_name in bound.arguments:
|
|
118
|
+
ordered_args.append(bound.arguments[p_name])
|
|
119
|
+
|
|
120
|
+
# Pure dictionary walk for placeholder substitution (no internal casting strings to dates)
|
|
121
|
+
def substitute_placeholders(node: Any) -> Any:
|
|
122
|
+
if isinstance(node, str) and node.startswith("?"):
|
|
123
|
+
try:
|
|
124
|
+
idx = int(node[1:])
|
|
125
|
+
return ordered_args[idx] # Inject whatever object type the user passed
|
|
126
|
+
except (ValueError, IndexError):
|
|
127
|
+
return node
|
|
128
|
+
elif isinstance(node, dict):
|
|
129
|
+
return {k: substitute_placeholders(v) for k, v in node.items()}
|
|
130
|
+
elif isinstance(node, list):
|
|
131
|
+
return [substitute_placeholders(item) for item in node]
|
|
132
|
+
return node
|
|
133
|
+
|
|
134
|
+
evaluated_query = cast(dict[str, Any], substitute_placeholders(template))
|
|
135
|
+
|
|
136
|
+
framework_logger.debug(
|
|
137
|
+
"Invoked custom query %s.%s with args=%s kwargs=%s",
|
|
138
|
+
self.__class__.__name__,
|
|
139
|
+
name,
|
|
140
|
+
args,
|
|
141
|
+
kwargs,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return execute_dynamic_query(self.collection, evaluated_query, "custom", return_type, entity_cls)
|
|
145
|
+
|
|
146
|
+
wrapper.__name__ = name
|
|
147
|
+
wrapper.__qualname__ = f"{entity_cls.__name__}Repository.{name}"
|
|
148
|
+
return wrapper
|
|
149
|
+
|
|
150
|
+
@staticmethod
|
|
151
|
+
def _build_convention_method(
|
|
152
|
+
name: str,
|
|
153
|
+
match: re.Match[str],
|
|
154
|
+
func: Callable[..., Any],
|
|
155
|
+
valid_fields: set[str],
|
|
156
|
+
return_type: Any,
|
|
157
|
+
entity_cls: type[DocumentModel],
|
|
158
|
+
) -> Callable[..., Any]:
|
|
159
|
+
prefix, field_name = match.groups()
|
|
160
|
+
|
|
161
|
+
if field_name not in valid_fields:
|
|
162
|
+
raise TypeError(
|
|
163
|
+
f"RepositoryDefinitionError: Field '{field_name}' does not exist on model {entity_cls.__name__}")
|
|
164
|
+
|
|
165
|
+
mongo_field = "_id" if field_name == "id" else field_name
|
|
166
|
+
sig = inspect.signature(func)
|
|
167
|
+
param_names = [p for p in sig.parameters if p != "self"]
|
|
168
|
+
|
|
169
|
+
def wrapper(self, *args: Any, **kwargs: Any) -> Any:
|
|
170
|
+
bound = sig.bind_partial(self, *args, **kwargs)
|
|
171
|
+
bound.apply_defaults()
|
|
172
|
+
actual_value = bound.arguments.get(param_names[0]) if param_names else None
|
|
173
|
+
|
|
174
|
+
query_dict: dict[str, Any] = {mongo_field: actual_value}
|
|
175
|
+
|
|
176
|
+
framework_logger.debug(
|
|
177
|
+
"Invoked convention query %s.%s using %s=%r",
|
|
178
|
+
self.__class__.__name__,
|
|
179
|
+
name,
|
|
180
|
+
mongo_field,
|
|
181
|
+
actual_value,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return execute_dynamic_query(self.collection, query_dict, prefix, return_type, entity_cls)
|
|
185
|
+
|
|
186
|
+
wrapper.__name__ = name
|
|
187
|
+
wrapper.__qualname__ = f"{entity_cls.__name__}Repository.{name}"
|
|
188
|
+
return wrapper
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cast_primary_keys(node: Any, current_field: str | None = None) -> Any:
|
|
192
|
+
"""Recursively walks a query structure to convert ID strings to native BSON ObjectIds."""
|
|
193
|
+
if isinstance(node, dict):
|
|
194
|
+
new_dict: dict[str, Any] = {}
|
|
195
|
+
for k, v in node.items():
|
|
196
|
+
next_field = current_field if isinstance(k, str) and k.startswith("$") else k
|
|
197
|
+
new_dict[k] = cast_primary_keys(v, next_field)
|
|
198
|
+
return new_dict
|
|
199
|
+
elif isinstance(node, list):
|
|
200
|
+
return [cast_primary_keys(item, current_field) for item in node]
|
|
201
|
+
elif isinstance(node, str) and current_field == "_id":
|
|
202
|
+
try:
|
|
203
|
+
return ObjectId(node)
|
|
204
|
+
except errors.InvalidId:
|
|
205
|
+
return node
|
|
206
|
+
return node
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def execute_dynamic_query(
|
|
210
|
+
collection: Any,
|
|
211
|
+
query_dict: dict[str, Any],
|
|
212
|
+
strategy: str,
|
|
213
|
+
return_type: Any,
|
|
214
|
+
entity_cls: type[DocumentModel],
|
|
215
|
+
) -> Any:
|
|
216
|
+
# Only cast fields checking against the database's internal _id field
|
|
217
|
+
processed_query = cast_primary_keys(query_dict)
|
|
218
|
+
|
|
219
|
+
framework_logger.debug(
|
|
220
|
+
"Executing MongoDB query on collection [%s] with strategy [%s]: %s",
|
|
221
|
+
collection.name,
|
|
222
|
+
strategy,
|
|
223
|
+
processed_query,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
origin_type = get_origin(return_type) or return_type
|
|
227
|
+
|
|
228
|
+
if strategy == "count_by" or origin_type is int:
|
|
229
|
+
return collection.count_documents(processed_query)
|
|
230
|
+
|
|
231
|
+
if strategy == "exists_by" or origin_type is bool:
|
|
232
|
+
return collection.count_documents(processed_query, limit=1) > 0
|
|
233
|
+
|
|
234
|
+
def sanitize_output(doc: Any) -> Any:
|
|
235
|
+
if doc and "_id" in doc:
|
|
236
|
+
doc = dict(doc)
|
|
237
|
+
doc["_id"] = str(doc["_id"])
|
|
238
|
+
return doc
|
|
239
|
+
|
|
240
|
+
if origin_type is list:
|
|
241
|
+
cursor = collection.find(processed_query)
|
|
242
|
+
return [entity_cls.model_validate(sanitize_output(doc)) for doc in cursor]
|
|
243
|
+
|
|
244
|
+
doc = collection.find_one(processed_query)
|
|
245
|
+
return entity_cls.model_validate(sanitize_output(doc)) if doc else None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class IRepository(Generic[T], metaclass=RepositoryMeta):
|
|
249
|
+
def __init__(self):
|
|
250
|
+
"""
|
|
251
|
+
Automated Framework Constructor.
|
|
252
|
+
Automatically resolves the target collection name from the
|
|
253
|
+
Pydantic model's @document decorator metadata.
|
|
254
|
+
"""
|
|
255
|
+
entity_cls = cast(type[DocumentModel], self._entity_cls)
|
|
256
|
+
collection_name = entity_cls.__collection_name__
|
|
257
|
+
self.collection = get_collection(collection_name)
|
|
258
|
+
framework_logger.debug(
|
|
259
|
+
"Initialized repository [%s] for collection [%s]",
|
|
260
|
+
self.__class__.__name__,
|
|
261
|
+
collection_name,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def save(self, pyd_model: T) -> str:
|
|
265
|
+
data = pyd_model.model_dump(exclude_none=True)
|
|
266
|
+
inserted_data = self.collection.insert_one(data)
|
|
267
|
+
return inserted_data.inserted_id
|
|
268
|
+
|
|
269
|
+
def save_all(self, pyd_models: List[T]) -> List[str]:
|
|
270
|
+
data = [pyd_model for pyd_model in pyd_models]
|
|
271
|
+
inserted_data = self.collection.insert_many(data)
|
|
272
|
+
return inserted_data.inserted_ids
|
|
273
|
+
|
|
274
|
+
def find_all(self) -> list[T]:
|
|
275
|
+
"""
|
|
276
|
+
Fetches all documents matching a query and maps them safely to
|
|
277
|
+
the repository's bound Pydantic model.
|
|
278
|
+
"""
|
|
279
|
+
# 1. Fetch raw cursor streams from MongoDB
|
|
280
|
+
cursor = self.collection.find()
|
|
281
|
+
|
|
282
|
+
# 2. Local serialization helper to clean BSON primary keys
|
|
283
|
+
def sanitize(doc):
|
|
284
|
+
if doc and "_id" in doc:
|
|
285
|
+
doc = dict(doc)
|
|
286
|
+
doc["_id"] = str(doc["_id"])
|
|
287
|
+
return doc
|
|
288
|
+
|
|
289
|
+
# 3. Instantiate using self._entity_cls (which holds the real model at runtime)
|
|
290
|
+
return [self._entity_cls(**sanitize(data_dict)) for data_dict in cursor]
|
|
291
|
+
|
|
292
|
+
def find(self, query_dict: dict) -> T:
|
|
293
|
+
data = self.collection.find(query_dict)
|
|
294
|
+
if not data:
|
|
295
|
+
raise ValueError("No data found")
|
|
296
|
+
return dict(data)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: python-ppa
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: JPA-like ORM for Python
|
|
5
|
+
Author: Pratyush Kumar
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.13
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: annotated-types==0.7.0
|
|
11
|
+
Requires-Dist: dnspython==2.8.0
|
|
12
|
+
Requires-Dist: email-validator==2.3.0
|
|
13
|
+
Requires-Dist: idna==3.18
|
|
14
|
+
Requires-Dist: pydantic==2.13.4
|
|
15
|
+
Requires-Dist: pydantic_core==2.46.4
|
|
16
|
+
Requires-Dist: pymongo==4.17.0
|
|
17
|
+
Requires-Dist: python-dotenv==1.2.2
|
|
18
|
+
Requires-Dist: PyYAML==6.0.3
|
|
19
|
+
Requires-Dist: typing-inspection==0.4.2
|
|
20
|
+
Requires-Dist: typing_extensions==4.15.0
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# python-jpa
|
|
24
|
+
|
|
25
|
+
`python-jpa` is a lightweight, Spring Data JPA-inspired Object-Document Mapper (ODM) for MongoDB built on top of **Python 3.13+**, **Pydantic v2**, and **PyMongo**.
|
|
26
|
+
|
|
27
|
+
It uses advanced Python metaprogramming (`metaclass`) to dynamically generate MongoDB queries at runtime based on method naming conventions or explicit query declarations, drastically reducing boilerplate code.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 🚀 Features
|
|
32
|
+
|
|
33
|
+
* **JPA-Like Repository Pattern:** Declare an interface, and let the metaclass handle query assembly dynamically.
|
|
34
|
+
* **Query Naming Conventions:** Instantly supports `find_by_*`, `find_all_by_*`, `exists_by_*`, and `count_by_*` pattern resolutions.
|
|
35
|
+
* **Custom Query Annotations:** Bind complex MongoDB query templates with custom parameter injections using the `@query` decorator.
|
|
36
|
+
* **Data Validation:** Fully powered by Pydantic v2 for robust runtime type checking and parsing.
|
|
37
|
+
* **Zero-Boilerplate ID Mapping:** Automatically handles conversions between stringified hexadecimal keys and native BSON `ObjectId` footprints.
|
|
38
|
+
* **Environment Profiles:** Flexible configurations handling multi-stage deployment environments (`dev`, `prod`, etc.) via YAML interpolation.
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 📁 Directory Structure
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
.
|
|
46
|
+
├── README.md
|
|
47
|
+
├── jpa
|
|
48
|
+
│ ├── __init__.py
|
|
49
|
+
│ ├── config.py # App bootstrapping, YAML parsing & environment mapping
|
|
50
|
+
│ └── mongo
|
|
51
|
+
│ ├── __init__.py
|
|
52
|
+
│ └── interface.py # Repository core interface, custom metadata, and queries
|
|
53
|
+
├── pyproject.toml
|
|
54
|
+
└── requirements.txt
|
|
55
|
+
|
|
56
|
+
🛠️ Getting Started
|
|
57
|
+
Prerequisites
|
|
58
|
+
• Python 3.13 or higher
|
|
59
|
+
Installation & Virtual Env Setup
|
|
60
|
+
1. Clone the repository and navigate to its root: cd python-jpa
|
|
61
|
+
2. Spin up a virtual environment and update your pip core dependencies: python3 -m venv venv source venv/bin/activate # On Windows use: venv\Scripts\activate pip install --upgrade pip
|
|
62
|
+
3. Install the project library dependencies: pip install -r requirements.txt
|
|
63
|
+
⚙️ Configuration Management
|
|
64
|
+
The framework supports multi-profile YAML configurations with real-time environment substitution syntax (e.g., ${ENV_VAR:default_value}).
|
|
65
|
+
Create a resources/ directory at the root of your execution workspace and include your application sheets:
|
|
66
|
+
1. Master Configuration (resources/settings.yml)
|
|
67
|
+
app:
|
|
68
|
+
profile: ${APP_PROFILE:dev} # Swaps profile to target environment configuration
|
|
69
|
+
|
|
70
|
+
2. Environment Profile Configuration (resources/settings-dev.yml)
|
|
71
|
+
mongodb:
|
|
72
|
+
uri: ${MONGO_URI:mongodb://localhost:27017}
|
|
73
|
+
database: ${MONGO_DB_NAME:jpa_database}
|
|
74
|
+
|
|
75
|
+
framework:
|
|
76
|
+
logging:
|
|
77
|
+
enabled: true
|
|
78
|
+
level: "DEBUG"
|
|
79
|
+
|
|
80
|
+
📖 Usage Example
|
|
81
|
+
Here is a quick overview of how you can configure a domain entity and generate auto-implemented interface pipelines:
|
|
82
|
+
1. Define your Document Model
|
|
83
|
+
from pydantic import Field
|
|
84
|
+
from jpa.mongo.interface import DocumentModel, document
|
|
85
|
+
|
|
86
|
+
@document(name="users")
|
|
87
|
+
class User(DocumentModel):
|
|
88
|
+
id: str = Field(alias="_id", default=None)
|
|
89
|
+
username: str
|
|
90
|
+
email: str
|
|
91
|
+
age: int
|
|
92
|
+
|
|
93
|
+
2. Declare your Interface Repository
|
|
94
|
+
By extending IRepository[T], method naming patterns are captured and converted into database interactions seamlessly.
|
|
95
|
+
from jpa.mongo.interface import IRepository, query
|
|
96
|
+
from typing import List, Optional
|
|
97
|
+
|
|
98
|
+
class UserRepository(IRepository[User]):
|
|
99
|
+
|
|
100
|
+
# 1. Query generation by structural method naming convention
|
|
101
|
+
def find_by_username(self, username: str) -> Optional[User]: ...
|
|
102
|
+
|
|
103
|
+
def count_by_age(self, age: int) -> int: ...
|
|
104
|
+
|
|
105
|
+
# 2. Templated declaration using placeholder substitutions
|
|
106
|
+
@query(definition={"email": "?0", "age": {"$gte": "?1"}})
|
|
107
|
+
def find_by_email_and_min_age(self, email: str, min_age: int) -> List[User]: ...
|
|
108
|
+
|
|
109
|
+
3. Execute CRUD Actions
|
|
110
|
+
from jpa.config import close_db_connection
|
|
111
|
+
|
|
112
|
+
# Initialize repository instance
|
|
113
|
+
user_repo = UserRepository()
|
|
114
|
+
|
|
115
|
+
# Save a document
|
|
116
|
+
new_user = User(username="pratyush", email="pratyush@example.com", age=25)
|
|
117
|
+
user_id = user_repo.save(new_user)
|
|
118
|
+
|
|
119
|
+
# Fetch using automatic query generation
|
|
120
|
+
user = user_repo.find_by_username("pratyush")
|
|
121
|
+
print(f"Found User: {user.email if user else 'Not Found'}")
|
|
122
|
+
|
|
123
|
+
# Clean up connections on process termination
|
|
124
|
+
close_db_connection()
|
|
125
|
+
|
|
126
|
+
📜 License
|
|
127
|
+
This project is open-source software licensed under the MIT License.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
jpa/__init__.py,sha256=xuglU4WFGcuOUnNS3cgPA7hY6MpNm7RhRMClDsoJn7w,44
|
|
2
|
+
jpa/config.py,sha256=BpnjdXIYjKe7P8HGKFwZkkTbHf94734dhuT1ki4x0UM,8430
|
|
3
|
+
jpa/mongo/__init__.py,sha256=miJ75M7NhhzFGh1mPhEauLD5-mqMs2JR0s1uz_e1zwk,122
|
|
4
|
+
jpa/mongo/interface.py,sha256=5BYJW-NdMqdNyKA6CrRVEjZCG-LmnqVDsN59tkH2j0s,10743
|
|
5
|
+
python_ppa-0.0.1.dist-info/licenses/LICENSE,sha256=J2MOuVHjIUNmdrDLa3O35gQz6E_wAJh9Ke3dod_ZDWc,1071
|
|
6
|
+
python_ppa-0.0.1.dist-info/METADATA,sha256=fT8Z7SIu1Yv-Y162znZLhLaZhLV-fq_zIKZbDqbTITw,4903
|
|
7
|
+
python_ppa-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
8
|
+
python_ppa-0.0.1.dist-info/top_level.txt,sha256=Ff25NuPyZ4QIlYXMgPtFLgeYqSKfD9Ynjmjev_Bf1nw,4
|
|
9
|
+
python_ppa-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pratyush Kumar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jpa
|