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.
Files changed (44) hide show
  1. {parad-2.2.2 → parad-2.2.3}/PKG-INFO +34 -2
  2. {parad-2.2.2 → parad-2.2.3}/README.md +31 -1
  3. {parad-2.2.2 → parad-2.2.3}/parad/__init__.py +3 -3
  4. {parad-2.2.2 → parad-2.2.3}/parad/commands/init.py +13 -3
  5. {parad-2.2.2 → parad-2.2.3}/parad/config.py +8 -4
  6. {parad-2.2.2 → parad-2.2.3}/parad/connection.py +35 -3
  7. parad-2.2.3/parad/dbapi.py +217 -0
  8. parad-2.2.3/parad/sqlalchemy.py +30 -0
  9. {parad-2.2.2 → parad-2.2.3}/parad/types.py +1 -0
  10. {parad-2.2.2 → parad-2.2.3}/parad.egg-info/PKG-INFO +34 -2
  11. {parad-2.2.2 → parad-2.2.3}/parad.egg-info/SOURCES.txt +3 -0
  12. parad-2.2.3/parad.egg-info/entry_points.txt +5 -0
  13. {parad-2.2.2 → parad-2.2.3}/parad.egg-info/requires.txt +3 -0
  14. {parad-2.2.2 → parad-2.2.3}/pyproject.toml +7 -1
  15. parad-2.2.3/tests/test_sqlalchemy.py +70 -0
  16. parad-2.2.2/parad.egg-info/entry_points.txt +0 -2
  17. {parad-2.2.2 → parad-2.2.3}/parad/__main__.py +0 -0
  18. {parad-2.2.2 → parad-2.2.3}/parad/cli.py +0 -0
  19. {parad-2.2.2 → parad-2.2.3}/parad/commands/__init__.py +0 -0
  20. {parad-2.2.2 → parad-2.2.3}/parad/commands/auth.py +0 -0
  21. {parad-2.2.2 → parad-2.2.3}/parad/commands/backups.py +0 -0
  22. {parad-2.2.2 → parad-2.2.3}/parad/commands/config_cmd.py +0 -0
  23. {parad-2.2.2 → parad-2.2.3}/parad/commands/connect.py +0 -0
  24. {parad-2.2.2 → parad-2.2.3}/parad/commands/databases.py +0 -0
  25. {parad-2.2.2 → parad-2.2.3}/parad/commands/projects.py +0 -0
  26. {parad-2.2.2 → parad-2.2.3}/parad/commands/query.py +0 -0
  27. {parad-2.2.2 → parad-2.2.3}/parad/commands/shell.py +0 -0
  28. {parad-2.2.2 → parad-2.2.3}/parad/commands/status.py +0 -0
  29. {parad-2.2.2 → parad-2.2.3}/parad/commands/sync.py +0 -0
  30. {parad-2.2.2 → parad-2.2.3}/parad/commands/versions.py +0 -0
  31. {parad-2.2.2 → parad-2.2.3}/parad/commands/watch.py +0 -0
  32. {parad-2.2.2 → parad-2.2.3}/parad/crypto.py +0 -0
  33. {parad-2.2.2 → parad-2.2.3}/parad/engine.py +0 -0
  34. {parad-2.2.2 → parad-2.2.3}/parad/gateway.py +0 -0
  35. {parad-2.2.2 → parad-2.2.3}/parad/state.py +0 -0
  36. {parad-2.2.2 → parad-2.2.3}/parad/watcher.py +0 -0
  37. {parad-2.2.2 → parad-2.2.3}/parad.egg-info/dependency_links.txt +0 -0
  38. {parad-2.2.2 → parad-2.2.3}/parad.egg-info/top_level.txt +0 -0
  39. {parad-2.2.2 → parad-2.2.3}/setup.cfg +0 -0
  40. {parad-2.2.2 → parad-2.2.3}/tests/test_connection.py +0 -0
  41. {parad-2.2.2 → parad-2.2.3}/tests/test_crypto.py +0 -0
  42. {parad-2.2.2 → parad-2.2.3}/tests/test_engine.py +0 -0
  43. {parad-2.2.2 → parad-2.2.3}/tests/test_state.py +0 -0
  44. {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.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.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
- def init(name: str, passphrase: str, gateway: str | None, project: str | None, do_watch: bool):
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 a connection URL for a database.
146
-
147
- Returns: parad://local/{name}?passphrase={passphrase}
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) if self._gateway_url else ""
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 url:
757
- parsed_url = parse_url(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
@@ -34,6 +34,7 @@ class LoggingConfig(BaseModel):
34
34
 
35
35
 
36
36
  class Config(BaseModel):
37
+ database_url: str = ""
37
38
  database_path: str = "~/.paradox/data.db"
38
39
  project_id: str = ""
39
40
  project_name: str = ""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parad
3
- Version: 2.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
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ parad = parad.cli:main
3
+
4
+ [sqlalchemy.dialects]
5
+ parad = parad.sqlalchemy:ParadDialect
@@ -3,3 +3,6 @@ httpx>=0.25
3
3
  cryptography>=41.0
4
4
  pydantic>=2.0
5
5
  python-dotenv>=1.0
6
+
7
+ [sqlalchemy]
8
+ SQLAlchemy>=2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "parad"
7
- version = "2.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()
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- parad = parad.cli:main
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