parad 2.2.2__tar.gz → 2.2.4__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.4}/PKG-INFO +43 -6
- {parad-2.2.2 → parad-2.2.4}/README.md +40 -5
- parad-2.2.4/parad/__init__.py +8 -0
- {parad-2.2.2 → parad-2.2.4}/parad/cli.py +3 -0
- parad-2.2.4/parad/commands/config_cmd.py +32 -0
- {parad-2.2.2 → parad-2.2.4}/parad/commands/init.py +13 -3
- parad-2.2.4/parad/commands/url.py +35 -0
- {parad-2.2.2 → parad-2.2.4}/parad/config.py +64 -10
- {parad-2.2.2 → parad-2.2.4}/parad/connection.py +35 -3
- parad-2.2.4/parad/dbapi.py +217 -0
- parad-2.2.4/parad/sqlalchemy.py +30 -0
- {parad-2.2.2 → parad-2.2.4}/parad/types.py +1 -0
- {parad-2.2.2 → parad-2.2.4}/parad.egg-info/PKG-INFO +43 -6
- {parad-2.2.2 → parad-2.2.4}/parad.egg-info/SOURCES.txt +4 -0
- parad-2.2.4/parad.egg-info/entry_points.txt +5 -0
- {parad-2.2.2 → parad-2.2.4}/parad.egg-info/requires.txt +3 -0
- {parad-2.2.2 → parad-2.2.4}/pyproject.toml +7 -1
- {parad-2.2.2 → parad-2.2.4}/tests/test_connection.py +32 -0
- parad-2.2.4/tests/test_sqlalchemy.py +70 -0
- parad-2.2.2/parad/__init__.py +0 -8
- parad-2.2.2/parad/commands/config_cmd.py +0 -27
- parad-2.2.2/parad.egg-info/entry_points.txt +0 -2
- {parad-2.2.2 → parad-2.2.4}/parad/__main__.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/commands/__init__.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/commands/auth.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/commands/backups.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/commands/connect.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/commands/databases.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/commands/projects.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/commands/query.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/commands/shell.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/commands/status.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/commands/sync.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/commands/versions.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/commands/watch.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/crypto.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/engine.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/gateway.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/state.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad/watcher.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad.egg-info/dependency_links.txt +0 -0
- {parad-2.2.2 → parad-2.2.4}/parad.egg-info/top_level.txt +0 -0
- {parad-2.2.2 → parad-2.2.4}/setup.cfg +0 -0
- {parad-2.2.2 → parad-2.2.4}/tests/test_crypto.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/tests/test_engine.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/tests/test_state.py +0 -0
- {parad-2.2.2 → parad-2.2.4}/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.4
|
|
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
|
|
|
@@ -73,10 +75,14 @@ db = connect("mydb", passphrase="secret", auto_sync=False)
|
|
|
73
75
|
### CLI
|
|
74
76
|
|
|
75
77
|
```bash
|
|
76
|
-
# Create
|
|
77
|
-
parad
|
|
78
|
-
|
|
78
|
+
# Create a new encrypted database and print the canonical URL deliberately
|
|
79
|
+
parad auth login
|
|
80
|
+
parad init mydb --project myproject --print-database-url
|
|
81
|
+
# Retrieve the existing canonical URL without remote mutation
|
|
82
|
+
parad url
|
|
83
|
+
parad url --print-database-url
|
|
79
84
|
# Push to cloud
|
|
85
|
+
|
|
80
86
|
parad push
|
|
81
87
|
|
|
82
88
|
# Pull latest
|
|
@@ -93,7 +99,7 @@ parad shell
|
|
|
93
99
|
|
|
94
100
|
| Command | Description |
|
|
95
101
|
|---|---|
|
|
96
|
-
| `parad init <name>` | Create encrypted DB + register with gateway |
|
|
102
|
+
| `parad init <name>` | Create encrypted DB + register with gateway; emit canonical DATABASE_URL |
|
|
97
103
|
| `parad push` | Push database to Telegram cloud |
|
|
98
104
|
| `parad pull [version]` | Pull latest or specific version |
|
|
99
105
|
| `parad sync` | Push then pull |
|
|
@@ -106,7 +112,37 @@ parad shell
|
|
|
106
112
|
| `parad update <table> <set> <where>` | Update rows |
|
|
107
113
|
| `parad delete <table> <where>` | Delete rows |
|
|
108
114
|
| `parad shell` | Interactive SQL REPL |
|
|
109
|
-
| `parad config show/set` | Manage config |
|
|
115
|
+
| `parad config show/set` | Manage config; secret-bearing fields are redacted |
|
|
116
|
+
| `parad url [name]` | Retrieve the canonical database_url |
|
|
117
|
+
| `parad database-url [name]` | Alias for `parad url` |
|
|
118
|
+
## Canonical DATABASE_URL first
|
|
119
|
+
Use one canonical connection value for new applications and deployments. After `parad init` provisions the project/database, it persists `database_url` in `~/.paradox/config.json` and prints a redacted URL by default:
|
|
120
|
+
```bash
|
|
121
|
+
parad auth login
|
|
122
|
+
parad init mydb --project myproject
|
|
123
|
+
```
|
|
124
|
+
Print the complete secret-bearing value only when intentionally copying it into a secret manager:
|
|
125
|
+
```bash
|
|
126
|
+
parad init mydb --project myproject --print-database-url
|
|
127
|
+
```
|
|
128
|
+
For an existing database, retrieve the saved canonical value without opening, syncing, or mutating the remote database:
|
|
129
|
+
```bash
|
|
130
|
+
parad url
|
|
131
|
+
parad url --print-database-url
|
|
132
|
+
```
|
|
133
|
+
Retrieval checks `DATABASE_URL`, then persisted `database_url`, then reconstructs and persists a canonical URL from legacy split fields when a passphrase is available. If the passphrase is missing, it stops instead of inventing a replacement. New projects should use only `DATABASE_URL`; split fields remain supported for legacy applications.
|
|
134
|
+
Applications can use the same single value:
|
|
135
|
+
```python
|
|
136
|
+
import os
|
|
137
|
+
from parad import connect
|
|
138
|
+
db = connect(url=os.environ["DATABASE_URL"])
|
|
139
|
+
```
|
|
140
|
+
An explicit `url` argument is strongest. Explicit `name` or `db_path` options remain available for legacy target selection.
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
## SQLAlchemy
|
|
144
|
+
|
|
145
|
+
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).
|
|
110
146
|
|
|
111
147
|
## Configuration
|
|
112
148
|
|
|
@@ -114,6 +150,7 @@ Config lives at `~/.paradox/config.json`:
|
|
|
114
150
|
|
|
115
151
|
```json
|
|
116
152
|
{
|
|
153
|
+
"database_url": "parad://<api-key>@local/project/mydb?gateway=https%3A%2F%2F...&passphrase=...",
|
|
117
154
|
"database_path": "~/.paradox/data.db",
|
|
118
155
|
"sync": {
|
|
119
156
|
"gateway_url": "https://paradox-db.onrender.com/v1",
|
|
@@ -49,10 +49,14 @@ db = connect("mydb", passphrase="secret", auto_sync=False)
|
|
|
49
49
|
### CLI
|
|
50
50
|
|
|
51
51
|
```bash
|
|
52
|
-
# Create
|
|
53
|
-
parad
|
|
54
|
-
|
|
52
|
+
# Create a new encrypted database and print the canonical URL deliberately
|
|
53
|
+
parad auth login
|
|
54
|
+
parad init mydb --project myproject --print-database-url
|
|
55
|
+
# Retrieve the existing canonical URL without remote mutation
|
|
56
|
+
parad url
|
|
57
|
+
parad url --print-database-url
|
|
55
58
|
# Push to cloud
|
|
59
|
+
|
|
56
60
|
parad push
|
|
57
61
|
|
|
58
62
|
# Pull latest
|
|
@@ -69,7 +73,7 @@ parad shell
|
|
|
69
73
|
|
|
70
74
|
| Command | Description |
|
|
71
75
|
|---|---|
|
|
72
|
-
| `parad init <name>` | Create encrypted DB + register with gateway |
|
|
76
|
+
| `parad init <name>` | Create encrypted DB + register with gateway; emit canonical DATABASE_URL |
|
|
73
77
|
| `parad push` | Push database to Telegram cloud |
|
|
74
78
|
| `parad pull [version]` | Pull latest or specific version |
|
|
75
79
|
| `parad sync` | Push then pull |
|
|
@@ -82,7 +86,37 @@ parad shell
|
|
|
82
86
|
| `parad update <table> <set> <where>` | Update rows |
|
|
83
87
|
| `parad delete <table> <where>` | Delete rows |
|
|
84
88
|
| `parad shell` | Interactive SQL REPL |
|
|
85
|
-
| `parad config show/set` | Manage config |
|
|
89
|
+
| `parad config show/set` | Manage config; secret-bearing fields are redacted |
|
|
90
|
+
| `parad url [name]` | Retrieve the canonical database_url |
|
|
91
|
+
| `parad database-url [name]` | Alias for `parad url` |
|
|
92
|
+
## Canonical DATABASE_URL first
|
|
93
|
+
Use one canonical connection value for new applications and deployments. After `parad init` provisions the project/database, it persists `database_url` in `~/.paradox/config.json` and prints a redacted URL by default:
|
|
94
|
+
```bash
|
|
95
|
+
parad auth login
|
|
96
|
+
parad init mydb --project myproject
|
|
97
|
+
```
|
|
98
|
+
Print the complete secret-bearing value only when intentionally copying it into a secret manager:
|
|
99
|
+
```bash
|
|
100
|
+
parad init mydb --project myproject --print-database-url
|
|
101
|
+
```
|
|
102
|
+
For an existing database, retrieve the saved canonical value without opening, syncing, or mutating the remote database:
|
|
103
|
+
```bash
|
|
104
|
+
parad url
|
|
105
|
+
parad url --print-database-url
|
|
106
|
+
```
|
|
107
|
+
Retrieval checks `DATABASE_URL`, then persisted `database_url`, then reconstructs and persists a canonical URL from legacy split fields when a passphrase is available. If the passphrase is missing, it stops instead of inventing a replacement. New projects should use only `DATABASE_URL`; split fields remain supported for legacy applications.
|
|
108
|
+
Applications can use the same single value:
|
|
109
|
+
```python
|
|
110
|
+
import os
|
|
111
|
+
from parad import connect
|
|
112
|
+
db = connect(url=os.environ["DATABASE_URL"])
|
|
113
|
+
```
|
|
114
|
+
An explicit `url` argument is strongest. Explicit `name` or `db_path` options remain available for legacy target selection.
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
## SQLAlchemy
|
|
118
|
+
|
|
119
|
+
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).
|
|
86
120
|
|
|
87
121
|
## Configuration
|
|
88
122
|
|
|
@@ -90,6 +124,7 @@ Config lives at `~/.paradox/config.json`:
|
|
|
90
124
|
|
|
91
125
|
```json
|
|
92
126
|
{
|
|
127
|
+
"database_url": "parad://<api-key>@local/project/mydb?gateway=https%3A%2F%2F...&passphrase=...",
|
|
93
128
|
"database_path": "~/.paradox/data.db",
|
|
94
129
|
"sync": {
|
|
95
130
|
"gateway_url": "https://paradox-db.onrender.com/v1",
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""parad — Encrypted local-first SQLite with cloud sync."""
|
|
2
|
+
|
|
3
|
+
from parad.connection import connect, ParadConnection, parse_url, generate_url, redact_url, db_state_key, generate_passphrase
|
|
4
|
+
from parad.engine import Engine
|
|
5
|
+
from parad.config import load_config, get_passphrase, get_canonical_database_url, get_connection_url
|
|
6
|
+
|
|
7
|
+
__version__ = "2.2.4"
|
|
8
|
+
__all__ = ["connect", "ParadConnection", "parse_url", "generate_url", "redact_url", "db_state_key", "generate_passphrase", "Engine", "load_config", "get_canonical_database_url", "get_connection_url"]
|
|
@@ -24,6 +24,7 @@ from parad.commands.projects import project_group
|
|
|
24
24
|
from parad.commands.databases import db_group
|
|
25
25
|
from parad.commands.versions import version_group
|
|
26
26
|
from parad.commands.backups import backup_group
|
|
27
|
+
from parad.commands.url import url_command, database_url_command
|
|
27
28
|
|
|
28
29
|
main.add_command(connect)
|
|
29
30
|
main.add_command(init)
|
|
@@ -46,3 +47,5 @@ main.add_command(project_group)
|
|
|
46
47
|
main.add_command(db_group)
|
|
47
48
|
main.add_command(version_group)
|
|
48
49
|
main.add_command(backup_group)
|
|
50
|
+
main.add_command(url_command)
|
|
51
|
+
main.add_command(database_url_command)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""parad config — View and update configuration."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from parad.config import load_config, set_config_value, save_config
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.group("config")
|
|
8
|
+
def config_group():
|
|
9
|
+
"""Manage parad configuration."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@config_group.command("show")
|
|
14
|
+
def config_show():
|
|
15
|
+
"""Show current configuration with secret-bearing fields redacted."""
|
|
16
|
+
import json
|
|
17
|
+
|
|
18
|
+
config = load_config().model_dump()
|
|
19
|
+
config["database_url"] = "<redacted>" if config.get("database_url") else ""
|
|
20
|
+
config.setdefault("encryption", {})["passphrase"] = "<redacted>" if config.get("encryption", {}).get("passphrase") else ""
|
|
21
|
+
config.setdefault("sync", {})["api_key"] = "<redacted>" if config.get("sync", {}).get("api_key") else ""
|
|
22
|
+
click.echo(json.dumps(config, indent=2))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@config_group.command("set")
|
|
26
|
+
@click.argument("key")
|
|
27
|
+
@click.argument("value")
|
|
28
|
+
def config_set(key: str, value: str):
|
|
29
|
+
"""Set a config value: parad config set sync.api_key pk_xxx"""
|
|
30
|
+
set_config_value(key, value)
|
|
31
|
+
shown = "<redacted>" if any(part.lower() in {"database_url", "api_key", "passphrase", "password", "token"} for part in key.split(".")) else value
|
|
32
|
+
click.echo(f"✓ Set {key} = {shown}")
|
|
@@ -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:
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Canonical database URL retrieval commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from parad.config import get_canonical_database_url
|
|
8
|
+
from parad.connection import redact_url
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _emit_url(name: str | None, print_database_url: bool, json_mode: bool) -> None:
|
|
12
|
+
database_url = get_canonical_database_url(name)
|
|
13
|
+
displayed = database_url if print_database_url else redact_url(database_url)
|
|
14
|
+
if json_mode:
|
|
15
|
+
click.echo(json.dumps({"database_url": displayed}))
|
|
16
|
+
else:
|
|
17
|
+
click.echo(f"DATABASE_URL: {displayed}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.command("url")
|
|
21
|
+
@click.argument("name", required=False)
|
|
22
|
+
@click.option("--print-database-url", is_flag=True, help="Print the full secret-bearing DATABASE_URL")
|
|
23
|
+
@click.option("--json", "json_mode", is_flag=True, help="Print JSON output")
|
|
24
|
+
def url_command(name: str | None, print_database_url: bool, json_mode: bool) -> None:
|
|
25
|
+
"""Retrieve the canonical database_url from env/config or legacy fields."""
|
|
26
|
+
_emit_url(name, print_database_url, json_mode)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.command("database-url")
|
|
30
|
+
@click.argument("name", required=False)
|
|
31
|
+
@click.option("--print-database-url", is_flag=True, help="Print the full secret-bearing DATABASE_URL")
|
|
32
|
+
@click.option("--json", "json_mode", is_flag=True, help="Print JSON output")
|
|
33
|
+
def database_url_command(name: str | None, print_database_url: bool, json_mode: bool) -> None:
|
|
34
|
+
"""Alias for ``parad url``."""
|
|
35
|
+
_emit_url(name, print_database_url, json_mode)
|
|
@@ -10,6 +10,11 @@ CONFIG_DIR = Path(os.environ.get("PARADOX_HOME", "~/.paradox")).expanduser()
|
|
|
10
10
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
def config_file() -> Path:
|
|
14
|
+
"""Return the current config path, honoring runtime PARADOX_HOME changes."""
|
|
15
|
+
return config_dir() / "config.json"
|
|
16
|
+
|
|
17
|
+
|
|
13
18
|
def config_dir() -> Path:
|
|
14
19
|
"""Return the current config directory.
|
|
15
20
|
|
|
@@ -19,6 +24,7 @@ def config_dir() -> Path:
|
|
|
19
24
|
return Path(os.environ.get("PARADOX_HOME", "~/.paradox")).expanduser()
|
|
20
25
|
|
|
21
26
|
DEFAULT_CONFIG = {
|
|
27
|
+
"database_url": "",
|
|
22
28
|
"database_path": "~/.paradox/data.db",
|
|
23
29
|
"encryption": {
|
|
24
30
|
"cipher": "aes-256-cbc",
|
|
@@ -73,15 +79,17 @@ def load_config() -> Config:
|
|
|
73
79
|
- PARADOX_GATEWAY → sync.gateway_url
|
|
74
80
|
- PARADOX_DATABASE → database_path
|
|
75
81
|
- PARADOX_API_KEY → sync.api_key
|
|
82
|
+
- DATABASE_URL → canonical Parad connection URL
|
|
76
83
|
|
|
77
84
|
.env files are loaded automatically if python-dotenv is installed.
|
|
78
85
|
"""
|
|
79
86
|
_load_dotenv()
|
|
80
87
|
|
|
81
88
|
user_config = {}
|
|
82
|
-
|
|
89
|
+
config_path = config_file()
|
|
90
|
+
if config_path.exists():
|
|
83
91
|
try:
|
|
84
|
-
user_config = json.loads(
|
|
92
|
+
user_config = json.loads(config_path.read_text())
|
|
85
93
|
except (json.JSONDecodeError, OSError):
|
|
86
94
|
pass
|
|
87
95
|
merged = _deep_merge(DEFAULT_CONFIG, user_config)
|
|
@@ -95,14 +103,17 @@ def load_config() -> Config:
|
|
|
95
103
|
merged["database_path"] = os.environ["PARADOX_DATABASE"]
|
|
96
104
|
if "PARADOX_API_KEY" in os.environ:
|
|
97
105
|
merged["sync"]["api_key"] = os.environ["PARADOX_API_KEY"]
|
|
106
|
+
if "DATABASE_URL" in os.environ:
|
|
107
|
+
merged["database_url"] = os.environ["DATABASE_URL"]
|
|
98
108
|
|
|
99
109
|
return Config(**merged)
|
|
100
110
|
|
|
101
111
|
|
|
102
112
|
def save_config(config: Config):
|
|
103
113
|
"""Save config to ~/.paradox/config.json."""
|
|
104
|
-
|
|
105
|
-
|
|
114
|
+
path = config_file()
|
|
115
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
path.write_text(json.dumps(config.model_dump(), indent=2))
|
|
106
117
|
|
|
107
118
|
|
|
108
119
|
def set_config_value(key: str, value: str):
|
|
@@ -141,10 +152,53 @@ def get_passphrase() -> str:
|
|
|
141
152
|
return config.encryption.passphrase
|
|
142
153
|
|
|
143
154
|
|
|
144
|
-
def
|
|
145
|
-
"""
|
|
146
|
-
|
|
147
|
-
|
|
155
|
+
def get_canonical_database_url(name: str | None = None) -> str:
|
|
156
|
+
"""Return the canonical single-value database URL.
|
|
157
|
+
|
|
158
|
+
Precedence is ``DATABASE_URL`` environment variable, then the persisted
|
|
159
|
+
``database_url`` config field, then legacy split fields. When the legacy
|
|
160
|
+
fields are sufficient, the reconstructed URL is persisted immediately so
|
|
161
|
+
later processes can use the canonical value only.
|
|
148
162
|
"""
|
|
149
|
-
|
|
150
|
-
|
|
163
|
+
config = load_config()
|
|
164
|
+
configured = os.environ.get("DATABASE_URL", "").strip() or config.database_url.strip()
|
|
165
|
+
if configured:
|
|
166
|
+
from parad.connection import parse_url
|
|
167
|
+
|
|
168
|
+
parsed = parse_url(configured)
|
|
169
|
+
if name and parsed["name"] != name:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"Canonical DATABASE_URL points to '{parsed['name']}', not '{name}'"
|
|
172
|
+
)
|
|
173
|
+
return configured
|
|
174
|
+
|
|
175
|
+
inferred_name = name or gateway_db_name(config.database_path)
|
|
176
|
+
if not inferred_name:
|
|
177
|
+
raise ValueError("No database name is configured; run parad init <name> first")
|
|
178
|
+
|
|
179
|
+
passphrase = os.environ.get("PARADOX_PASSPHRASE", "").strip() or config.encryption.passphrase.strip()
|
|
180
|
+
gateway_url = os.environ.get("PARADOX_GATEWAY", "").strip() or config.sync.gateway_url.strip()
|
|
181
|
+
api_key = os.environ.get("PARADOX_API_KEY", "").strip() or config.sync.api_key.strip()
|
|
182
|
+
if gateway_url and not passphrase:
|
|
183
|
+
raise ValueError(
|
|
184
|
+
f"No passphrase is configured for '{inferred_name}'. Set PARADOX_PASSPHRASE "
|
|
185
|
+
"or recover DATABASE_URL from the original provisioning output."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
from parad.connection import generate_url
|
|
189
|
+
|
|
190
|
+
canonical = generate_url(
|
|
191
|
+
inferred_name,
|
|
192
|
+
passphrase,
|
|
193
|
+
gateway_url,
|
|
194
|
+
config.project_name or None,
|
|
195
|
+
api_key,
|
|
196
|
+
)
|
|
197
|
+
config.database_url = canonical
|
|
198
|
+
save_config(config)
|
|
199
|
+
return canonical
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def get_connection_url(name: str) -> str:
|
|
203
|
+
"""Backward-compatible alias for :func:`get_canonical_database_url`."""
|
|
204
|
+
return get_canonical_database_url(name)
|
|
@@ -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.4
|
|
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
|
|
|
@@ -73,10 +75,14 @@ db = connect("mydb", passphrase="secret", auto_sync=False)
|
|
|
73
75
|
### CLI
|
|
74
76
|
|
|
75
77
|
```bash
|
|
76
|
-
# Create
|
|
77
|
-
parad
|
|
78
|
-
|
|
78
|
+
# Create a new encrypted database and print the canonical URL deliberately
|
|
79
|
+
parad auth login
|
|
80
|
+
parad init mydb --project myproject --print-database-url
|
|
81
|
+
# Retrieve the existing canonical URL without remote mutation
|
|
82
|
+
parad url
|
|
83
|
+
parad url --print-database-url
|
|
79
84
|
# Push to cloud
|
|
85
|
+
|
|
80
86
|
parad push
|
|
81
87
|
|
|
82
88
|
# Pull latest
|
|
@@ -93,7 +99,7 @@ parad shell
|
|
|
93
99
|
|
|
94
100
|
| Command | Description |
|
|
95
101
|
|---|---|
|
|
96
|
-
| `parad init <name>` | Create encrypted DB + register with gateway |
|
|
102
|
+
| `parad init <name>` | Create encrypted DB + register with gateway; emit canonical DATABASE_URL |
|
|
97
103
|
| `parad push` | Push database to Telegram cloud |
|
|
98
104
|
| `parad pull [version]` | Pull latest or specific version |
|
|
99
105
|
| `parad sync` | Push then pull |
|
|
@@ -106,7 +112,37 @@ parad shell
|
|
|
106
112
|
| `parad update <table> <set> <where>` | Update rows |
|
|
107
113
|
| `parad delete <table> <where>` | Delete rows |
|
|
108
114
|
| `parad shell` | Interactive SQL REPL |
|
|
109
|
-
| `parad config show/set` | Manage config |
|
|
115
|
+
| `parad config show/set` | Manage config; secret-bearing fields are redacted |
|
|
116
|
+
| `parad url [name]` | Retrieve the canonical database_url |
|
|
117
|
+
| `parad database-url [name]` | Alias for `parad url` |
|
|
118
|
+
## Canonical DATABASE_URL first
|
|
119
|
+
Use one canonical connection value for new applications and deployments. After `parad init` provisions the project/database, it persists `database_url` in `~/.paradox/config.json` and prints a redacted URL by default:
|
|
120
|
+
```bash
|
|
121
|
+
parad auth login
|
|
122
|
+
parad init mydb --project myproject
|
|
123
|
+
```
|
|
124
|
+
Print the complete secret-bearing value only when intentionally copying it into a secret manager:
|
|
125
|
+
```bash
|
|
126
|
+
parad init mydb --project myproject --print-database-url
|
|
127
|
+
```
|
|
128
|
+
For an existing database, retrieve the saved canonical value without opening, syncing, or mutating the remote database:
|
|
129
|
+
```bash
|
|
130
|
+
parad url
|
|
131
|
+
parad url --print-database-url
|
|
132
|
+
```
|
|
133
|
+
Retrieval checks `DATABASE_URL`, then persisted `database_url`, then reconstructs and persists a canonical URL from legacy split fields when a passphrase is available. If the passphrase is missing, it stops instead of inventing a replacement. New projects should use only `DATABASE_URL`; split fields remain supported for legacy applications.
|
|
134
|
+
Applications can use the same single value:
|
|
135
|
+
```python
|
|
136
|
+
import os
|
|
137
|
+
from parad import connect
|
|
138
|
+
db = connect(url=os.environ["DATABASE_URL"])
|
|
139
|
+
```
|
|
140
|
+
An explicit `url` argument is strongest. Explicit `name` or `db_path` options remain available for legacy target selection.
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
## SQLAlchemy
|
|
144
|
+
|
|
145
|
+
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).
|
|
110
146
|
|
|
111
147
|
## Configuration
|
|
112
148
|
|
|
@@ -114,6 +150,7 @@ Config lives at `~/.paradox/config.json`:
|
|
|
114
150
|
|
|
115
151
|
```json
|
|
116
152
|
{
|
|
153
|
+
"database_url": "parad://<api-key>@local/project/mydb?gateway=https%3A%2F%2F...&passphrase=...",
|
|
117
154
|
"database_path": "~/.paradox/data.db",
|
|
118
155
|
"sync": {
|
|
119
156
|
"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
|
|
@@ -29,10 +31,12 @@ parad/commands/query.py
|
|
|
29
31
|
parad/commands/shell.py
|
|
30
32
|
parad/commands/status.py
|
|
31
33
|
parad/commands/sync.py
|
|
34
|
+
parad/commands/url.py
|
|
32
35
|
parad/commands/versions.py
|
|
33
36
|
parad/commands/watch.py
|
|
34
37
|
tests/test_connection.py
|
|
35
38
|
tests/test_crypto.py
|
|
36
39
|
tests/test_engine.py
|
|
40
|
+
tests/test_sqlalchemy.py
|
|
37
41
|
tests/test_state.py
|
|
38
42
|
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.4"
|
|
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"
|
|
@@ -10,6 +10,7 @@ import pytest
|
|
|
10
10
|
import parad.config as _cfg
|
|
11
11
|
import parad.connection as pc
|
|
12
12
|
from parad.connection import connect, db_state_key, generate_url, parse_url
|
|
13
|
+
from parad.config import get_canonical_database_url, load_config
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
class FakeGatewayClient:
|
|
@@ -154,6 +155,37 @@ def test_generate_url_omits_empty_query_params():
|
|
|
154
155
|
assert generate_url("mydb") == "parad://local/mydb"
|
|
155
156
|
|
|
156
157
|
|
|
158
|
+
def test_canonical_database_url_prefers_environment(monkeypatch, tmp_path):
|
|
159
|
+
monkeypatch.setenv("PARADOX_HOME", str(tmp_path))
|
|
160
|
+
url = "parad://local/proj/newdb?passphrase=secret&gateway=https://g/v1"
|
|
161
|
+
monkeypatch.setenv("DATABASE_URL", url)
|
|
162
|
+
assert get_canonical_database_url() == url
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_canonical_database_url_prefers_persisted_config(monkeypatch, tmp_path):
|
|
166
|
+
monkeypatch.setenv("PARADOX_HOME", str(tmp_path))
|
|
167
|
+
(tmp_path / "config.json").write_text('{"database_url": "parad://local/proj/db?passphrase=secret"}')
|
|
168
|
+
monkeypatch.delenv("DATABASE_URL", raising=False)
|
|
169
|
+
assert get_canonical_database_url() == "parad://local/proj/db?passphrase=secret"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_canonical_database_url_reconstructs_and_persists_legacy_config(monkeypatch, tmp_path):
|
|
173
|
+
monkeypatch.setenv("PARADOX_HOME", str(tmp_path))
|
|
174
|
+
monkeypatch.delenv("DATABASE_URL", raising=False)
|
|
175
|
+
(tmp_path / "config.json").write_text(
|
|
176
|
+
'{"database_path": "~/legacy.db", "project_name": "proj", '
|
|
177
|
+
'"encryption": {"passphrase": "secret"}, '
|
|
178
|
+
'"sync": {"gateway_url": "https://g/v1", "api_key": "token"}}'
|
|
179
|
+
)
|
|
180
|
+
url = get_canonical_database_url()
|
|
181
|
+
parsed = parse_url(url)
|
|
182
|
+
assert parsed["name"] == "legacy"
|
|
183
|
+
assert parsed["project"] == "proj"
|
|
184
|
+
assert parsed["passphrase"] == "secret"
|
|
185
|
+
assert parsed["token"] == "token"
|
|
186
|
+
assert load_config().database_url == url
|
|
187
|
+
|
|
188
|
+
|
|
157
189
|
def test_db_state_key_project_scoped():
|
|
158
190
|
assert db_state_key("mydb", "myproj") == "myproj/mydb"
|
|
159
191
|
assert db_state_key("mydb", "myproj/sub") == "myproj/sub/mydb"
|
|
@@ -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()
|
parad-2.2.2/parad/__init__.py
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
"""parad — Encrypted local-first SQLite with cloud sync."""
|
|
2
|
-
|
|
3
|
-
from parad.connection import connect, ParadConnection, parse_url, generate_url, db_state_key, generate_passphrase
|
|
4
|
-
from parad.engine import Engine
|
|
5
|
-
from parad.config import load_config, get_passphrase, get_connection_url
|
|
6
|
-
|
|
7
|
-
__version__ = "2.2.2"
|
|
8
|
-
__all__ = ["connect", "ParadConnection", "parse_url", "generate_url", "db_state_key", "generate_passphrase", "Engine", "load_config"]
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
"""parad config — View and update configuration."""
|
|
2
|
-
|
|
3
|
-
import click
|
|
4
|
-
from parad.config import load_config, set_config_value, save_config
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@click.group("config")
|
|
8
|
-
def config_group():
|
|
9
|
-
"""Manage parad configuration."""
|
|
10
|
-
pass
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
@config_group.command("show")
|
|
14
|
-
def config_show():
|
|
15
|
-
"""Show current configuration."""
|
|
16
|
-
import json
|
|
17
|
-
config = load_config()
|
|
18
|
-
click.echo(json.dumps(config.model_dump(), indent=2))
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@config_group.command("set")
|
|
22
|
-
@click.argument("key")
|
|
23
|
-
@click.argument("value")
|
|
24
|
-
def config_set(key: str, value: str):
|
|
25
|
-
"""Set a config value: parad config set sync.api_key pk_xxx"""
|
|
26
|
-
set_config_value(key, value)
|
|
27
|
-
click.echo(f"✓ Set {key} = {value}")
|
|
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
|