python-ppa 0.1.5__tar.gz → 0.1.7__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.5
3
+ Version: 0.1.7
4
4
  Summary: JPA-like ORM for Python
5
5
  Author: Pratyush Kumar
6
6
  License-Expression: MIT
@@ -13,6 +13,7 @@ Requires-Dist: email-validator==2.3.0
13
13
  Requires-Dist: idna==3.18
14
14
  Requires-Dist: pydantic==2.13.4
15
15
  Requires-Dist: pydantic_core==2.46.4
16
+ Requires-Dist: psycopg[binary]>=3.2.0
16
17
  Requires-Dist: pymongo==4.17.0
17
18
  Requires-Dist: python-dotenv==1.2.2
18
19
  Requires-Dist: PyYAML==6.0.3
@@ -20,9 +21,9 @@ Requires-Dist: typing-inspection==0.4.2
20
21
  Requires-Dist: typing_extensions==4.15.0
21
22
  Dynamic: license-file
22
23
 
23
- # python-jpa
24
+ # python-ppa
24
25
 
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
+ `python-ppa` is a lightweight, Spring Data ppa-inspired persistence framework for MongoDB and PostgreSQL built on top of **Python 3.13+**, **Pydantic v2**, **PyMongo**, and **psycopg**.
26
27
 
27
28
  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,9 +31,9 @@ It uses advanced Python metaprogramming (`metaclass`) to dynamically generate Mo
30
31
 
31
32
  ## 🚀 Features
32
33
 
33
- * **JPA-Like Repository Pattern:** Declare an interface, and let the metaclass handle query assembly dynamically.
34
+ * **ppa-Like Repository Pattern:** Declare an interface, and let the metaclass handle query assembly dynamically.
34
35
  * **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
+ * **Custom Query Annotations:** Bind complex MongoDB or PostgreSQL query templates with custom parameter injections using the `@query` decorator.
36
37
  * **Data Validation:** Fully powered by Pydantic v2 for robust runtime type checking and parsing.
37
38
  * **Zero-Boilerplate ID Mapping:** Automatically handles conversions between stringified hexadecimal keys and native BSON `ObjectId` footprints.
38
39
  * **Environment Profiles:** Flexible configurations handling multi-stage deployment environments (`dev`, `prod`, etc.) via YAML interpolation.
@@ -44,44 +45,88 @@ It uses advanced Python metaprogramming (`metaclass`) to dynamically generate Mo
44
45
  ```text
45
46
  .
46
47
  ├── README.md
47
- ├── jpa
48
+ ├── ppa
48
49
  │ ├── __init__.py
49
50
  │ ├── config.py # App bootstrapping, YAML parsing & environment mapping
50
- │ └── mongo
51
+ │ ├── mongo
51
52
  │ ├── __init__.py
52
53
  │ └── interface.py # Repository core interface, custom metadata, and queries
54
+ │ └── postgres
55
+ │ ├── __init__.py
56
+ │ └── interface.py # PostgreSQL repository interface and SQL query conventions
53
57
  ├── pyproject.toml
54
58
  └── requirements.txt
59
+ ```
60
+
61
+ ## 🛠️ Getting Started
62
+
63
+ ### Prerequisites
64
+
65
+ * Python 3.13 or higher
66
+
67
+ ### Installation & Virtual Env Setup
68
+
69
+ 1. Clone the repository and navigate to its root: `cd python-ppa`
70
+ 2. Spin up a virtual environment and update your pip core dependencies:
71
+
72
+ ```bash
73
+ python3 -m venv venv
74
+ source venv/bin/activate
75
+ pip install --upgrade pip
76
+ ```
77
+
78
+ 3. Install the project library dependencies:
79
+
80
+ ```bash
81
+ pip install -r requirements.txt
82
+ ```
83
+
84
+ ## ⚙️ Configuration Management
55
85
 
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
86
  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)
87
+ Create a `resources/` directory at the root of your execution workspace and include your application sheets.
88
+
89
+ 1. Master Configuration (`resources/settings.yml`)
90
+
91
+ ```yaml
67
92
  app:
68
93
  profile: ${APP_PROFILE:dev} # Swaps profile to target environment configuration
94
+ ```
69
95
 
70
- 2. Environment Profile Configuration (resources/settings-dev.yml)
96
+ 2. Environment Profile Configuration (`resources/settings-dev.yml`)
97
+
98
+ ```yaml
71
99
  mongodb:
72
100
  uri: ${MONGO_URI:mongodb://localhost:27017}
73
- database: ${MONGO_DB_NAME:jpa_database}
101
+ database: ${MONGO_DB_NAME:ppa_database}
102
+
103
+ # Or configure PostgreSQL instead.
104
+ postgresql:
105
+ dsn: ${POSTGRES_DSN:postgresql://postgres:postgres@localhost:5432/ppa_database}
106
+
107
+ # Equivalent field-based PostgreSQL configuration:
108
+ # postgresql:
109
+ # host: ${POSTGRES_HOST:localhost}
110
+ # port: ${POSTGRES_PORT:5432}
111
+ # database: ${POSTGRES_DB_NAME:ppa_database}
112
+ # user: ${POSTGRES_USER:postgres}
113
+ # password: ${POSTGRES_PASSWORD:postgres}
74
114
 
75
115
  framework:
76
116
  logging:
77
117
  enabled: true
78
118
  level: "DEBUG"
119
+ ```
120
+
121
+ ## 📖 Usage Example
79
122
 
80
- 📖 Usage Example
81
123
  Here is a quick overview of how you can configure a domain entity and generate auto-implemented interface pipelines:
124
+
82
125
  1. Define your Document Model
126
+
127
+ ```python
83
128
  from pydantic import Field
84
- from jpa.mongo.interface import DocumentModel, document
129
+ from ppa.mongo.interface import DocumentModel, document
85
130
 
86
131
  @document(name="users")
87
132
  class User(DocumentModel):
@@ -89,10 +134,14 @@ class User(DocumentModel):
89
134
  username: str
90
135
  email: str
91
136
  age: int
137
+ ```
92
138
 
93
139
  2. Declare your Interface Repository
140
+
94
141
  By extending IRepository[T], method naming patterns are captured and converted into database interactions seamlessly.
95
- from jpa.mongo.interface import IRepository, query
142
+
143
+ ```python
144
+ from ppa.mongo.interface import IRepository, query
96
145
  from typing import List, Optional
97
146
 
98
147
  class UserRepository(IRepository[User]):
@@ -105,9 +154,12 @@ class UserRepository(IRepository[User]):
105
154
  # 2. Templated declaration using placeholder substitutions
106
155
  @query(definition={"email": "?0", "age": {"$gte": "?1"}})
107
156
  def find_by_email_and_min_age(self, email: str, min_age: int) -> List[User]: ...
157
+ ```
108
158
 
109
159
  3. Execute CRUD Actions
110
- from jpa.config import close_db_connection
160
+
161
+ ```python
162
+ from ppa.config import close_db_connection
111
163
 
112
164
  # Initialize repository instance
113
165
  user_repo = UserRepository()
@@ -122,6 +174,28 @@ print(f"Found User: {user.email if user else 'Not Found'}")
122
174
 
123
175
  # Clean up connections on process termination
124
176
  close_db_connection()
177
+ ```
178
+
179
+ PostgreSQL repositories follow the same naming conventions with SQL-backed models:
180
+
181
+ ```python
182
+ from pydantic import EmailStr
183
+ from ppa.postgres.interface import EntityModel, IRepository, query, table
184
+
185
+ @table(name="users")
186
+ class User(EntityModel):
187
+ username: str
188
+ email: EmailStr
189
+ age: int
190
+
191
+ class UserRepository(IRepository[User]):
192
+ def find_by_username(self, username: str) -> User | None: ...
193
+ def count_by_age(self, age: int) -> int: ...
194
+
195
+ @query("SELECT * FROM users WHERE email = ?0 AND age >= ?1")
196
+ def find_by_email_and_min_age(self, email: str, min_age: int) -> list[User]: ...
197
+ ```
198
+
199
+ ## 📜 License
125
200
 
126
- 📜 License
127
201
  This project is open-source software licensed under the MIT License.
@@ -1,6 +1,6 @@
1
- # python-jpa
1
+ # python-ppa
2
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**.
3
+ `python-ppa` is a lightweight, Spring Data ppa-inspired persistence framework for MongoDB and PostgreSQL built on top of **Python 3.13+**, **Pydantic v2**, **PyMongo**, and **psycopg**.
4
4
 
5
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
6
 
@@ -8,9 +8,9 @@ It uses advanced Python metaprogramming (`metaclass`) to dynamically generate Mo
8
8
 
9
9
  ## 🚀 Features
10
10
 
11
- * **JPA-Like Repository Pattern:** Declare an interface, and let the metaclass handle query assembly dynamically.
11
+ * **ppa-Like Repository Pattern:** Declare an interface, and let the metaclass handle query assembly dynamically.
12
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.
13
+ * **Custom Query Annotations:** Bind complex MongoDB or PostgreSQL query templates with custom parameter injections using the `@query` decorator.
14
14
  * **Data Validation:** Fully powered by Pydantic v2 for robust runtime type checking and parsing.
15
15
  * **Zero-Boilerplate ID Mapping:** Automatically handles conversions between stringified hexadecimal keys and native BSON `ObjectId` footprints.
16
16
  * **Environment Profiles:** Flexible configurations handling multi-stage deployment environments (`dev`, `prod`, etc.) via YAML interpolation.
@@ -22,44 +22,88 @@ It uses advanced Python metaprogramming (`metaclass`) to dynamically generate Mo
22
22
  ```text
23
23
  .
24
24
  ├── README.md
25
- ├── jpa
25
+ ├── ppa
26
26
  │ ├── __init__.py
27
27
  │ ├── config.py # App bootstrapping, YAML parsing & environment mapping
28
- │ └── mongo
28
+ │ ├── mongo
29
29
  │ ├── __init__.py
30
30
  │ └── interface.py # Repository core interface, custom metadata, and queries
31
+ │ └── postgres
32
+ │ ├── __init__.py
33
+ │ └── interface.py # PostgreSQL repository interface and SQL query conventions
31
34
  ├── pyproject.toml
32
35
  └── requirements.txt
36
+ ```
37
+
38
+ ## 🛠️ Getting Started
39
+
40
+ ### Prerequisites
41
+
42
+ * Python 3.13 or higher
43
+
44
+ ### Installation & Virtual Env Setup
45
+
46
+ 1. Clone the repository and navigate to its root: `cd python-ppa`
47
+ 2. Spin up a virtual environment and update your pip core dependencies:
48
+
49
+ ```bash
50
+ python3 -m venv venv
51
+ source venv/bin/activate
52
+ pip install --upgrade pip
53
+ ```
54
+
55
+ 3. Install the project library dependencies:
56
+
57
+ ```bash
58
+ pip install -r requirements.txt
59
+ ```
60
+
61
+ ## ⚙️ Configuration Management
33
62
 
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
63
  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)
64
+ Create a `resources/` directory at the root of your execution workspace and include your application sheets.
65
+
66
+ 1. Master Configuration (`resources/settings.yml`)
67
+
68
+ ```yaml
45
69
  app:
46
70
  profile: ${APP_PROFILE:dev} # Swaps profile to target environment configuration
71
+ ```
47
72
 
48
- 2. Environment Profile Configuration (resources/settings-dev.yml)
73
+ 2. Environment Profile Configuration (`resources/settings-dev.yml`)
74
+
75
+ ```yaml
49
76
  mongodb:
50
77
  uri: ${MONGO_URI:mongodb://localhost:27017}
51
- database: ${MONGO_DB_NAME:jpa_database}
78
+ database: ${MONGO_DB_NAME:ppa_database}
79
+
80
+ # Or configure PostgreSQL instead.
81
+ postgresql:
82
+ dsn: ${POSTGRES_DSN:postgresql://postgres:postgres@localhost:5432/ppa_database}
83
+
84
+ # Equivalent field-based PostgreSQL configuration:
85
+ # postgresql:
86
+ # host: ${POSTGRES_HOST:localhost}
87
+ # port: ${POSTGRES_PORT:5432}
88
+ # database: ${POSTGRES_DB_NAME:ppa_database}
89
+ # user: ${POSTGRES_USER:postgres}
90
+ # password: ${POSTGRES_PASSWORD:postgres}
52
91
 
53
92
  framework:
54
93
  logging:
55
94
  enabled: true
56
95
  level: "DEBUG"
96
+ ```
97
+
98
+ ## 📖 Usage Example
57
99
 
58
- 📖 Usage Example
59
100
  Here is a quick overview of how you can configure a domain entity and generate auto-implemented interface pipelines:
101
+
60
102
  1. Define your Document Model
103
+
104
+ ```python
61
105
  from pydantic import Field
62
- from jpa.mongo.interface import DocumentModel, document
106
+ from ppa.mongo.interface import DocumentModel, document
63
107
 
64
108
  @document(name="users")
65
109
  class User(DocumentModel):
@@ -67,10 +111,14 @@ class User(DocumentModel):
67
111
  username: str
68
112
  email: str
69
113
  age: int
114
+ ```
70
115
 
71
116
  2. Declare your Interface Repository
117
+
72
118
  By extending IRepository[T], method naming patterns are captured and converted into database interactions seamlessly.
73
- from jpa.mongo.interface import IRepository, query
119
+
120
+ ```python
121
+ from ppa.mongo.interface import IRepository, query
74
122
  from typing import List, Optional
75
123
 
76
124
  class UserRepository(IRepository[User]):
@@ -83,9 +131,12 @@ class UserRepository(IRepository[User]):
83
131
  # 2. Templated declaration using placeholder substitutions
84
132
  @query(definition={"email": "?0", "age": {"$gte": "?1"}})
85
133
  def find_by_email_and_min_age(self, email: str, min_age: int) -> List[User]: ...
134
+ ```
86
135
 
87
136
  3. Execute CRUD Actions
88
- from jpa.config import close_db_connection
137
+
138
+ ```python
139
+ from ppa.config import close_db_connection
89
140
 
90
141
  # Initialize repository instance
91
142
  user_repo = UserRepository()
@@ -100,6 +151,28 @@ print(f"Found User: {user.email if user else 'Not Found'}")
100
151
 
101
152
  # Clean up connections on process termination
102
153
  close_db_connection()
154
+ ```
155
+
156
+ PostgreSQL repositories follow the same naming conventions with SQL-backed models:
157
+
158
+ ```python
159
+ from pydantic import EmailStr
160
+ from ppa.postgres.interface import EntityModel, IRepository, query, table
161
+
162
+ @table(name="users")
163
+ class User(EntityModel):
164
+ username: str
165
+ email: EmailStr
166
+ age: int
167
+
168
+ class UserRepository(IRepository[User]):
169
+ def find_by_username(self, username: str) -> User | None: ...
170
+ def count_by_age(self, age: int) -> int: ...
171
+
172
+ @query("SELECT * FROM users WHERE email = ?0 AND age >= ?1")
173
+ def find_by_email_and_min_age(self, email: str, min_age: int) -> list[User]: ...
174
+ ```
175
+
176
+ ## 📜 License
103
177
 
104
- 📜 License
105
- This project is open-source software licensed under the MIT License.
178
+ This project is open-source software licensed under the MIT License.
@@ -0,0 +1,3 @@
1
+ from ppa import mongo
2
+ from ppa import postgres
3
+ from ppa import config
@@ -96,16 +96,33 @@ class ConfigSettings:
96
96
 
97
97
  self._config_data = _load_and_interpolate_yaml(profile_config_path)
98
98
  mongodb_config = _as_dict(self._config_data.get("mongodb"), context=f"{profile_filename}.mongodb")
99
+ postgres_config = _as_dict(
100
+ self._config_data.get("postgresql", self._config_data.get("postgres")),
101
+ context=f"{profile_filename}.postgresql",
102
+ )
99
103
 
100
104
  # 3. Expose parameters to the framework
101
105
  self.mongo_uri = mongodb_config.get("uri")
102
106
  self.database_name = mongodb_config.get("database")
107
+ self.postgres_dsn = self._resolve_postgres_dsn(postgres_config)
108
+ self.postgres_database_name = postgres_config.get("database") or postgres_config.get("dbname")
103
109
 
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:
110
+ has_mongo_config = bool(self.mongo_uri and self.database_name)
111
+ has_postgres_config = bool(self.postgres_dsn)
112
+
113
+ if mongodb_config and not has_mongo_config:
106
114
  raise KeyError(
107
115
  f"Invalid configuration format in {profile_filename}. Missing 'mongodb.uri' or 'mongodb.database'.")
108
116
 
117
+ if postgres_config and not has_postgres_config:
118
+ raise KeyError(
119
+ f"Invalid configuration format in {profile_filename}. Provide 'postgresql.dsn' or "
120
+ "'postgresql.host', 'postgresql.database', and 'postgresql.user'.")
121
+
122
+ if not has_mongo_config and not has_postgres_config:
123
+ raise KeyError(
124
+ f"Invalid configuration format in {profile_filename}. Configure either 'mongodb' or 'postgresql'.")
125
+
109
126
  framework_config = _as_dict(self._config_data.get("framework"), context=f"{profile_filename}.framework")
110
127
  framework_logging_config = _as_dict(framework_config.get("logging"),
111
128
  context=f"{profile_filename}.framework.logging")
@@ -115,6 +132,32 @@ class ConfigSettings:
115
132
  self.framework_log_date_format = str(
116
133
  framework_logging_config.get("date_format", DEFAULT_FRAMEWORK_LOG_DATE_FORMAT))
117
134
 
135
+ @staticmethod
136
+ def _resolve_postgres_dsn(postgres_config: dict[str, Any]) -> str | None:
137
+ dsn = postgres_config.get("dsn") or postgres_config.get("uri") or postgres_config.get("url")
138
+ if isinstance(dsn, str) and dsn:
139
+ return dsn
140
+
141
+ host = postgres_config.get("host")
142
+ database = postgres_config.get("database") or postgres_config.get("dbname")
143
+ user = postgres_config.get("user") or postgres_config.get("username")
144
+ if not host or not database or not user:
145
+ return None
146
+
147
+ parts = [
148
+ f"host={host}",
149
+ f"dbname={database}",
150
+ f"user={user}",
151
+ ]
152
+ optional_fields = {
153
+ "password": postgres_config.get("password"),
154
+ "port": postgres_config.get("port"),
155
+ "sslmode": postgres_config.get("sslmode"),
156
+ "connect_timeout": postgres_config.get("connect_timeout"),
157
+ }
158
+ parts.extend(f"{key}={value}" for key, value in optional_fields.items() if value not in (None, ""))
159
+ return " ".join(parts)
160
+
118
161
 
119
162
  def _configure_framework_logger() -> logging.Logger:
120
163
  logger = logging.getLogger(FRAMEWORK_LOGGER_NAME)
@@ -144,11 +187,23 @@ def get_framework_logger(name: str | None = None) -> logging.Logger:
144
187
  settings = ConfigSettings()
145
188
  framework_logger = _configure_framework_logger()
146
189
  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)
190
+ _mongo_client: MongoClient | None = None
191
+ db = None
192
+
193
+
194
+ def _get_mongo_client() -> MongoClient:
195
+ global _mongo_client, db
196
+ if not isinstance(settings.mongo_uri, str) or not settings.mongo_uri:
197
+ raise ValueError("MongoDB URI must be provided before using Mongo repositories")
198
+ if not isinstance(settings.database_name, str) or not settings.database_name:
199
+ raise ValueError("MongoDB database name must be provided before using Mongo repositories")
200
+
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
152
207
 
153
208
 
154
209
  def value(place_holder: str) -> Any:
@@ -196,10 +251,26 @@ def value(place_holder: str) -> Any:
196
251
 
197
252
  def get_collection(collection_name: str):
198
253
  """Provides repositories access to the shared single client pool."""
254
+ _get_mongo_client()
199
255
  return db[collection_name]
200
256
 
201
257
 
258
+ def get_postgres_connection():
259
+ """Creates a PostgreSQL connection using the active profile settings."""
260
+ if not isinstance(settings.postgres_dsn, str) or not settings.postgres_dsn:
261
+ raise ValueError("PostgreSQL DSN must be provided before using Postgres repositories")
262
+
263
+ try:
264
+ import psycopg
265
+ from psycopg.rows import dict_row
266
+ except ImportError as exc:
267
+ raise ImportError("Install psycopg to use PostgreSQL repositories.") from exc
268
+
269
+ return psycopg.connect(settings.postgres_dsn, row_factory=dict_row)
270
+
271
+
202
272
  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()
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()
@@ -0,0 +1,5 @@
1
+ from ppa.postgres.interface import EntityList
2
+ from ppa.postgres.interface import EntityModel
3
+ from ppa.postgres.interface import IRepository
4
+ from ppa.postgres.interface import query
5
+ from ppa.postgres.interface import table
@@ -0,0 +1,344 @@
1
+ import inspect
2
+ import re
3
+ from typing import Any, Callable, ClassVar, Generic, List, Optional, TypeVar, cast, get_args, get_origin, get_type_hints
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ from ppa.config import get_framework_logger, get_postgres_connection
8
+
9
+ T = TypeVar("T", bound="EntityModel")
10
+ F = TypeVar("F", bound=Callable[..., Any])
11
+ framework_logger = get_framework_logger("postgres.repository")
12
+ IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
13
+
14
+
15
+ class EntityModel(BaseModel):
16
+ """Base class for PostgreSQL-backed models."""
17
+
18
+ __table_name__: ClassVar[str]
19
+
20
+ model_config = ConfigDict(
21
+ populate_by_name=True,
22
+ arbitrary_types_allowed=True,
23
+ )
24
+
25
+ id: Optional[int] = None
26
+
27
+ def model_dump(self, **kwargs: Any) -> dict[str, Any]:
28
+ kwargs.setdefault("exclude_none", True)
29
+ return super().model_dump(**kwargs)
30
+
31
+
32
+ class EntityList(list[T]):
33
+ """List of entities that can be used to perform bulk operations."""
34
+
35
+ def model_dump(self, **kwargs: Any) -> list[dict[str, Any]]:
36
+ return [entity.model_dump(**kwargs) for entity in self]
37
+
38
+ @classmethod
39
+ def model_validate(cls, obj: list[Any], entity_cls: type[T]) -> "EntityList[T]":
40
+ if not isinstance(obj, list) and not hasattr(obj, "__iter__"):
41
+ raise ValueError("Data provided to EntityList.model_validate must be iterable.")
42
+
43
+ return cls([entity_cls.model_validate(item) for item in obj])
44
+
45
+
46
+ def query(definition: str) -> Callable[[F], F]:
47
+ """Annotation to specify a custom PostgreSQL query template with ?0 style placeholders."""
48
+
49
+ def decorator(func: F) -> F:
50
+ func.__query_template__ = definition
51
+ return func
52
+
53
+ return decorator
54
+
55
+
56
+ def table(name: str) -> Callable[[type[EntityModel]], type[EntityModel]]:
57
+ """Class decorator to bind a Pydantic model to a PostgreSQL table."""
58
+
59
+ _quote_identifier(name)
60
+
61
+ def decorator(cls: type[EntityModel]) -> type[EntityModel]:
62
+ cls.__table_name__ = name
63
+ return cls
64
+
65
+ return decorator
66
+
67
+
68
+ def _quote_identifier(identifier: str) -> str:
69
+ if not IDENTIFIER_PATTERN.match(identifier):
70
+ raise ValueError(f"Invalid SQL identifier: {identifier}")
71
+ return f'"{identifier}"'
72
+
73
+
74
+ def _field_names(entity_cls: type[EntityModel]) -> set[str]:
75
+ return set(entity_cls.model_fields.keys())
76
+
77
+
78
+ def _build_where_clause(field_name: str) -> str:
79
+ return f"{_quote_identifier(field_name)} = %s"
80
+
81
+
82
+ def _custom_query_to_sql(template: str, ordered_args: list[Any]) -> tuple[str, list[Any]]:
83
+ values: list[Any] = []
84
+
85
+ def replace(match: re.Match[str]) -> str:
86
+ idx = int(match.group(1))
87
+ try:
88
+ values.append(ordered_args[idx])
89
+ except IndexError as exc:
90
+ raise ValueError(f"Missing argument for SQL placeholder ?{idx}") from exc
91
+ return "%s"
92
+
93
+ return re.sub(r"\?(\d+)", replace, template), values
94
+
95
+
96
+ class RepositoryMeta(type):
97
+ def __new__(mcs, name: str, bases: tuple[type, ...], namespace: dict[str, Any]):
98
+ cls = super().__new__(mcs, name, bases, namespace)
99
+
100
+ if name == "IRepository":
101
+ return cls
102
+
103
+ entity_cls = mcs._extract_entity_cls(cls)
104
+ if not entity_cls:
105
+ return cls
106
+
107
+ typed_entity_cls = cast(type[EntityModel], entity_cls)
108
+
109
+ if not hasattr(typed_entity_cls, "__table_name__"):
110
+ raise TypeError(
111
+ f"RepositoryDefinitionError: The model '{entity_cls.__name__}' "
112
+ f"used by '{name}' must be annotated with @table(name='...')"
113
+ )
114
+
115
+ cls._entity_cls = typed_entity_cls
116
+ valid_fields = _field_names(typed_entity_cls)
117
+
118
+ for attr_name, attr_value in list(namespace.items()):
119
+ if inspect.isfunction(attr_value) and not attr_name.startswith("_"):
120
+ query_template = getattr(attr_value, "__query_template__", None)
121
+ is_convention = re.match(r"(find_by|find_all_by|exists_by|count_by)_(.*)", attr_name)
122
+
123
+ if not is_convention and not query_template:
124
+ framework_logger.info("Convention does not exist, relying on user implementation")
125
+ continue
126
+
127
+ hints = get_type_hints(attr_value)
128
+ return_type = hints.get("return")
129
+
130
+ if isinstance(query_template, str):
131
+ generated_method = mcs._build_annotated_method(
132
+ attr_name,
133
+ attr_value,
134
+ query_template,
135
+ return_type,
136
+ typed_entity_cls,
137
+ )
138
+ elif is_convention is not None:
139
+ generated_method = mcs._build_convention_method(
140
+ attr_name,
141
+ is_convention,
142
+ attr_value,
143
+ valid_fields,
144
+ return_type,
145
+ typed_entity_cls,
146
+ )
147
+ else:
148
+ continue
149
+
150
+ setattr(cls, attr_name, generated_method)
151
+
152
+ return cls
153
+
154
+ @staticmethod
155
+ def _extract_entity_cls(cls: type) -> type[BaseModel] | None:
156
+ for base in getattr(cls, "__orig_bases__", []):
157
+ if get_origin(base) is IRepository:
158
+ args = get_args(base)
159
+ if args and issubclass(args[0], BaseModel):
160
+ return args[0]
161
+ return None
162
+
163
+ @staticmethod
164
+ def _build_annotated_method(
165
+ name: str,
166
+ func: Callable[..., Any],
167
+ template: str,
168
+ return_type: Any,
169
+ entity_cls: type[EntityModel],
170
+ ) -> Callable[..., Any]:
171
+ sig = inspect.signature(func)
172
+ param_names = [p for p in sig.parameters if p != "self"]
173
+
174
+ def wrapper(self, *args: Any, **kwargs: Any) -> Any:
175
+ bound = sig.bind(self, *args, **kwargs)
176
+ bound.apply_defaults()
177
+ ordered_args = [bound.arguments[p_name] for p_name in param_names if p_name in bound.arguments]
178
+ sql, params = _custom_query_to_sql(template, ordered_args)
179
+
180
+ framework_logger.debug(
181
+ "Invoked custom Postgres query %s.%s with args=%s kwargs=%s",
182
+ self.__class__.__name__,
183
+ name,
184
+ args,
185
+ kwargs,
186
+ )
187
+
188
+ return execute_dynamic_query(sql, params, "custom", return_type, entity_cls)
189
+
190
+ wrapper.__name__ = name
191
+ wrapper.__qualname__ = f"{entity_cls.__name__}Repository.{name}"
192
+ return wrapper
193
+
194
+ @staticmethod
195
+ def _build_convention_method(
196
+ name: str,
197
+ match: re.Match[str],
198
+ func: Callable[..., Any],
199
+ valid_fields: set[str],
200
+ return_type: Any,
201
+ entity_cls: type[EntityModel],
202
+ ) -> Callable[..., Any]:
203
+ prefix, field_name = match.groups()
204
+
205
+ if field_name not in valid_fields:
206
+ raise TypeError(
207
+ f"RepositoryDefinitionError: Field '{field_name}' does not exist on model {entity_cls.__name__}"
208
+ )
209
+
210
+ sql_field = _quote_identifier(field_name)
211
+ table_name = _quote_identifier(entity_cls.__table_name__)
212
+ sig = inspect.signature(func)
213
+ param_names = [p for p in sig.parameters if p != "self"]
214
+
215
+ def wrapper(self, *args: Any, **kwargs: Any) -> Any:
216
+ bound = sig.bind_partial(self, *args, **kwargs)
217
+ bound.apply_defaults()
218
+ actual_value = bound.arguments.get(param_names[0]) if param_names else None
219
+
220
+ if prefix == "count_by":
221
+ sql = f"SELECT COUNT(*) AS count FROM {table_name} WHERE {sql_field} = %s"
222
+ elif prefix == "exists_by":
223
+ sql = f"SELECT EXISTS(SELECT 1 FROM {table_name} WHERE {sql_field} = %s LIMIT 1) AS exists"
224
+ else:
225
+ sql = f"SELECT * FROM {table_name} WHERE {sql_field} = %s"
226
+
227
+ framework_logger.debug(
228
+ "Invoked convention Postgres query %s.%s using %s=%r",
229
+ self.__class__.__name__,
230
+ name,
231
+ field_name,
232
+ actual_value,
233
+ )
234
+
235
+ return execute_dynamic_query(sql, [actual_value], prefix, return_type, entity_cls)
236
+
237
+ wrapper.__name__ = name
238
+ wrapper.__qualname__ = f"{entity_cls.__name__}Repository.{name}"
239
+ return wrapper
240
+
241
+
242
+ def execute_dynamic_query(
243
+ sql: str,
244
+ params: list[Any],
245
+ strategy: str,
246
+ return_type: Any,
247
+ entity_cls: type[EntityModel],
248
+ ) -> Any:
249
+ framework_logger.debug("Executing PostgreSQL query with strategy [%s]: %s", strategy, sql)
250
+ origin_type = get_origin(return_type) or return_type
251
+
252
+ with get_postgres_connection() as connection:
253
+ with connection.cursor() as cursor:
254
+ cursor.execute(sql, params)
255
+
256
+ if strategy == "count_by" or origin_type is int:
257
+ row = cursor.fetchone()
258
+ return int(row["count"]) if row else 0
259
+
260
+ if strategy == "exists_by" or origin_type is bool:
261
+ row = cursor.fetchone()
262
+ return bool(row["exists"]) if row else False
263
+
264
+ if origin_type in (list, EntityList):
265
+ rows = cursor.fetchall()
266
+ results = [entity_cls.model_validate(row) for row in rows]
267
+ return EntityList(results) if origin_type is EntityList else results
268
+
269
+ row = cursor.fetchone()
270
+ return entity_cls.model_validate(row) if row else None
271
+
272
+
273
+ class IRepository(Generic[T], metaclass=RepositoryMeta):
274
+ def __init__(self):
275
+ entity_cls = cast(type[EntityModel], self._entity_cls)
276
+ self.table_name = entity_cls.__table_name__
277
+ framework_logger.debug(
278
+ "Initialized repository [%s] for table [%s]",
279
+ self.__class__.__name__,
280
+ self.table_name,
281
+ )
282
+
283
+ def save(self, pyd_model: T) -> str:
284
+ data = pyd_model.model_dump()
285
+ data.pop("id", None)
286
+ if not data:
287
+ raise ValueError("No data provided for insert")
288
+
289
+ columns = list(data.keys())
290
+ quoted_columns = ", ".join(_quote_identifier(column) for column in columns)
291
+ placeholders = ", ".join(["%s"] * len(columns))
292
+ sql = f"INSERT INTO {_quote_identifier(self.table_name)} ({quoted_columns}) VALUES ({placeholders}) RETURNING id"
293
+
294
+ with get_postgres_connection() as connection:
295
+ with connection.cursor() as cursor:
296
+ cursor.execute(sql, [data[column] for column in columns])
297
+ row = cursor.fetchone()
298
+ return str(row["id"]) if row and "id" in row else ""
299
+
300
+ def save_all(self, pyd_models: List[T]) -> List[str]:
301
+ return [self.save(pyd_model) for pyd_model in pyd_models]
302
+
303
+ def find_all(self) -> EntityList[T]:
304
+ sql = f"SELECT * FROM {_quote_identifier(self.table_name)}"
305
+ with get_postgres_connection() as connection:
306
+ with connection.cursor() as cursor:
307
+ cursor.execute(sql)
308
+ rows = cursor.fetchall()
309
+ return EntityList([self._entity_cls.model_validate(row) for row in rows])
310
+
311
+ def find(self, query_dict: dict[str, Any]) -> T:
312
+ if not query_dict:
313
+ raise ValueError("Query criteria must be provided")
314
+
315
+ clauses = [_build_where_clause(field_name) for field_name in query_dict]
316
+ sql = f"SELECT * FROM {_quote_identifier(self.table_name)} WHERE {' AND '.join(clauses)} LIMIT 1"
317
+
318
+ with get_postgres_connection() as connection:
319
+ with connection.cursor() as cursor:
320
+ cursor.execute(sql, list(query_dict.values()))
321
+ row = cursor.fetchone()
322
+ if not row:
323
+ raise ValueError("No data found")
324
+ return self._entity_cls.model_validate(row)
325
+
326
+ def delete(self, resource_id: str) -> bool:
327
+ sql = f"DELETE FROM {_quote_identifier(self.table_name)} WHERE {_quote_identifier('id')} = %s"
328
+ with get_postgres_connection() as connection:
329
+ with connection.cursor() as cursor:
330
+ cursor.execute(sql, [resource_id])
331
+ return cursor.rowcount > 0
332
+
333
+ def update(self, resource_id: str, data: dict[str, Any]) -> bool:
334
+ update_data = {key: value for key, value in data.items() if key != "id"}
335
+ if not update_data:
336
+ raise ValueError("No data provided for update")
337
+
338
+ assignments = ", ".join(f"{_quote_identifier(field_name)} = %s" for field_name in update_data)
339
+ sql = f"UPDATE {_quote_identifier(self.table_name)} SET {assignments} WHERE {_quote_identifier('id')} = %s"
340
+
341
+ with get_postgres_connection() as connection:
342
+ with connection.cursor() as cursor:
343
+ cursor.execute(sql, [*update_data.values(), resource_id])
344
+ return cursor.rowcount > 0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-ppa"
7
- version = "0.1.5"
7
+ version = "0.1.7"
8
8
  description = "JPA-like ORM for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -20,6 +20,7 @@ dependencies = [
20
20
  "idna==3.18",
21
21
  "pydantic==2.13.4",
22
22
  "pydantic_core==2.46.4",
23
+ "psycopg[binary]>=3.2.0",
23
24
  "pymongo==4.17.0",
24
25
  "python-dotenv==1.2.2",
25
26
  "PyYAML==6.0.3",
@@ -29,4 +30,4 @@ dependencies = [
29
30
 
30
31
  [tool.setuptools.packages.find]
31
32
  # Automatically discover the 'ppa' package and its nested subpackages (like mongo)
32
- include = ["ppa*"]
33
+ include = ["ppa*"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-ppa
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: JPA-like ORM for Python
5
5
  Author: Pratyush Kumar
6
6
  License-Expression: MIT
@@ -13,6 +13,7 @@ Requires-Dist: email-validator==2.3.0
13
13
  Requires-Dist: idna==3.18
14
14
  Requires-Dist: pydantic==2.13.4
15
15
  Requires-Dist: pydantic_core==2.46.4
16
+ Requires-Dist: psycopg[binary]>=3.2.0
16
17
  Requires-Dist: pymongo==4.17.0
17
18
  Requires-Dist: python-dotenv==1.2.2
18
19
  Requires-Dist: PyYAML==6.0.3
@@ -20,9 +21,9 @@ Requires-Dist: typing-inspection==0.4.2
20
21
  Requires-Dist: typing_extensions==4.15.0
21
22
  Dynamic: license-file
22
23
 
23
- # python-jpa
24
+ # python-ppa
24
25
 
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
+ `python-ppa` is a lightweight, Spring Data ppa-inspired persistence framework for MongoDB and PostgreSQL built on top of **Python 3.13+**, **Pydantic v2**, **PyMongo**, and **psycopg**.
26
27
 
27
28
  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,9 +31,9 @@ It uses advanced Python metaprogramming (`metaclass`) to dynamically generate Mo
30
31
 
31
32
  ## 🚀 Features
32
33
 
33
- * **JPA-Like Repository Pattern:** Declare an interface, and let the metaclass handle query assembly dynamically.
34
+ * **ppa-Like Repository Pattern:** Declare an interface, and let the metaclass handle query assembly dynamically.
34
35
  * **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
+ * **Custom Query Annotations:** Bind complex MongoDB or PostgreSQL query templates with custom parameter injections using the `@query` decorator.
36
37
  * **Data Validation:** Fully powered by Pydantic v2 for robust runtime type checking and parsing.
37
38
  * **Zero-Boilerplate ID Mapping:** Automatically handles conversions between stringified hexadecimal keys and native BSON `ObjectId` footprints.
38
39
  * **Environment Profiles:** Flexible configurations handling multi-stage deployment environments (`dev`, `prod`, etc.) via YAML interpolation.
@@ -44,44 +45,88 @@ It uses advanced Python metaprogramming (`metaclass`) to dynamically generate Mo
44
45
  ```text
45
46
  .
46
47
  ├── README.md
47
- ├── jpa
48
+ ├── ppa
48
49
  │ ├── __init__.py
49
50
  │ ├── config.py # App bootstrapping, YAML parsing & environment mapping
50
- │ └── mongo
51
+ │ ├── mongo
51
52
  │ ├── __init__.py
52
53
  │ └── interface.py # Repository core interface, custom metadata, and queries
54
+ │ └── postgres
55
+ │ ├── __init__.py
56
+ │ └── interface.py # PostgreSQL repository interface and SQL query conventions
53
57
  ├── pyproject.toml
54
58
  └── requirements.txt
59
+ ```
60
+
61
+ ## 🛠️ Getting Started
62
+
63
+ ### Prerequisites
64
+
65
+ * Python 3.13 or higher
66
+
67
+ ### Installation & Virtual Env Setup
68
+
69
+ 1. Clone the repository and navigate to its root: `cd python-ppa`
70
+ 2. Spin up a virtual environment and update your pip core dependencies:
71
+
72
+ ```bash
73
+ python3 -m venv venv
74
+ source venv/bin/activate
75
+ pip install --upgrade pip
76
+ ```
77
+
78
+ 3. Install the project library dependencies:
79
+
80
+ ```bash
81
+ pip install -r requirements.txt
82
+ ```
83
+
84
+ ## ⚙️ Configuration Management
55
85
 
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
86
  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)
87
+ Create a `resources/` directory at the root of your execution workspace and include your application sheets.
88
+
89
+ 1. Master Configuration (`resources/settings.yml`)
90
+
91
+ ```yaml
67
92
  app:
68
93
  profile: ${APP_PROFILE:dev} # Swaps profile to target environment configuration
94
+ ```
69
95
 
70
- 2. Environment Profile Configuration (resources/settings-dev.yml)
96
+ 2. Environment Profile Configuration (`resources/settings-dev.yml`)
97
+
98
+ ```yaml
71
99
  mongodb:
72
100
  uri: ${MONGO_URI:mongodb://localhost:27017}
73
- database: ${MONGO_DB_NAME:jpa_database}
101
+ database: ${MONGO_DB_NAME:ppa_database}
102
+
103
+ # Or configure PostgreSQL instead.
104
+ postgresql:
105
+ dsn: ${POSTGRES_DSN:postgresql://postgres:postgres@localhost:5432/ppa_database}
106
+
107
+ # Equivalent field-based PostgreSQL configuration:
108
+ # postgresql:
109
+ # host: ${POSTGRES_HOST:localhost}
110
+ # port: ${POSTGRES_PORT:5432}
111
+ # database: ${POSTGRES_DB_NAME:ppa_database}
112
+ # user: ${POSTGRES_USER:postgres}
113
+ # password: ${POSTGRES_PASSWORD:postgres}
74
114
 
75
115
  framework:
76
116
  logging:
77
117
  enabled: true
78
118
  level: "DEBUG"
119
+ ```
120
+
121
+ ## 📖 Usage Example
79
122
 
80
- 📖 Usage Example
81
123
  Here is a quick overview of how you can configure a domain entity and generate auto-implemented interface pipelines:
124
+
82
125
  1. Define your Document Model
126
+
127
+ ```python
83
128
  from pydantic import Field
84
- from jpa.mongo.interface import DocumentModel, document
129
+ from ppa.mongo.interface import DocumentModel, document
85
130
 
86
131
  @document(name="users")
87
132
  class User(DocumentModel):
@@ -89,10 +134,14 @@ class User(DocumentModel):
89
134
  username: str
90
135
  email: str
91
136
  age: int
137
+ ```
92
138
 
93
139
  2. Declare your Interface Repository
140
+
94
141
  By extending IRepository[T], method naming patterns are captured and converted into database interactions seamlessly.
95
- from jpa.mongo.interface import IRepository, query
142
+
143
+ ```python
144
+ from ppa.mongo.interface import IRepository, query
96
145
  from typing import List, Optional
97
146
 
98
147
  class UserRepository(IRepository[User]):
@@ -105,9 +154,12 @@ class UserRepository(IRepository[User]):
105
154
  # 2. Templated declaration using placeholder substitutions
106
155
  @query(definition={"email": "?0", "age": {"$gte": "?1"}})
107
156
  def find_by_email_and_min_age(self, email: str, min_age: int) -> List[User]: ...
157
+ ```
108
158
 
109
159
  3. Execute CRUD Actions
110
- from jpa.config import close_db_connection
160
+
161
+ ```python
162
+ from ppa.config import close_db_connection
111
163
 
112
164
  # Initialize repository instance
113
165
  user_repo = UserRepository()
@@ -122,6 +174,28 @@ print(f"Found User: {user.email if user else 'Not Found'}")
122
174
 
123
175
  # Clean up connections on process termination
124
176
  close_db_connection()
177
+ ```
178
+
179
+ PostgreSQL repositories follow the same naming conventions with SQL-backed models:
180
+
181
+ ```python
182
+ from pydantic import EmailStr
183
+ from ppa.postgres.interface import EntityModel, IRepository, query, table
184
+
185
+ @table(name="users")
186
+ class User(EntityModel):
187
+ username: str
188
+ email: EmailStr
189
+ age: int
190
+
191
+ class UserRepository(IRepository[User]):
192
+ def find_by_username(self, username: str) -> User | None: ...
193
+ def count_by_age(self, age: int) -> int: ...
194
+
195
+ @query("SELECT * FROM users WHERE email = ?0 AND age >= ?1")
196
+ def find_by_email_and_min_age(self, email: str, min_age: int) -> list[User]: ...
197
+ ```
198
+
199
+ ## 📜 License
125
200
 
126
- 📜 License
127
201
  This project is open-source software licensed under the MIT License.
@@ -5,6 +5,8 @@ ppa/__init__.py
5
5
  ppa/config.py
6
6
  ppa/mongo/__init__.py
7
7
  ppa/mongo/interface.py
8
+ ppa/postgres/__init__.py
9
+ ppa/postgres/interface.py
8
10
  python_ppa.egg-info/PKG-INFO
9
11
  python_ppa.egg-info/SOURCES.txt
10
12
  python_ppa.egg-info/dependency_links.txt
@@ -4,6 +4,7 @@ email-validator==2.3.0
4
4
  idna==3.18
5
5
  pydantic==2.13.4
6
6
  pydantic_core==2.46.4
7
+ psycopg[binary]>=3.2.0
7
8
  pymongo==4.17.0
8
9
  python-dotenv==1.2.2
9
10
  PyYAML==6.0.3
@@ -1,2 +0,0 @@
1
- from ppa import mongo
2
- from ppa import config
File without changes
File without changes