python-ppa 0.0.1__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,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,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,105 @@
1
+ # python-jpa
2
+
3
+ `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**.
4
+
5
+ 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.
6
+
7
+ ---
8
+
9
+ ## 🚀 Features
10
+
11
+ * **JPA-Like Repository Pattern:** Declare an interface, and let the metaclass handle query assembly dynamically.
12
+ * **Query Naming Conventions:** Instantly supports `find_by_*`, `find_all_by_*`, `exists_by_*`, and `count_by_*` pattern resolutions.
13
+ * **Custom Query Annotations:** Bind complex MongoDB query templates with custom parameter injections using the `@query` decorator.
14
+ * **Data Validation:** Fully powered by Pydantic v2 for robust runtime type checking and parsing.
15
+ * **Zero-Boilerplate ID Mapping:** Automatically handles conversions between stringified hexadecimal keys and native BSON `ObjectId` footprints.
16
+ * **Environment Profiles:** Flexible configurations handling multi-stage deployment environments (`dev`, `prod`, etc.) via YAML interpolation.
17
+
18
+ ---
19
+
20
+ ## 📁 Directory Structure
21
+
22
+ ```text
23
+ .
24
+ ├── README.md
25
+ ├── jpa
26
+ │ ├── __init__.py
27
+ │ ├── config.py # App bootstrapping, YAML parsing & environment mapping
28
+ │ └── mongo
29
+ │ ├── __init__.py
30
+ │ └── interface.py # Repository core interface, custom metadata, and queries
31
+ ├── pyproject.toml
32
+ └── requirements.txt
33
+
34
+ 🛠️ Getting Started
35
+ Prerequisites
36
+ • Python 3.13 or higher
37
+ Installation & Virtual Env Setup
38
+ 1. Clone the repository and navigate to its root: cd python-jpa
39
+ 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
40
+ 3. Install the project library dependencies: pip install -r requirements.txt
41
+ ⚙️ Configuration Management
42
+ The framework supports multi-profile YAML configurations with real-time environment substitution syntax (e.g., ${ENV_VAR:default_value}).
43
+ Create a resources/ directory at the root of your execution workspace and include your application sheets:
44
+ 1. Master Configuration (resources/settings.yml)
45
+ app:
46
+ profile: ${APP_PROFILE:dev} # Swaps profile to target environment configuration
47
+
48
+ 2. Environment Profile Configuration (resources/settings-dev.yml)
49
+ mongodb:
50
+ uri: ${MONGO_URI:mongodb://localhost:27017}
51
+ database: ${MONGO_DB_NAME:jpa_database}
52
+
53
+ framework:
54
+ logging:
55
+ enabled: true
56
+ level: "DEBUG"
57
+
58
+ 📖 Usage Example
59
+ Here is a quick overview of how you can configure a domain entity and generate auto-implemented interface pipelines:
60
+ 1. Define your Document Model
61
+ from pydantic import Field
62
+ from jpa.mongo.interface import DocumentModel, document
63
+
64
+ @document(name="users")
65
+ class User(DocumentModel):
66
+ id: str = Field(alias="_id", default=None)
67
+ username: str
68
+ email: str
69
+ age: int
70
+
71
+ 2. Declare your Interface Repository
72
+ By extending IRepository[T], method naming patterns are captured and converted into database interactions seamlessly.
73
+ from jpa.mongo.interface import IRepository, query
74
+ from typing import List, Optional
75
+
76
+ class UserRepository(IRepository[User]):
77
+
78
+ # 1. Query generation by structural method naming convention
79
+ def find_by_username(self, username: str) -> Optional[User]: ...
80
+
81
+ def count_by_age(self, age: int) -> int: ...
82
+
83
+ # 2. Templated declaration using placeholder substitutions
84
+ @query(definition={"email": "?0", "age": {"$gte": "?1"}})
85
+ def find_by_email_and_min_age(self, email: str, min_age: int) -> List[User]: ...
86
+
87
+ 3. Execute CRUD Actions
88
+ from jpa.config import close_db_connection
89
+
90
+ # Initialize repository instance
91
+ user_repo = UserRepository()
92
+
93
+ # Save a document
94
+ new_user = User(username="pratyush", email="pratyush@example.com", age=25)
95
+ user_id = user_repo.save(new_user)
96
+
97
+ # Fetch using automatic query generation
98
+ user = user_repo.find_by_username("pratyush")
99
+ print(f"Found User: {user.email if user else 'Not Found'}")
100
+
101
+ # Clean up connections on process termination
102
+ close_db_connection()
103
+
104
+ 📜 License
105
+ This project is open-source software licensed under the MIT License.
@@ -0,0 +1,2 @@
1
+ from jpa import mongo
2
+ from jpa import config
@@ -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()
@@ -0,0 +1,3 @@
1
+ from jpa.mongo.interface import IRepository
2
+ from jpa.mongo.interface import query
3
+ from jpa.mongo.interface import document
@@ -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,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "python-ppa"
7
+ version = "0.0.1"
8
+ description = "JPA-like ORM for Python"
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "Pratyush Kumar" }
14
+ ]
15
+ # Add this block to auto-install your core library requirements
16
+ dependencies = [
17
+ "annotated-types==0.7.0",
18
+ "dnspython==2.8.0",
19
+ "email-validator==2.3.0",
20
+ "idna==3.18",
21
+ "pydantic==2.13.4",
22
+ "pydantic_core==2.46.4",
23
+ "pymongo==4.17.0",
24
+ "python-dotenv==1.2.2",
25
+ "PyYAML==6.0.3",
26
+ "typing-inspection==0.4.2",
27
+ "typing_extensions==4.15.0"
28
+ ]
29
+
30
+ [tool.setuptools.packages.find]
31
+ # Automatically discover the 'jpa' package and its nested subpackages (like mongo)
32
+ include = ["jpa*"]
@@ -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,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ jpa/__init__.py
5
+ jpa/config.py
6
+ jpa/mongo/__init__.py
7
+ jpa/mongo/interface.py
8
+ python_ppa.egg-info/PKG-INFO
9
+ python_ppa.egg-info/SOURCES.txt
10
+ python_ppa.egg-info/dependency_links.txt
11
+ python_ppa.egg-info/requires.txt
12
+ python_ppa.egg-info/top_level.txt
@@ -0,0 +1,11 @@
1
+ annotated-types==0.7.0
2
+ dnspython==2.8.0
3
+ email-validator==2.3.0
4
+ idna==3.18
5
+ pydantic==2.13.4
6
+ pydantic_core==2.46.4
7
+ pymongo==4.17.0
8
+ python-dotenv==1.2.2
9
+ PyYAML==6.0.3
10
+ typing-inspection==0.4.2
11
+ typing_extensions==4.15.0
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+