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.
Files changed (47) hide show
  1. {parad-2.2.2 → parad-2.2.4}/PKG-INFO +43 -6
  2. {parad-2.2.2 → parad-2.2.4}/README.md +40 -5
  3. parad-2.2.4/parad/__init__.py +8 -0
  4. {parad-2.2.2 → parad-2.2.4}/parad/cli.py +3 -0
  5. parad-2.2.4/parad/commands/config_cmd.py +32 -0
  6. {parad-2.2.2 → parad-2.2.4}/parad/commands/init.py +13 -3
  7. parad-2.2.4/parad/commands/url.py +35 -0
  8. {parad-2.2.2 → parad-2.2.4}/parad/config.py +64 -10
  9. {parad-2.2.2 → parad-2.2.4}/parad/connection.py +35 -3
  10. parad-2.2.4/parad/dbapi.py +217 -0
  11. parad-2.2.4/parad/sqlalchemy.py +30 -0
  12. {parad-2.2.2 → parad-2.2.4}/parad/types.py +1 -0
  13. {parad-2.2.2 → parad-2.2.4}/parad.egg-info/PKG-INFO +43 -6
  14. {parad-2.2.2 → parad-2.2.4}/parad.egg-info/SOURCES.txt +4 -0
  15. parad-2.2.4/parad.egg-info/entry_points.txt +5 -0
  16. {parad-2.2.2 → parad-2.2.4}/parad.egg-info/requires.txt +3 -0
  17. {parad-2.2.2 → parad-2.2.4}/pyproject.toml +7 -1
  18. {parad-2.2.2 → parad-2.2.4}/tests/test_connection.py +32 -0
  19. parad-2.2.4/tests/test_sqlalchemy.py +70 -0
  20. parad-2.2.2/parad/__init__.py +0 -8
  21. parad-2.2.2/parad/commands/config_cmd.py +0 -27
  22. parad-2.2.2/parad.egg-info/entry_points.txt +0 -2
  23. {parad-2.2.2 → parad-2.2.4}/parad/__main__.py +0 -0
  24. {parad-2.2.2 → parad-2.2.4}/parad/commands/__init__.py +0 -0
  25. {parad-2.2.2 → parad-2.2.4}/parad/commands/auth.py +0 -0
  26. {parad-2.2.2 → parad-2.2.4}/parad/commands/backups.py +0 -0
  27. {parad-2.2.2 → parad-2.2.4}/parad/commands/connect.py +0 -0
  28. {parad-2.2.2 → parad-2.2.4}/parad/commands/databases.py +0 -0
  29. {parad-2.2.2 → parad-2.2.4}/parad/commands/projects.py +0 -0
  30. {parad-2.2.2 → parad-2.2.4}/parad/commands/query.py +0 -0
  31. {parad-2.2.2 → parad-2.2.4}/parad/commands/shell.py +0 -0
  32. {parad-2.2.2 → parad-2.2.4}/parad/commands/status.py +0 -0
  33. {parad-2.2.2 → parad-2.2.4}/parad/commands/sync.py +0 -0
  34. {parad-2.2.2 → parad-2.2.4}/parad/commands/versions.py +0 -0
  35. {parad-2.2.2 → parad-2.2.4}/parad/commands/watch.py +0 -0
  36. {parad-2.2.2 → parad-2.2.4}/parad/crypto.py +0 -0
  37. {parad-2.2.2 → parad-2.2.4}/parad/engine.py +0 -0
  38. {parad-2.2.2 → parad-2.2.4}/parad/gateway.py +0 -0
  39. {parad-2.2.2 → parad-2.2.4}/parad/state.py +0 -0
  40. {parad-2.2.2 → parad-2.2.4}/parad/watcher.py +0 -0
  41. {parad-2.2.2 → parad-2.2.4}/parad.egg-info/dependency_links.txt +0 -0
  42. {parad-2.2.2 → parad-2.2.4}/parad.egg-info/top_level.txt +0 -0
  43. {parad-2.2.2 → parad-2.2.4}/setup.cfg +0 -0
  44. {parad-2.2.2 → parad-2.2.4}/tests/test_crypto.py +0 -0
  45. {parad-2.2.2 → parad-2.2.4}/tests/test_engine.py +0 -0
  46. {parad-2.2.2 → parad-2.2.4}/tests/test_state.py +0 -0
  47. {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.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 an encrypted database
77
- parad init mydb
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 an encrypted database
53
- parad init mydb
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
- 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:
@@ -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
- if CONFIG_FILE.exists():
89
+ config_path = config_file()
90
+ if config_path.exists():
83
91
  try:
84
- user_config = json.loads(CONFIG_FILE.read_text())
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
- CONFIG_DIR.mkdir(parents=True, exist_ok=True)
105
- CONFIG_FILE.write_text(json.dumps(config.model_dump(), indent=2))
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 get_connection_url(name: str) -> str:
145
- """Get a connection URL for a database.
146
-
147
- Returns: parad://local/{name}?passphrase={passphrase}
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
- passphrase = get_passphrase()
150
- return f"parad://local/{name}?passphrase={passphrase}"
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) 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.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 an encrypted database
77
- parad init mydb
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
@@ -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.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()
@@ -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}")
@@ -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