parad 2.2.2__tar.gz → 2.2.3__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.
- {parad-2.2.2 → parad-2.2.3}/PKG-INFO +34 -2
- {parad-2.2.2 → parad-2.2.3}/README.md +31 -1
- {parad-2.2.2 → parad-2.2.3}/parad/__init__.py +3 -3
- {parad-2.2.2 → parad-2.2.3}/parad/commands/init.py +13 -3
- {parad-2.2.2 → parad-2.2.3}/parad/config.py +8 -4
- {parad-2.2.2 → parad-2.2.3}/parad/connection.py +35 -3
- parad-2.2.3/parad/dbapi.py +217 -0
- parad-2.2.3/parad/sqlalchemy.py +30 -0
- {parad-2.2.2 → parad-2.2.3}/parad/types.py +1 -0
- {parad-2.2.2 → parad-2.2.3}/parad.egg-info/PKG-INFO +34 -2
- {parad-2.2.2 → parad-2.2.3}/parad.egg-info/SOURCES.txt +3 -0
- parad-2.2.3/parad.egg-info/entry_points.txt +5 -0
- {parad-2.2.2 → parad-2.2.3}/parad.egg-info/requires.txt +3 -0
- {parad-2.2.2 → parad-2.2.3}/pyproject.toml +7 -1
- parad-2.2.3/tests/test_sqlalchemy.py +70 -0
- parad-2.2.2/parad.egg-info/entry_points.txt +0 -2
- {parad-2.2.2 → parad-2.2.3}/parad/__main__.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/cli.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/commands/__init__.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/commands/auth.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/commands/backups.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/commands/config_cmd.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/commands/connect.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/commands/databases.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/commands/projects.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/commands/query.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/commands/shell.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/commands/status.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/commands/sync.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/commands/versions.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/commands/watch.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/crypto.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/engine.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/gateway.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/state.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad/watcher.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad.egg-info/dependency_links.txt +0 -0
- {parad-2.2.2 → parad-2.2.3}/parad.egg-info/top_level.txt +0 -0
- {parad-2.2.2 → parad-2.2.3}/setup.cfg +0 -0
- {parad-2.2.2 → parad-2.2.3}/tests/test_connection.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/tests/test_crypto.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/tests/test_engine.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/tests/test_state.py +0 -0
- {parad-2.2.2 → parad-2.2.3}/tests/test_workflow.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parad
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.3
|
|
4
4
|
Summary: Drop-in encrypted database with cloud sync — connect() and go
|
|
5
5
|
Author-email: nexuss0781 <nexuss0781@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/nexuss0781/Paradox-DB
|
|
@@ -21,6 +21,8 @@ Requires-Dist: httpx>=0.25
|
|
|
21
21
|
Requires-Dist: cryptography>=41.0
|
|
22
22
|
Requires-Dist: pydantic>=2.0
|
|
23
23
|
Requires-Dist: python-dotenv>=1.0
|
|
24
|
+
Provides-Extra: sqlalchemy
|
|
25
|
+
Requires-Dist: SQLAlchemy>=2.0; extra == "sqlalchemy"
|
|
24
26
|
|
|
25
27
|
# parad
|
|
26
28
|
|
|
@@ -93,7 +95,7 @@ parad shell
|
|
|
93
95
|
|
|
94
96
|
| Command | Description |
|
|
95
97
|
|---|---|
|
|
96
|
-
| `parad init <name>` | Create encrypted DB + register with gateway |
|
|
98
|
+
| `parad init <name>` | Create encrypted DB + register with gateway; emit canonical DATABASE_URL |
|
|
97
99
|
| `parad push` | Push database to Telegram cloud |
|
|
98
100
|
| `parad pull [version]` | Pull latest or specific version |
|
|
99
101
|
| `parad sync` | Push then pull |
|
|
@@ -108,12 +110,42 @@ parad shell
|
|
|
108
110
|
| `parad shell` | Interactive SQL REPL |
|
|
109
111
|
| `parad config show/set` | Manage config |
|
|
110
112
|
|
|
113
|
+
## Canonical DATABASE_URL
|
|
114
|
+
|
|
115
|
+
After `parad init` successfully provisions the project and database, Parad persists the complete connection URL in `config.json` as `database_url` and prints a redacted form:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
parad init mydb --project myproject
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Use `--print-database-url` only when intentionally copying the complete secret-bearing URL into a secret manager:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
parad init mydb --project myproject --print-database-url
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Applications can use the same single value:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
import os
|
|
131
|
+
from parad import connect
|
|
132
|
+
|
|
133
|
+
db = connect(url=os.environ["DATABASE_URL"])
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
An explicit `url`, `name`, or `db_path` argument takes precedence over an ambient `DATABASE_URL`. Existing connection strings and config-based workflows remain supported.
|
|
137
|
+
|
|
138
|
+
## SQLAlchemy
|
|
139
|
+
|
|
140
|
+
Install the optional integration with `pip install "parad[sqlalchemy]"`, then use the same canonical URL with `create_engine("parad://...")`. The ORM, Core, DB-API, and encrypted lifecycle examples are documented in [`docs/SQLALCHEMY.md`](docs/SQLALCHEMY.md).
|
|
141
|
+
|
|
111
142
|
## Configuration
|
|
112
143
|
|
|
113
144
|
Config lives at `~/.paradox/config.json`:
|
|
114
145
|
|
|
115
146
|
```json
|
|
116
147
|
{
|
|
148
|
+
"database_url": "parad://<api-key>@local/project/mydb?gateway=https%3A%2F%2F...&passphrase=...",
|
|
117
149
|
"database_path": "~/.paradox/data.db",
|
|
118
150
|
"sync": {
|
|
119
151
|
"gateway_url": "https://paradox-db.onrender.com/v1",
|
|
@@ -69,7 +69,7 @@ parad shell
|
|
|
69
69
|
|
|
70
70
|
| Command | Description |
|
|
71
71
|
|---|---|
|
|
72
|
-
| `parad init <name>` | Create encrypted DB + register with gateway |
|
|
72
|
+
| `parad init <name>` | Create encrypted DB + register with gateway; emit canonical DATABASE_URL |
|
|
73
73
|
| `parad push` | Push database to Telegram cloud |
|
|
74
74
|
| `parad pull [version]` | Pull latest or specific version |
|
|
75
75
|
| `parad sync` | Push then pull |
|
|
@@ -84,12 +84,42 @@ parad shell
|
|
|
84
84
|
| `parad shell` | Interactive SQL REPL |
|
|
85
85
|
| `parad config show/set` | Manage config |
|
|
86
86
|
|
|
87
|
+
## Canonical DATABASE_URL
|
|
88
|
+
|
|
89
|
+
After `parad init` successfully provisions the project and database, Parad persists the complete connection URL in `config.json` as `database_url` and prints a redacted form:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
parad init mydb --project myproject
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Use `--print-database-url` only when intentionally copying the complete secret-bearing URL into a secret manager:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
parad init mydb --project myproject --print-database-url
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Applications can use the same single value:
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
import os
|
|
105
|
+
from parad import connect
|
|
106
|
+
|
|
107
|
+
db = connect(url=os.environ["DATABASE_URL"])
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
An explicit `url`, `name`, or `db_path` argument takes precedence over an ambient `DATABASE_URL`. Existing connection strings and config-based workflows remain supported.
|
|
111
|
+
|
|
112
|
+
## SQLAlchemy
|
|
113
|
+
|
|
114
|
+
Install the optional integration with `pip install "parad[sqlalchemy]"`, then use the same canonical URL with `create_engine("parad://...")`. The ORM, Core, DB-API, and encrypted lifecycle examples are documented in [`docs/SQLALCHEMY.md`](docs/SQLALCHEMY.md).
|
|
115
|
+
|
|
87
116
|
## Configuration
|
|
88
117
|
|
|
89
118
|
Config lives at `~/.paradox/config.json`:
|
|
90
119
|
|
|
91
120
|
```json
|
|
92
121
|
{
|
|
122
|
+
"database_url": "parad://<api-key>@local/project/mydb?gateway=https%3A%2F%2F...&passphrase=...",
|
|
93
123
|
"database_path": "~/.paradox/data.db",
|
|
94
124
|
"sync": {
|
|
95
125
|
"gateway_url": "https://paradox-db.onrender.com/v1",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"""parad — Encrypted local-first SQLite with cloud sync."""
|
|
2
2
|
|
|
3
|
-
from parad.connection import connect, ParadConnection, parse_url, generate_url, db_state_key, generate_passphrase
|
|
3
|
+
from parad.connection import connect, ParadConnection, parse_url, generate_url, redact_url, db_state_key, generate_passphrase
|
|
4
4
|
from parad.engine import Engine
|
|
5
5
|
from parad.config import load_config, get_passphrase, get_connection_url
|
|
6
6
|
|
|
7
|
-
__version__ = "2.2.
|
|
8
|
-
__all__ = ["connect", "ParadConnection", "parse_url", "generate_url", "db_state_key", "generate_passphrase", "Engine", "load_config"]
|
|
7
|
+
__version__ = "2.2.3"
|
|
8
|
+
__all__ = ["connect", "ParadConnection", "parse_url", "generate_url", "redact_url", "db_state_key", "generate_passphrase", "Engine", "load_config"]
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import click
|
|
4
4
|
from pathlib import Path
|
|
5
5
|
from parad.config import load_config, save_config, config_dir, gateway_db_name, set_config_value
|
|
6
|
-
from parad.connection import db_state_key
|
|
6
|
+
from parad.connection import db_state_key, generate_url, redact_url
|
|
7
7
|
from parad.engine import Engine
|
|
8
8
|
from parad.gateway import GatewayClient, GatewayError
|
|
9
9
|
from parad.state import set_remote_version, set_last_local_hash
|
|
@@ -93,7 +93,8 @@ def _find_or_create_database(gw, project_id: str, db_name: str) -> str:
|
|
|
93
93
|
@click.option("--gateway", envvar="PARADOX_GATEWAY_URL", default=None)
|
|
94
94
|
@click.option("--project", default=None, help="Project name to use (creates if not found)")
|
|
95
95
|
@click.option("--watch", "do_watch", is_flag=True, help="Start auto-sync daemon after init")
|
|
96
|
-
|
|
96
|
+
@click.option("--print-database-url", is_flag=True, help="Print the full secret-bearing DATABASE_URL")
|
|
97
|
+
def init(name: str, passphrase: str, gateway: str | None, project: str | None, do_watch: bool, print_database_url: bool):
|
|
97
98
|
"""Create a new encrypted database and push to gateway.
|
|
98
99
|
|
|
99
100
|
Handles everything in one step: auth, project/database setup, local DB creation, and push.
|
|
@@ -145,12 +146,21 @@ def init(name: str, passphrase: str, gateway: str | None, project: str | None, d
|
|
|
145
146
|
except GatewayError as e:
|
|
146
147
|
click.echo(f"⚠ Push failed: {e}")
|
|
147
148
|
|
|
148
|
-
# Step 6: Save config
|
|
149
|
+
# Step 6: Save config and publish the canonical URL only after creation succeeds.
|
|
150
|
+
canonical_url = generate_url(
|
|
151
|
+
name,
|
|
152
|
+
passphrase,
|
|
153
|
+
config.sync.gateway_url,
|
|
154
|
+
project_name,
|
|
155
|
+
token=config.sync.api_key,
|
|
156
|
+
)
|
|
157
|
+
config.database_url = canonical_url
|
|
149
158
|
save_config(config)
|
|
150
159
|
|
|
151
160
|
click.echo(f"\n✓ Database ready: {db_path}")
|
|
152
161
|
click.echo(f" Project: {project_name} ({project_id})")
|
|
153
162
|
click.echo(f" Database: {name} ({database_id})")
|
|
163
|
+
click.echo(f" DATABASE_URL: {canonical_url if print_database_url else redact_url(canonical_url)}")
|
|
154
164
|
|
|
155
165
|
# Step 7: Optionally start daemon
|
|
156
166
|
if do_watch:
|
|
@@ -19,6 +19,7 @@ def config_dir() -> Path:
|
|
|
19
19
|
return Path(os.environ.get("PARADOX_HOME", "~/.paradox")).expanduser()
|
|
20
20
|
|
|
21
21
|
DEFAULT_CONFIG = {
|
|
22
|
+
"database_url": "",
|
|
22
23
|
"database_path": "~/.paradox/data.db",
|
|
23
24
|
"encryption": {
|
|
24
25
|
"cipher": "aes-256-cbc",
|
|
@@ -73,6 +74,7 @@ def load_config() -> Config:
|
|
|
73
74
|
- PARADOX_GATEWAY → sync.gateway_url
|
|
74
75
|
- PARADOX_DATABASE → database_path
|
|
75
76
|
- PARADOX_API_KEY → sync.api_key
|
|
77
|
+
- DATABASE_URL → canonical Parad connection URL
|
|
76
78
|
|
|
77
79
|
.env files are loaded automatically if python-dotenv is installed.
|
|
78
80
|
"""
|
|
@@ -95,6 +97,8 @@ def load_config() -> Config:
|
|
|
95
97
|
merged["database_path"] = os.environ["PARADOX_DATABASE"]
|
|
96
98
|
if "PARADOX_API_KEY" in os.environ:
|
|
97
99
|
merged["sync"]["api_key"] = os.environ["PARADOX_API_KEY"]
|
|
100
|
+
if "DATABASE_URL" in os.environ:
|
|
101
|
+
merged["database_url"] = os.environ["DATABASE_URL"]
|
|
98
102
|
|
|
99
103
|
return Config(**merged)
|
|
100
104
|
|
|
@@ -142,9 +146,9 @@ def get_passphrase() -> str:
|
|
|
142
146
|
|
|
143
147
|
|
|
144
148
|
def get_connection_url(name: str) -> str:
|
|
145
|
-
"""Get
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
+
"""Get the canonical connection URL, with a legacy local fallback."""
|
|
150
|
+
config = load_config()
|
|
151
|
+
if config.database_url and gateway_db_name(config.database_path) == name:
|
|
152
|
+
return config.database_url
|
|
149
153
|
passphrase = get_passphrase()
|
|
150
154
|
return f"parad://local/{name}?passphrase={passphrase}"
|
|
@@ -106,6 +106,20 @@ def generate_url(
|
|
|
106
106
|
return url
|
|
107
107
|
|
|
108
108
|
|
|
109
|
+
def redact_url(url: str) -> str:
|
|
110
|
+
"""Remove API-key, password, token, and passphrase material for display."""
|
|
111
|
+
parsed = urlparse(url)
|
|
112
|
+
userinfo = "<redacted>@" if parsed.username else ""
|
|
113
|
+
path = parsed.path
|
|
114
|
+
query = []
|
|
115
|
+
for key, values in parse_qs(parsed.query, keep_blank_values=True).items():
|
|
116
|
+
if key not in {"token", "passphrase"}:
|
|
117
|
+
for value in values:
|
|
118
|
+
query.append(f"{quote(key, safe='')}={quote(value, safe=':/')}")
|
|
119
|
+
suffix = f"?{'&'.join(query)}" if query else ""
|
|
120
|
+
return f"parad://{userinfo}{parsed.hostname or ''}{path}{suffix}"
|
|
121
|
+
|
|
122
|
+
|
|
109
123
|
def db_state_key(name: str, project: str | None = None) -> str:
|
|
110
124
|
"""State-file key for a database.
|
|
111
125
|
|
|
@@ -474,7 +488,7 @@ class ParadConnection:
|
|
|
474
488
|
self._project_id = project_id
|
|
475
489
|
self._storage_channel = storage_channel
|
|
476
490
|
self._log_channel = log_channel
|
|
477
|
-
self._db_name = gateway_db_name(self._db_path)
|
|
491
|
+
self._db_name = gateway_db_name(self._db_path)
|
|
478
492
|
self._db_key = db_state_key(self._db_name, project)
|
|
479
493
|
|
|
480
494
|
self._engine = Engine(self._db_path, self._passphrase)
|
|
@@ -516,6 +530,17 @@ class ParadConnection:
|
|
|
516
530
|
def is_connected(self) -> bool:
|
|
517
531
|
return self._engine._conn is not None
|
|
518
532
|
|
|
533
|
+
@property
|
|
534
|
+
def database_url(self) -> str:
|
|
535
|
+
"""Canonical connection URL for this resolved database."""
|
|
536
|
+
return generate_url(
|
|
537
|
+
self._db_name,
|
|
538
|
+
self._passphrase,
|
|
539
|
+
self._gateway_url,
|
|
540
|
+
self._project,
|
|
541
|
+
token=self._api_key,
|
|
542
|
+
)
|
|
543
|
+
|
|
519
544
|
# ── SQL interface ───────────────────────────────────────────
|
|
520
545
|
|
|
521
546
|
def execute(self, sql: str, params: tuple | list | None = None) -> list[dict]:
|
|
@@ -751,10 +776,11 @@ def connect(
|
|
|
751
776
|
Useful for web servers on ephemeral filesystems (Render, Heroku, etc.).
|
|
752
777
|
"""
|
|
753
778
|
cfg = load_config()
|
|
779
|
+
configured_url = url or ((not name and not db_path) and (os.environ.get("DATABASE_URL") or cfg.database_url or "") or "")
|
|
754
780
|
parsed_url: dict = {}
|
|
755
781
|
|
|
756
|
-
if
|
|
757
|
-
parsed_url = parse_url(
|
|
782
|
+
if configured_url:
|
|
783
|
+
parsed_url = parse_url(configured_url)
|
|
758
784
|
|
|
759
785
|
url_name = parsed_url.get("name") or ""
|
|
760
786
|
url_project = parsed_url.get("project") or None
|
|
@@ -882,4 +908,10 @@ def connect(
|
|
|
882
908
|
log_channel=log_channel or os.environ.get("PARADOX_LOG_CHANNEL", ""),
|
|
883
909
|
)
|
|
884
910
|
|
|
911
|
+
try:
|
|
912
|
+
cfg.database_url = conn.database_url
|
|
913
|
+
save_config(cfg)
|
|
914
|
+
except Exception:
|
|
915
|
+
pass
|
|
916
|
+
|
|
885
917
|
return conn
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""PEP 249 DB-API adapter for Parad's encrypted SQLite engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
import threading
|
|
7
|
+
from typing import Any, Iterable, Sequence
|
|
8
|
+
|
|
9
|
+
from .connection import ParadConnection, connect as parad_connect
|
|
10
|
+
|
|
11
|
+
apilevel = "2.0"
|
|
12
|
+
threadsafety = 1
|
|
13
|
+
paramstyle = "qmark"
|
|
14
|
+
|
|
15
|
+
Warning = sqlite3.Warning
|
|
16
|
+
Error = sqlite3.Error
|
|
17
|
+
InterfaceError = sqlite3.InterfaceError
|
|
18
|
+
DatabaseError = sqlite3.DatabaseError
|
|
19
|
+
DataError = sqlite3.DataError
|
|
20
|
+
OperationalError = sqlite3.OperationalError
|
|
21
|
+
IntegrityError = sqlite3.IntegrityError
|
|
22
|
+
InternalError = sqlite3.InternalError
|
|
23
|
+
ProgrammingError = sqlite3.ProgrammingError
|
|
24
|
+
NotSupportedError = sqlite3.NotSupportedError
|
|
25
|
+
|
|
26
|
+
Binary = sqlite3.Binary
|
|
27
|
+
Date = sqlite3.Date
|
|
28
|
+
Time = sqlite3.Time
|
|
29
|
+
Timestamp = sqlite3.Timestamp
|
|
30
|
+
DateFromTicks = sqlite3.DateFromTicks
|
|
31
|
+
TimeFromTicks = sqlite3.TimeFromTicks
|
|
32
|
+
TimestampFromTicks = sqlite3.TimestampFromTicks
|
|
33
|
+
sqlite_version = sqlite3.sqlite_version
|
|
34
|
+
sqlite_version_info = sqlite3.sqlite_version_info
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Cursor:
|
|
38
|
+
"""PEP 249 cursor backed by Parad's live SQLite connection."""
|
|
39
|
+
|
|
40
|
+
arraysize = 1
|
|
41
|
+
|
|
42
|
+
def __init__(self, connection: "Connection"):
|
|
43
|
+
self.connection = connection
|
|
44
|
+
self._cursor: sqlite3.Cursor | None = None
|
|
45
|
+
self._closed = False
|
|
46
|
+
|
|
47
|
+
def _require_open(self) -> sqlite3.Cursor:
|
|
48
|
+
if self._closed or self.connection.closed:
|
|
49
|
+
raise InterfaceError("cursor is closed")
|
|
50
|
+
if self._cursor is None:
|
|
51
|
+
self._cursor = self.connection._raw.cursor()
|
|
52
|
+
return self._cursor
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def description(self):
|
|
56
|
+
return self._cursor.description if self._cursor is not None else None
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def rowcount(self) -> int:
|
|
60
|
+
if self._cursor is None:
|
|
61
|
+
return -1
|
|
62
|
+
return self._cursor.rowcount
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def lastrowid(self):
|
|
66
|
+
if self._cursor is None:
|
|
67
|
+
return None
|
|
68
|
+
return self._cursor.lastrowid
|
|
69
|
+
|
|
70
|
+
def execute(self, operation: str, parameters: Sequence[Any] | None = None):
|
|
71
|
+
cursor = self._require_open()
|
|
72
|
+
self.connection._lock.acquire()
|
|
73
|
+
try:
|
|
74
|
+
cursor.execute(operation, tuple(parameters or ()))
|
|
75
|
+
except Exception:
|
|
76
|
+
self.connection._lock.release()
|
|
77
|
+
raise
|
|
78
|
+
self.connection._lock.release()
|
|
79
|
+
return self
|
|
80
|
+
|
|
81
|
+
def executemany(self, operation: str, seq_of_parameters: Iterable[Sequence[Any]]):
|
|
82
|
+
cursor = self._require_open()
|
|
83
|
+
self.connection._lock.acquire()
|
|
84
|
+
try:
|
|
85
|
+
cursor.executemany(operation, [tuple(params) for params in seq_of_parameters])
|
|
86
|
+
except Exception:
|
|
87
|
+
self.connection._lock.release()
|
|
88
|
+
raise
|
|
89
|
+
self.connection._lock.release()
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
def executescript(self, script: str):
|
|
93
|
+
cursor = self._require_open()
|
|
94
|
+
self.connection._lock.acquire()
|
|
95
|
+
try:
|
|
96
|
+
cursor.executescript(script)
|
|
97
|
+
except Exception:
|
|
98
|
+
self.connection._lock.release()
|
|
99
|
+
raise
|
|
100
|
+
self.connection._lock.release()
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def fetchone(self):
|
|
104
|
+
return self._require_open().fetchone()
|
|
105
|
+
|
|
106
|
+
def fetchmany(self, size: int | None = None):
|
|
107
|
+
return self._require_open().fetchmany(self.arraysize if size is None else size)
|
|
108
|
+
|
|
109
|
+
def fetchall(self):
|
|
110
|
+
return self._require_open().fetchall()
|
|
111
|
+
|
|
112
|
+
def setinputsizes(self, sizes):
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
def setoutputsize(self, size, column=None):
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def close(self):
|
|
119
|
+
if not self._closed and self._cursor is not None:
|
|
120
|
+
self._cursor.close()
|
|
121
|
+
self._closed = True
|
|
122
|
+
|
|
123
|
+
def __iter__(self):
|
|
124
|
+
return iter(self._require_open())
|
|
125
|
+
|
|
126
|
+
def __enter__(self):
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
130
|
+
self.close()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Connection:
|
|
134
|
+
"""PEP 249 connection backed by a ParadConnection."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, database_url: str, **kwargs: Any):
|
|
137
|
+
self._parad = parad_connect(url=database_url, auto_sync=kwargs.pop("auto_sync", False))
|
|
138
|
+
self._raw = self._parad.engine._conn
|
|
139
|
+
if self._raw is None:
|
|
140
|
+
raise InterfaceError("Parad database engine is not open")
|
|
141
|
+
self._lock = threading.RLock()
|
|
142
|
+
self.closed = False
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def parad(self) -> ParadConnection:
|
|
146
|
+
return self._parad
|
|
147
|
+
|
|
148
|
+
def cursor(self) -> Cursor:
|
|
149
|
+
if self.closed:
|
|
150
|
+
raise InterfaceError("connection is closed")
|
|
151
|
+
return Cursor(self)
|
|
152
|
+
|
|
153
|
+
def commit(self):
|
|
154
|
+
if self.closed:
|
|
155
|
+
raise InterfaceError("connection is closed")
|
|
156
|
+
with self._lock:
|
|
157
|
+
self._raw.commit()
|
|
158
|
+
|
|
159
|
+
def rollback(self):
|
|
160
|
+
if self.closed:
|
|
161
|
+
raise InterfaceError("connection is closed")
|
|
162
|
+
with self._lock:
|
|
163
|
+
self._raw.rollback()
|
|
164
|
+
|
|
165
|
+
def close(self):
|
|
166
|
+
if not self.closed:
|
|
167
|
+
with self._lock:
|
|
168
|
+
self._parad.close()
|
|
169
|
+
self.closed = True
|
|
170
|
+
|
|
171
|
+
def execute(self, operation: str, parameters: Sequence[Any] | None = None):
|
|
172
|
+
cursor = self.cursor()
|
|
173
|
+
cursor.execute(operation, parameters)
|
|
174
|
+
return cursor
|
|
175
|
+
|
|
176
|
+
def executemany(self, operation: str, seq_of_parameters: Iterable[Sequence[Any]]):
|
|
177
|
+
cursor = self.cursor()
|
|
178
|
+
cursor.executemany(operation, seq_of_parameters)
|
|
179
|
+
return cursor
|
|
180
|
+
|
|
181
|
+
def executescript(self, script: str):
|
|
182
|
+
cursor = self.cursor()
|
|
183
|
+
cursor.executescript(script)
|
|
184
|
+
return cursor
|
|
185
|
+
|
|
186
|
+
def create_function(self, *args, **kwargs):
|
|
187
|
+
if self.closed:
|
|
188
|
+
raise InterfaceError("connection is closed")
|
|
189
|
+
return self._raw.create_function(*args, **kwargs)
|
|
190
|
+
|
|
191
|
+
def set_authorizer(self, *args, **kwargs):
|
|
192
|
+
if self.closed:
|
|
193
|
+
raise InterfaceError("connection is closed")
|
|
194
|
+
return self._raw.set_authorizer(*args, **kwargs)
|
|
195
|
+
|
|
196
|
+
def interrupt(self):
|
|
197
|
+
if not self.closed:
|
|
198
|
+
return self._raw.interrupt()
|
|
199
|
+
|
|
200
|
+
def __enter__(self):
|
|
201
|
+
if self.closed:
|
|
202
|
+
raise InterfaceError("connection is closed")
|
|
203
|
+
return self
|
|
204
|
+
|
|
205
|
+
def __exit__(self, exc_type, exc_value, traceback):
|
|
206
|
+
if exc_type is None:
|
|
207
|
+
self.commit()
|
|
208
|
+
else:
|
|
209
|
+
self.rollback()
|
|
210
|
+
self.close()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def connect(database: str | None = None, **kwargs: Any) -> Connection:
|
|
214
|
+
"""Return a DB-API connection for a canonical Parad URL."""
|
|
215
|
+
if not database:
|
|
216
|
+
raise ProgrammingError("Parad DB-API requires a DATABASE_URL or database URL")
|
|
217
|
+
return Connection(database, **kwargs)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""SQLAlchemy dialect for Parad's encrypted SQLite database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlalchemy.dialects.sqlite.base import SQLiteDialect
|
|
6
|
+
|
|
7
|
+
from . import dbapi
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ParadDialect(SQLiteDialect):
|
|
11
|
+
"""SQLite dialect whose DB-API connections are backed by Parad."""
|
|
12
|
+
|
|
13
|
+
name = "parad"
|
|
14
|
+
driver = "sqlite"
|
|
15
|
+
supports_statement_cache = False
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def import_dbapi(cls):
|
|
19
|
+
return dbapi
|
|
20
|
+
|
|
21
|
+
def create_connect_args(self, url):
|
|
22
|
+
# Preserve the complete canonical URL, including its API token,
|
|
23
|
+
# gateway, project, database, and passphrase query parameters.
|
|
24
|
+
return [url.render_as_string(hide_password=False)], {"auto_sync": False}
|
|
25
|
+
|
|
26
|
+
def get_driver_name(self):
|
|
27
|
+
return "sqlite"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
dialect = ParadDialect
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: parad
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.3
|
|
4
4
|
Summary: Drop-in encrypted database with cloud sync — connect() and go
|
|
5
5
|
Author-email: nexuss0781 <nexuss0781@gmail.com>
|
|
6
6
|
Project-URL: Homepage, https://github.com/nexuss0781/Paradox-DB
|
|
@@ -21,6 +21,8 @@ Requires-Dist: httpx>=0.25
|
|
|
21
21
|
Requires-Dist: cryptography>=41.0
|
|
22
22
|
Requires-Dist: pydantic>=2.0
|
|
23
23
|
Requires-Dist: python-dotenv>=1.0
|
|
24
|
+
Provides-Extra: sqlalchemy
|
|
25
|
+
Requires-Dist: SQLAlchemy>=2.0; extra == "sqlalchemy"
|
|
24
26
|
|
|
25
27
|
# parad
|
|
26
28
|
|
|
@@ -93,7 +95,7 @@ parad shell
|
|
|
93
95
|
|
|
94
96
|
| Command | Description |
|
|
95
97
|
|---|---|
|
|
96
|
-
| `parad init <name>` | Create encrypted DB + register with gateway |
|
|
98
|
+
| `parad init <name>` | Create encrypted DB + register with gateway; emit canonical DATABASE_URL |
|
|
97
99
|
| `parad push` | Push database to Telegram cloud |
|
|
98
100
|
| `parad pull [version]` | Pull latest or specific version |
|
|
99
101
|
| `parad sync` | Push then pull |
|
|
@@ -108,12 +110,42 @@ parad shell
|
|
|
108
110
|
| `parad shell` | Interactive SQL REPL |
|
|
109
111
|
| `parad config show/set` | Manage config |
|
|
110
112
|
|
|
113
|
+
## Canonical DATABASE_URL
|
|
114
|
+
|
|
115
|
+
After `parad init` successfully provisions the project and database, Parad persists the complete connection URL in `config.json` as `database_url` and prints a redacted form:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
parad init mydb --project myproject
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Use `--print-database-url` only when intentionally copying the complete secret-bearing URL into a secret manager:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
parad init mydb --project myproject --print-database-url
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Applications can use the same single value:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
import os
|
|
131
|
+
from parad import connect
|
|
132
|
+
|
|
133
|
+
db = connect(url=os.environ["DATABASE_URL"])
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
An explicit `url`, `name`, or `db_path` argument takes precedence over an ambient `DATABASE_URL`. Existing connection strings and config-based workflows remain supported.
|
|
137
|
+
|
|
138
|
+
## SQLAlchemy
|
|
139
|
+
|
|
140
|
+
Install the optional integration with `pip install "parad[sqlalchemy]"`, then use the same canonical URL with `create_engine("parad://...")`. The ORM, Core, DB-API, and encrypted lifecycle examples are documented in [`docs/SQLALCHEMY.md`](docs/SQLALCHEMY.md).
|
|
141
|
+
|
|
111
142
|
## Configuration
|
|
112
143
|
|
|
113
144
|
Config lives at `~/.paradox/config.json`:
|
|
114
145
|
|
|
115
146
|
```json
|
|
116
147
|
{
|
|
148
|
+
"database_url": "parad://<api-key>@local/project/mydb?gateway=https%3A%2F%2F...&passphrase=...",
|
|
117
149
|
"database_path": "~/.paradox/data.db",
|
|
118
150
|
"sync": {
|
|
119
151
|
"gateway_url": "https://paradox-db.onrender.com/v1",
|
|
@@ -6,8 +6,10 @@ parad/cli.py
|
|
|
6
6
|
parad/config.py
|
|
7
7
|
parad/connection.py
|
|
8
8
|
parad/crypto.py
|
|
9
|
+
parad/dbapi.py
|
|
9
10
|
parad/engine.py
|
|
10
11
|
parad/gateway.py
|
|
12
|
+
parad/sqlalchemy.py
|
|
11
13
|
parad/state.py
|
|
12
14
|
parad/types.py
|
|
13
15
|
parad/watcher.py
|
|
@@ -34,5 +36,6 @@ parad/commands/watch.py
|
|
|
34
36
|
tests/test_connection.py
|
|
35
37
|
tests/test_crypto.py
|
|
36
38
|
tests/test_engine.py
|
|
39
|
+
tests/test_sqlalchemy.py
|
|
37
40
|
tests/test_state.py
|
|
38
41
|
tests/test_workflow.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "parad"
|
|
7
|
-
version = "2.2.
|
|
7
|
+
version = "2.2.3"
|
|
8
8
|
description = "Drop-in encrypted database with cloud sync — connect() and go"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -29,6 +29,12 @@ dependencies = [
|
|
|
29
29
|
"python-dotenv>=1.0",
|
|
30
30
|
]
|
|
31
31
|
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
sqlalchemy = ["SQLAlchemy>=2.0"]
|
|
34
|
+
|
|
35
|
+
[project.entry-points."sqlalchemy.dialects"]
|
|
36
|
+
parad = "parad.sqlalchemy:ParadDialect"
|
|
37
|
+
|
|
32
38
|
[project.urls]
|
|
33
39
|
Homepage = "https://github.com/nexuss0781/Paradox-DB"
|
|
34
40
|
Repository = "https://github.com/nexuss0781/Paradox-DB"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import Boolean, Integer, String, create_engine, select
|
|
4
|
+
from sqlalchemy.dialects import registry
|
|
5
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
|
|
6
|
+
|
|
7
|
+
from parad.dbapi import connect as dbapi_connect
|
|
8
|
+
|
|
9
|
+
registry.register("parad", "parad.sqlalchemy", "ParadDialect")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Base(DeclarativeBase):
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class User(Base):
|
|
17
|
+
__tablename__ = "users"
|
|
18
|
+
|
|
19
|
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
|
20
|
+
name: Mapped[str] = mapped_column(String, nullable=False)
|
|
21
|
+
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_dbapi_cursor_and_transaction():
|
|
25
|
+
conn = dbapi_connect("parad://local/dbapi_test?passphrase=test-passphrase")
|
|
26
|
+
try:
|
|
27
|
+
cursor = conn.cursor()
|
|
28
|
+
cursor.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
|
|
29
|
+
cursor.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
|
|
30
|
+
conn.commit()
|
|
31
|
+
cursor.execute("SELECT name FROM users")
|
|
32
|
+
assert cursor.fetchone()[0] == "Alice"
|
|
33
|
+
assert cursor.description[0][0] == "name"
|
|
34
|
+
finally:
|
|
35
|
+
conn.close()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_sqlalchemy_orm_crud_and_transaction():
|
|
39
|
+
engine = create_engine("parad://local/sqlalchemy_test?passphrase=test-passphrase")
|
|
40
|
+
try:
|
|
41
|
+
Base.metadata.create_all(engine)
|
|
42
|
+
with Session(engine) as session:
|
|
43
|
+
session.add(User(name="Alice", active=True))
|
|
44
|
+
session.commit()
|
|
45
|
+
assert session.scalar(select(User.name)) == "Alice"
|
|
46
|
+
session.commit()
|
|
47
|
+
|
|
48
|
+
with session.begin():
|
|
49
|
+
session.add(User(name="Bob", active=False))
|
|
50
|
+
|
|
51
|
+
names = session.scalars(select(User.name).order_by(User.id)).all()
|
|
52
|
+
assert names == ["Alice", "Bob"]
|
|
53
|
+
finally:
|
|
54
|
+
engine.dispose()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_sqlalchemy_reopens_encrypted_database():
|
|
58
|
+
url = "parad://local/sqlalchemy_reopen?passphrase=test-passphrase"
|
|
59
|
+
first = create_engine(url)
|
|
60
|
+
Base.metadata.create_all(first)
|
|
61
|
+
with first.begin() as conn:
|
|
62
|
+
conn.execute(User.__table__.insert().values(name="Persisted", active=True))
|
|
63
|
+
first.dispose()
|
|
64
|
+
|
|
65
|
+
second = create_engine(url)
|
|
66
|
+
try:
|
|
67
|
+
with Session(second) as session:
|
|
68
|
+
assert session.scalar(select(User.name)) == "Persisted"
|
|
69
|
+
finally:
|
|
70
|
+
second.dispose()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|