duo-orm 0.1.0__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.
duo_orm/session.py ADDED
@@ -0,0 +1,36 @@
1
+ # duo_orm/session.py
2
+
3
+ import asyncio
4
+ from contextvars import ContextVar
5
+ from typing import Union
6
+
7
+ from sqlalchemy.orm import Session
8
+ from sqlalchemy.ext.asyncio import AsyncSession
9
+
10
+ # This ContextVar is the heart of our context-aware session management.
11
+ # It acts as a task-safe (or thread-safe) global variable that holds the
12
+ # active session for the current unit of work (e.g., a web request or a
13
+ # transaction block).
14
+ #
15
+ # The rest of the ORM will use .get() on this variable to find the
16
+ # currently active session.
17
+ active_session_var: ContextVar[Union[Session, AsyncSession]] = ContextVar("active_session")
18
+
19
+
20
+ def is_async_context() -> bool:
21
+ """
22
+ Checks if the code is currently running inside an asyncio event loop.
23
+
24
+ This is the primary mechanism our dispatcher functions use to decide
25
+ whether to run synchronous or asynchronous database logic.
26
+
27
+ Returns:
28
+ bool: True if inside an event loop, False otherwise.
29
+ """
30
+ try:
31
+ # This will succeed if an event loop is running in the current thread.
32
+ asyncio.get_running_loop()
33
+ return True
34
+ except RuntimeError:
35
+ # get_running_loop() raises a RuntimeError if no loop is running.
36
+ return False
@@ -0,0 +1,396 @@
1
+ Metadata-Version: 2.4
2
+ Name: duo-orm
3
+ Version: 0.1.0
4
+ Summary: An opinionated, modern ORM for Python combining the power of SQLAlchemy 2.0 with a clean, symmetrical API for sync and async operations.
5
+ Author-email: Your Name <you@example.com>
6
+ Project-URL: Homepage, https://github.com/example/duo-orm
7
+ Project-URL: Bug Tracker, https://github.com/example/duo-orm/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: Database
14
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
+ Requires-Python: >=3.12
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: sqlalchemy>=2.0
18
+ Requires-Dist: alembic>=1.8
19
+ Requires-Dist: click>=8.0
20
+ Requires-Dist: toml>=0.10
21
+ Requires-Dist: aiosqlite>=0.17
22
+ Provides-Extra: all
23
+ Requires-Dist: psycopg[binary]>=3.1; extra == "all"
24
+ Requires-Dist: oracledb>=2.0; extra == "all"
25
+ Requires-Dist: pymysql>=1.1; extra == "all"
26
+ Requires-Dist: asyncmy>=0.2; extra == "all"
27
+ Requires-Dist: pyodbc>=5.0; extra == "all"
28
+ Requires-Dist: aioodbc>=0.5; extra == "all"
29
+ Requires-Dist: aiosqlite>=0.20; extra == "all"
30
+ Provides-Extra: postgresql
31
+ Requires-Dist: psycopg[binary]>=3.1; extra == "postgresql"
32
+ Provides-Extra: oracle
33
+ Requires-Dist: oracledb>=2.0; extra == "oracle"
34
+ Provides-Extra: mysql
35
+ Requires-Dist: pymysql>=1.1; extra == "mysql"
36
+ Requires-Dist: asyncmy>=0.2; extra == "mysql"
37
+ Provides-Extra: mssql
38
+ Requires-Dist: pyodbc>=5.0; extra == "mssql"
39
+ Requires-Dist: aioodbc>=0.5; extra == "mssql"
40
+ Provides-Extra: sqlite
41
+ Requires-Dist: aiosqlite>=0.20; extra == "sqlite"
42
+ Provides-Extra: dev
43
+ Requires-Dist: pytest>=7.0; extra == "dev"
44
+ Requires-Dist: pytest-asyncio>=0.20; extra == "dev"
45
+ Requires-Dist: ruff>=0.1; extra == "dev"
46
+ Requires-Dist: black>=23.0; extra == "dev"
47
+
48
+ # DuoORM
49
+
50
+ An opinionated, modern ORM for Python combining the power of SQLAlchemy 2.0 with a clean, symmetrical API for sync and async operations.
51
+
52
+ ## Core Philosophy
53
+
54
+ DuoORM is built on a simple idea: you are in explicit control of the 'Unit of Work'. The API has two predictable modes and each `Database(...)` you instantiate manufactures its own `db.Model` base. Models from `db1.Model` and `db2.Model` stay isolated—even if they point to the same physical database—so you can safely manage multiple connections in one app (just keep each model hierarchy tied to its owning `db`).
55
+
56
+ * **Default Mode ("Statement-Driven"):** By default, every call (`.save()`, `.first()`) is its own "micro-transaction." The ORM creates a short-lived session that only knows about the single object you are operating on. It will predictably not see related objects, guaranteeing a simple, single-statement operation.
57
+
58
+ * **Transaction Mode ("State-Driven"):** When you use `async with db.transaction():`, you "opt-in" to a full-power, state-tracking Unit of Work. The ORM will use a single shared session for the entire block, allowing you to work with multiple objects, relationships, and cascades, all of which will be committed or rolled back together.
59
+
60
+ **Philosophy:** Clarity over cleverness. The 'magic' of state-tracking only happens when you explicitly ask for it.
61
+
62
+ ## Session & Transaction Model
63
+
64
+ There is no autosession detection logic. By default each ORM call opens a connection, runs exactly one SQL statement, commits (for writes), and closes. When you need multi-step or related work to behave atomically, you wrap it in an explicit transaction block:
65
+
66
+ ```python
67
+ async with db.transaction():
68
+ user = await User.where(User.id == 1).first()
69
+ post = Post(title="Hello", author_id=user.id)
70
+ await post.save()
71
+ ```
72
+
73
+ Inside the block your code automatically shares a single session. The block commits when it exits successfully and rolls back on error, giving you predictable Unit-of-Work semantics only when you opt-in.
74
+
75
+ ## Getting Started
76
+
77
+ ### 1. Installation
78
+
79
+ Drivers are managed automatically. Supply only base dialect URLs (e.g., `postgresql://...`, `mysql://...`, `sqlite:///file.db`) — do **not** include `+driver`; we inject the correct sync/async driver for you. If you pass extra query parameters that your DBAPI rejects, the error will surface directly from the driver so you can fix the URL.
80
+
81
+ ```bash
82
+ # Default: install with SQLite support (stdlib sqlite3 + aiosqlite)
83
+ pip install duo-orm
84
+
85
+ # Optional: install only the drivers you need
86
+ pip install duo-orm[postgresql] # psycopg (sync+async)
87
+ pip install duo-orm[mysql] # pymysql (sync) + asyncmy (async)
88
+ pip install duo-orm[mssql] # pyodbc (sync) + aioodbc (async)
89
+ pip install duo-orm[oracle] # oracledb (sync+async)
90
+ pip install duo-orm[all] # explicitly install everything
91
+ ```
92
+ Need a stdlib SQLite fallback? See the SQLite note below:
93
+
94
+ > SQLite fallback (only if your Python lacks stdlib sqlite3, e.g., minimal Docker/Lambda):
95
+ > ```bash
96
+ > pip install pysqlite3-binary
97
+ > ```
98
+ > Then alias it once at startup:
99
+ > ```python
100
+ > import sys, pysqlite3
101
+ > sys.modules["sqlite3"] = pysqlite3
102
+ > ```
103
+ > This makes `import sqlite3` use the binary fallback.
104
+
105
+ Async derivation uses the async driver for your dialect (psycopg for Postgres, asyncmy for MySQL, aioodbc for MSSQL, aiosqlite for SQLite). If the async driver isn’t installed, async calls will fail—install the matching extra above or `duo-orm[all]`.
106
+
107
+ DuoORM rides on SQLAlchemy’s dialect support. Features are available per backend only if SQLAlchemy exposes them (e.g., full JSON/ARRAY helpers on PostgreSQL; other dialects allow JSON storage but not the richer operators, so related tests are skipped with a reason).
108
+
109
+ ### 2. Initialization
110
+
111
+ Run the `init` command to create the basic structure:
112
+
113
+ ```bash
114
+ duo-orm init
115
+ ```
116
+
117
+ By default, scaffolding lands under `<project-root>/db/`:
118
+
119
+ ```
120
+ db/
121
+ ├── database.py
122
+ ├── models/
123
+ │ └── __init__.py
124
+ └── migrations/
125
+ ├── alembic.ini
126
+ ├── env.py
127
+ ├── script.py.mako
128
+ └── versions/
129
+ ```
130
+
131
+ `duo-orm init` also creates (or updates) `<project-root>/pyproject.toml` so it contains:
132
+
133
+ ```toml
134
+ [tool.duo-orm]
135
+ duo_orm_dir = "db"
136
+ ```
137
+
138
+ Need a different location? Pass `--dir` during init:
139
+
140
+ ```bash
141
+ duo-orm init --dir src/app/db_core
142
+ duo-orm migration create "add users"
143
+ ```
144
+
145
+ The chosen path is written back to `pyproject.toml`, so future `duo-orm migration ...` commands can omit `--dir`. Edit that stanza (or re-run `init --dir ...`) whenever you want to move the database stack.
146
+
147
+ **Optional:** call `db.connect()` during application startup (after importing your generated `database.py`) if you want to surface misconfiguration or driver issues immediately rather than waiting for the first query/transaction.
148
+
149
+ ### 3. Defining Models
150
+
151
+ Define your models in the `models` directory, inheriting from `db.Model`:
152
+
153
+ ```python
154
+ # models/user.py
155
+ from ..database import db
156
+ from duo_orm import Mapped, mapped_column
157
+
158
+ class User(db.Model):
159
+ __tablename__ = "users"
160
+ id: Mapped[int] = mapped_column(primary_key=True)
161
+ name: Mapped[str]
162
+ age: Mapped[int]
163
+ ```
164
+
165
+ ### 4. Creating Migrations
166
+
167
+ Once you have defined your models, create a migration to apply the schema to your database:
168
+
169
+ ```bash
170
+ duo-orm migration create "initial models"
171
+ duo-orm migration upgrade
172
+ ```
173
+
174
+ ### 5. Basic Usage
175
+
176
+ ```python
177
+ import asyncio
178
+ from .database import db
179
+ from .models import User
180
+
181
+ async def main():
182
+ # Simple writes
183
+ user = User(name="Alice", age=30)
184
+ await user.save() # INSERT
185
+
186
+ # Simple reads with Pythonic operators
187
+ users = await User.where(User.age > 25).all()
188
+ alice = await User.where(User.name == "Alice").first()
189
+
190
+ # More complex queries
191
+ users = await User.where(
192
+ (User.age > 30) & User.name.startswith('A')
193
+ ).all()
194
+
195
+ # Updates
196
+ alice.age = 31
197
+ await alice.save() # UPDATE
198
+
199
+ # Deletes
200
+ await alice.delete() # DELETE
201
+
202
+ if __name__ == "__main__":
203
+ asyncio.run(main())
204
+ ```
205
+
206
+ ## Framework Integration
207
+
208
+ DuoORM plays well with FastAPI (and any async framework) by wrapping each request in a dependency that opens a transaction block:
209
+
210
+ ```python
211
+ from fastapi import Depends, FastAPI
212
+ from .database import db
213
+
214
+ app = FastAPI()
215
+
216
+ async def db_session():
217
+ async with db.transaction():
218
+ yield
219
+
220
+ @app.get("/users/{user_id}")
221
+ async def read_user(user_id: int, _=Depends(db_session)):
222
+ return await User.where(User.id == user_id).first()
223
+ ```
224
+
225
+ All queries issued inside the request share the same session; pending writes persist until the response and exceptions roll the block back automatically.
226
+
227
+ ## Power User Access
228
+
229
+ When you need raw SQLAlchemy control, opt into a standalone session:
230
+
231
+ ```python
232
+ async with db.standalone_session() as session:
233
+ stmt = User.where(User.age > 30).alchemize()
234
+ rows = (await session.scalars(stmt)).all()
235
+ ```
236
+
237
+ `db.standalone_session()` returns a plain `AsyncSession`, so you can use Core queries, streaming, or bulk inserts without leaving the DuoORM ecosystem.
238
+
239
+ ## Behaviour Summary
240
+
241
+ | Context | Session lifetime | Commit model | Typical use |
242
+ | --- | --- | --- | --- |
243
+ | Default call (`await User.where(...).first()`) | Short-lived per call | Auto-commit / auto-close | Scripts, simple reads & writes |
244
+ | `db.transaction()` block | Shared for the block | Commit on exit, rollback on error | Web requests, multi-step workflows |
245
+ | `db.standalone_session()` | Manual | You commit / rollback | Power users, raw SQLAlchemy |
246
+
247
+ ## Developer Experience Principles
248
+
249
+ * **Simple:** `await Model.query()` just works—no session juggling for one-off calls.
250
+ * **Safe:** One statement per operation unless you explicitly opt into a transaction.
251
+ * **Symmetric:** Sync and async APIs mirror each other (drop `await` for sync code).
252
+ * **Predictable:** No hidden flushes, dirty graphs, or autosession guessing.
253
+ * **Framework-agnostic:** Works the same in FastAPI, CLIs, scripts, or the REPL.
254
+
255
+ ## Validation & Model Flags
256
+
257
+ * `Model.validate()` – override this hook to enforce business rules; raise `ValidationError(field="age", message)` to block `save()`/`bulk_create()`.
258
+ * `Model.fields()` – returns the tuple of column names defined on the model so you can introspect schemas at runtime.
259
+ * `Model.to_dict()` – serializes the instance into a plain dictionary of column values for JSON responses, logging, etc.
260
+ * `ValidationError` – now part of the public API and carries optional `field` and `detail` metadata so you can surface meaningful errors to clients.
261
+ * `info={"set_on": "create"}` / `info={"set_on": {"create","update"}}` – declaratively control when a column is stamped (insert only vs. insert + every save). For compatibility we still honor `auto_now_add` / `auto_now` flags.
262
+ ```python
263
+ created_at = mapped_column(DateTime(timezone=True), info={"set_on": "create"})
264
+ updated_at = mapped_column(DateTime(timezone=True), info={"set_on": {"create", "update"}})
265
+ ```
266
+
267
+ ## Query Enhancements
268
+
269
+ * `QueryBuilder.one()` – fetch exactly one record (raises `ObjectNotFoundError` / `MultipleObjectsFoundError` like SQLAlchemy).
270
+ * `QueryBuilder.exists()` – returns `True/False` without materializing rows.
271
+ * `QueryBuilder.paginate(limit, offset=0)` – oneliner to apply both LIMIT and OFFSET.
272
+ * `QueryBuilder.related(User.posts, where=[...], aggregate="exists", loader="selectin")` – single entry point for filtering/aggregating/eager-loading across one direct relationship. Calling `related()` multiple times or with multi-hop paths (e.g., `User.posts.comments`) isn’t supported; fall back to SQLAlchemy expressions for those cases.
273
+ * `json(User.profile)["flags"]["beta"].is_null()` – Pythonic JSON-path helper that compiles to regular SQLAlchemy expressions, so you can write complex JSON predicates inside `.where(...)` without juggling driver-specific operators.
274
+ * `array(User.tags).includes_any(["python", "orm"])` – expressive ARRAY helper for membership / superset / overlap checks without memorizing dialect-specific operators.
275
+ * All helpers work in sync and async contexts just like `.first()` and `.all()`.
276
+
277
+ ## Why SQLAlchemy Underneath?
278
+
279
+ DuoORM stands on SQLAlchemy Core for query generation, schema metadata, type handling, and async engines. It deliberately avoids SQLAlchemy’s ORM layer so it stays lightweight, async-first, and free from invisible Unit-of-Work behavior. When you need to drop down, you already have a real `AsyncSession` in hand.
280
+
281
+ ## Query Operators
282
+
283
+ ### Basic Comparison
284
+
285
+ | Operator | Example |
286
+ | --- | --- |
287
+ | `==` | `User.name == "Alice"` |
288
+ | `!=` | `User.status != "inactive"` |
289
+ | `>` | `User.age > 25` |
290
+ | `<` | `User.age < 60` |
291
+ | `>=` | `User.salary >= 50000` |
292
+ | `<=` | `User.created_at <= date` |
293
+
294
+ ### Membership & Pattern Matching
295
+
296
+ | Method | Example |
297
+ | --- | --- |
298
+ | `.in_([...])` | `User.country.in_(['IE', 'IN'])` |
299
+ | `.notin_([...])` | `User.role.notin_(['banned', 'test'])` |
300
+ | `.contains(value)` | `User.email.contains('@example.com')` |
301
+ | `.icontains(value)` | `User.email.icontains('@example.com')` |
302
+ | `.startswith(prefix)` | `User.name.startswith('Al')` |
303
+ | `.istartswith(prefix)` | `User.name.istartswith('al')` |
304
+ | `.iendswith(suffix)` | `User.slug.iendswith('-beta')` |
305
+
306
+ Case-insensitive helpers (`.icontains`, `.istartswith`, `.iendswith`) are provided by DuoORM and work on any column whose SQLAlchemy type derives from `String`. For custom wildcard patterns you can still fall back to SQLAlchemy’s `.like()` / `.ilike()`, but the ergonomic helpers cover 80% of real-world use.
307
+
308
+ ### JSON Path Helper
309
+
310
+ Use the built-in `json()` helper to compose JSON predicates that drop straight into `where()`:
311
+
312
+ ```python
313
+ from duo_orm import json
314
+
315
+ await User.where(
316
+ json(User.profile)["flags"]["beta"].is_null() |
317
+ json(User.profile)["flags"]["beta"].equals("")
318
+ ).all()
319
+ ```
320
+
321
+ It mirrors normal Python dict access (`[...]`) and exposes fluent helpers that emit SQLAlchemy clauses:
322
+
323
+ | Helper | Purpose | Example |
324
+ | --- | --- | --- |
325
+ | `json(col)["key"]` | Navigate nested keys/indices | `json(User.profile)["flags"]["beta"]` |
326
+ | `.equals(value)` / `==` | Compare scalars (auto-casts to text unless you call `.as_integer()` etc.) | `json(User.profile)["plan"] == "pro"` |
327
+ | `.contains(fragment)` | JSON containment (`@>`-style) for dict/list fragments | `json(User.profile).contains({"plan": "enterprise"})` |
328
+ | `.has_key(key)` | Key existence (dialect-dependent; raises if unsupported) | `json(User.profile)["flags"].has_key("beta")` |
329
+ | `.is_null()` / `.is_not_null()` | Null checks on the selected path | `json(User.profile)["expires_at"].is_null()` |
330
+ | `.as_integer()` / `.as_float()` / `.as_boolean()` / `.as_text()` | Cast before comparisons | `json(User.profile)["quota"].as_integer() > 10` |
331
+
332
+ Because the helper returns ordinary SQLAlchemy expressions, you can combine them with `&`, `|`, `not_`, nest them alongside other filters, and even extract the raw expression via `.expression()`. Dialect support is enforced by SQLAlchemy—if the backend lacks a JSON operator (e.g., `has_key` on SQLite), you’ll get a clear error at query construction time.
333
+
334
+ > Practical note: full JSON path/operator helpers are available on PostgreSQL today. Other dialects store JSON fine but lack operator parity, so related tests are skipped with an explicit reason until dialect-specific compilers are added.
335
+
336
+ ### Array Helper
337
+
338
+ For ARRAY columns, reach for the `array()` helper:
339
+
340
+ ```python
341
+ from duo_orm import array
342
+
343
+ await User.where(
344
+ array(User.tags).includes("orm") &
345
+ array(User.tags).includes_all(["python", "asyncio"])
346
+ ).all()
347
+ ```
348
+
349
+ Helper methods map directly to common set-style checks:
350
+
351
+ | Helper | Purpose | Example |
352
+ | --- | --- | --- |
353
+ | `.includes(value)` | True if the array contains that single value (`value = ANY(column)`). | `array(User.tags).includes("orm")` |
354
+ | `.includes_all(values)` | True if the array contains all provided values (`@>`). | `array(User.tags).includes_all(["python","orm"])` |
355
+ | `.includes_any(values)` | True if the array overlaps any provided value (`&&`). | `array(User.tags).includes_any(["java","go"])` |
356
+ | `.length()` | Number of elements (uses `cardinality()` when available, otherwise `array_length(..., 1)`). | `array(User.tags).length() > 2` |
357
+
358
+ These helpers return SQLAlchemy expressions, so they compose with the rest of your filters exactly like built-in clauses. SQLAlchemy/dialect support rules still apply—unsupported ARRAY operators raise the driver’s error (or the helper re-raises with a clearer message).
359
+
360
+ > Practical note: ARRAY helpers are PostgreSQL-only right now; other dialects do not expose compatible ARRAY operators. Tests are skipped with a reason when the backend lacks support.
361
+
362
+ ### Logical Combinators
363
+
364
+ | Operator | Example |
365
+ | --- | --- |
366
+ | `&` | `(User.age > 30) & (User.status == 'active')` |
367
+ | `|` | `(User.is_staff == True) | (User.is_admin == True)` |
368
+
369
+ ### Sync scripts or workers
370
+
371
+ The same `db.transaction()` helper can be used in synchronous code—just drop the `await` and use a plain `with`:
372
+
373
+ ```python
374
+ from .database import db
375
+ from .models import User
376
+
377
+ with db.transaction():
378
+ user = User(name="sync-flow")
379
+ user.save()
380
+
381
+ with db.transaction():
382
+ assert User.where(User.name == "sync-flow").exists()
383
+ ```
384
+
385
+ Behind the scenes we detect whether you’re in an event loop and return either the async or sync context manager automatically, so you get one API for both worlds.
386
+
387
+ ## Testing
388
+
389
+ The test suite expects an explicit `--db-url` and never falls back to a default. Run tests against the backend you care about, for example:
390
+
391
+ ```bash
392
+ pytest --db-url "sqlite:///./test.sqlite"
393
+ pytest --db-url "postgresql://user:pass@host:5432/dbname"
394
+ ```
395
+
396
+ JSON/ARRAY helper tests are skipped with explicit reasons on dialects that lack SQLAlchemy operator support.
@@ -0,0 +1,16 @@
1
+ duo_orm/__init__.py,sha256=5ITYNXuKdLxOnAu5PN0uSeEGA4fEvlEyN4o449QJ3XE,1827
2
+ duo_orm/basemodel.py,sha256=9JbZzy-dQM1ZUnBK73G24HQeHb43532tP_m0zMEGJhk,4963
3
+ duo_orm/db.py,sha256=2GKK_bKz7k_mD9fs1Zs7xA-YYASqWAUGHgChhPSVuR0,8669
4
+ duo_orm/exceptions.py,sha256=5A0-RGZbqjkS664f2wCTmBZfcxjnDlZwnkZcwnnw9nM,2170
5
+ duo_orm/executor.py,sha256=r-BrpD39zUBxmcsqliFx_Qh6CALtR_UHreo6b-FMOs8,8338
6
+ duo_orm/patch.py,sha256=EXr2sy4icFfijbuOSfcQAzxhI4PWudRCzzkBxXSQjNc,2383
7
+ duo_orm/query.py,sha256=v1A7nWCdLFkO406GZN2ObFyL06h5QoOXz_jP9EXfXAo,21797
8
+ duo_orm/session.py,sha256=HlTDj5sgBMNbGUB-GnkePSoccS6RDv05rgzW3dz0VdE,1209
9
+ duo_orm/migrations/__init__.py,sha256=76nRed9J-lF5wCCB5XGNGO7wGODrk-x7ZnkJeepZ4wk,66
10
+ duo_orm/migrations/cli.py,sha256=kvpBJfI3apEKvfF8sBV_LKU6SP62GfQEtUPZFTT6h2I,5914
11
+ duo_orm/migrations/config.py,sha256=P4LReQvLN--edMWMEWqptNNwt5xeKnK3NI-rOXxfG0s,8834
12
+ duo_orm-0.1.0.dist-info/METADATA,sha256=QcP85Yz5ffRzeeAPikkZcGwx4i9rFyUZ6wUi4GOLwDM,17757
13
+ duo_orm-0.1.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
14
+ duo_orm-0.1.0.dist-info/entry_points.txt,sha256=63w_FPRM0-fT6CTR2oJLEEjcH6WTyUL1k8jxIKos83g,55
15
+ duo_orm-0.1.0.dist-info/top_level.txt,sha256=Xtl97wuxq3rnT--QMy2LYx1NcAWi5FS11-DuV9QjnnU,8
16
+ duo_orm-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ duo-orm = duo_orm.migrations.cli:cli
@@ -0,0 +1 @@
1
+ duo_orm