parad 0.1.0__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-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: parad
3
+ Version: 0.1.0
4
+ Summary: Encrypted local-first SQLite with Telegram cloud sync
5
+ Author-email: nexuss0781 <nexuss0781@gmail.com>
6
+ Project-URL: Homepage, https://github.com/nexuss0781/Paradox-DB
7
+ Project-URL: Repository, https://github.com/nexuss0781/Paradox-DB
8
+ Project-URL: Issues, https://github.com/nexuss0781/Paradox-DB/issues
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Database
16
+ Classifier: Topic :: Security :: Cryptography
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: click>=8.0
20
+ Requires-Dist: httpx>=0.25
21
+ Requires-Dist: cryptography>=41.0
22
+ Requires-Dist: pydantic>=2.0
23
+
24
+ # parad
25
+
26
+ Encrypted local-first SQLite with Telegram cloud sync.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install parad
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```bash
37
+ # Create an encrypted database
38
+ parad init mydb
39
+
40
+ # Push to cloud
41
+ parad push
42
+
43
+ # Pull latest
44
+ parad pull
45
+
46
+ # Check status
47
+ parad status
48
+
49
+ # Interactive SQL
50
+ parad shell
51
+ ```
52
+
53
+ ## Commands
54
+
55
+ | Command | Description |
56
+ |---|---|
57
+ | `parad init <name>` | Create encrypted DB + register with gateway |
58
+ | `parad push` | Push database to Telegram cloud |
59
+ | `parad pull [version]` | Pull latest or specific version |
60
+ | `parad sync` | Push then pull |
61
+ | `parad status` | Local vs remote version |
62
+ | `parad versions` | List all remote versions |
63
+ | `parad rollback <ver>` | Rollback to previous version |
64
+ | `parad exec <sql>` | Run raw SQL |
65
+ | `parad insert <table> <json>` | Insert a row |
66
+ | `parad select <table> [where]` | Query rows |
67
+ | `parad update <table> <set> <where>` | Update rows |
68
+ | `parad delete <table> <where>` | Delete rows |
69
+ | `parad shell` | Interactive SQL REPL |
70
+ | `parad config show/set` | Manage config |
71
+
72
+ ## Configuration
73
+
74
+ Config lives at `~/.paradox/config.json`:
75
+
76
+ ```json
77
+ {
78
+ "database_path": "~/.paradox/data.db",
79
+ "sync": {
80
+ "gateway_url": "https://paradox-db.onrender.com/v1",
81
+ "api_key": "pk_..."
82
+ }
83
+ }
84
+ ```
85
+
86
+ ## Security
87
+
88
+ - AES-256-CBC encryption at rest
89
+ - PBKDF2-HMAC-SHA512 key derivation (256k iterations)
90
+ - Your passphrase never leaves your machine
parad-0.1.0/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # parad
2
+
3
+ Encrypted local-first SQLite with Telegram cloud sync.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install parad
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # Create an encrypted database
15
+ parad init mydb
16
+
17
+ # Push to cloud
18
+ parad push
19
+
20
+ # Pull latest
21
+ parad pull
22
+
23
+ # Check status
24
+ parad status
25
+
26
+ # Interactive SQL
27
+ parad shell
28
+ ```
29
+
30
+ ## Commands
31
+
32
+ | Command | Description |
33
+ |---|---|
34
+ | `parad init <name>` | Create encrypted DB + register with gateway |
35
+ | `parad push` | Push database to Telegram cloud |
36
+ | `parad pull [version]` | Pull latest or specific version |
37
+ | `parad sync` | Push then pull |
38
+ | `parad status` | Local vs remote version |
39
+ | `parad versions` | List all remote versions |
40
+ | `parad rollback <ver>` | Rollback to previous version |
41
+ | `parad exec <sql>` | Run raw SQL |
42
+ | `parad insert <table> <json>` | Insert a row |
43
+ | `parad select <table> [where]` | Query rows |
44
+ | `parad update <table> <set> <where>` | Update rows |
45
+ | `parad delete <table> <where>` | Delete rows |
46
+ | `parad shell` | Interactive SQL REPL |
47
+ | `parad config show/set` | Manage config |
48
+
49
+ ## Configuration
50
+
51
+ Config lives at `~/.paradox/config.json`:
52
+
53
+ ```json
54
+ {
55
+ "database_path": "~/.paradox/data.db",
56
+ "sync": {
57
+ "gateway_url": "https://paradox-db.onrender.com/v1",
58
+ "api_key": "pk_..."
59
+ }
60
+ }
61
+ ```
62
+
63
+ ## Security
64
+
65
+ - AES-256-CBC encryption at rest
66
+ - PBKDF2-HMAC-SHA512 key derivation (256k iterations)
67
+ - Your passphrase never leaves your machine
@@ -0,0 +1,3 @@
1
+ """parad — Encrypted local-first SQLite with Telegram cloud sync."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow running as `python -m parad`."""
2
+
3
+ from parad.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,34 @@
1
+ """parad CLI entry point."""
2
+
3
+ import os
4
+ import click
5
+
6
+ @click.group()
7
+ @click.version_option(package_name="parad")
8
+ def main():
9
+ """parad — Encrypted local-first SQLite with Telegram cloud sync."""
10
+ pass
11
+
12
+
13
+ # Import and register all commands
14
+ from parad.commands.init import init
15
+ from parad.commands.sync import push, pull, sync
16
+ from parad.commands.status import status, versions, rollback
17
+ from parad.commands.query import exec_cmd, insert, select, update, delete
18
+ from parad.commands.shell import shell
19
+ from parad.commands.config_cmd import config_group
20
+
21
+ main.add_command(init)
22
+ main.add_command(push)
23
+ main.add_command(pull)
24
+ main.add_command(sync)
25
+ main.add_command(status)
26
+ main.add_command(versions)
27
+ main.add_command(rollback)
28
+ main.add_command(exec_cmd, name="exec")
29
+ main.add_command(insert)
30
+ main.add_command(select)
31
+ main.add_command(update)
32
+ main.add_command(delete)
33
+ main.add_command(shell)
34
+ main.add_command(config_group)
File without changes
@@ -0,0 +1,27 @@
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}")
@@ -0,0 +1,48 @@
1
+ """parad init — Create a new encrypted database and register with gateway."""
2
+
3
+ import click
4
+ from pathlib import Path
5
+ from parad.config import load_config, save_config, CONFIG_DIR
6
+ from parad.engine import Engine
7
+ from parad.gateway import GatewayClient
8
+
9
+
10
+ @click.command()
11
+ @click.argument("name")
12
+ @click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
13
+ @click.option("--gateway", envvar="PARADOX_GATEWAY_URL", default=None)
14
+ def init(name: str, passphrase: str, gateway: str | None):
15
+ """Create a new encrypted database and register with gateway."""
16
+ config = load_config()
17
+
18
+ if gateway:
19
+ config.sync.gateway_url = gateway
20
+
21
+ db_path = CONFIG_DIR / f"{name}.db"
22
+ config.database_path = str(db_path)
23
+
24
+ # Create local encrypted database
25
+ engine = Engine(str(db_path), passphrase)
26
+ engine.open()
27
+ engine.create_tables()
28
+ engine.close()
29
+
30
+ click.echo(f"✓ Created encrypted database: {db_path}")
31
+
32
+ # Auto-register if no API key
33
+ if not config.sync.api_key:
34
+ gw = GatewayClient(config.sync.gateway_url)
35
+ try:
36
+ result = gw.register("0", config.sync.gateway_url)
37
+ config.sync.api_key = result.api_key
38
+ save_config(config)
39
+ click.echo(f"✓ Registered with gateway (user: {result.user_id})")
40
+ click.echo(f" API key: {result.api_key}")
41
+ except Exception as e:
42
+ click.echo(f"⚠ Gateway registration failed: {e}")
43
+ click.echo(" You can register later with: parad config set sync.api_key <key>")
44
+ else:
45
+ click.echo(" Gateway already configured")
46
+
47
+ save_config(config)
48
+ click.echo(f"\nDatabase ready at: {db_path}")
@@ -0,0 +1,79 @@
1
+ """parad exec/insert/select/update/delete — Local SQL operations."""
2
+
3
+ import json
4
+ import click
5
+ from parad.config import load_config
6
+ from parad.engine import Engine
7
+
8
+
9
+ @click.command("exec")
10
+ @click.argument("sql")
11
+ @click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
12
+ def exec_cmd(sql: str, passphrase: str):
13
+ """Execute raw SQL on the local database."""
14
+ config = load_config()
15
+ with Engine(config.database_path, passphrase) as engine:
16
+ rows = engine.execute(sql)
17
+ if rows:
18
+ for row in rows:
19
+ click.echo(json.dumps(dict(row), default=str))
20
+ else:
21
+ click.echo("OK")
22
+
23
+
24
+ @click.command("insert")
25
+ @click.argument("table")
26
+ @click.argument("data_json")
27
+ @click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
28
+ def insert(table: str, data_json: str, passphrase: str):
29
+ """Insert a row: parad insert <table> '{"col": "val"}'."""
30
+ config = load_config()
31
+ data = json.loads(data_json)
32
+ with Engine(config.database_path, passphrase) as engine:
33
+ rowid = engine.insert(table, data)
34
+ click.echo(f"Inserted rowid={rowid}")
35
+
36
+
37
+ @click.command("select")
38
+ @click.argument("table")
39
+ @click.argument("where_json", required=False)
40
+ @click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
41
+ def select(table: str, where_json: str | None, passphrase: str):
42
+ """Query rows: parad select <table> ['{"col": "val"}']."""
43
+ config = load_config()
44
+ where = json.loads(where_json) if where_json else None
45
+ with Engine(config.database_path, passphrase) as engine:
46
+ rows = engine.select(table, where)
47
+ if not rows:
48
+ click.echo("No rows found.")
49
+ return
50
+ for row in rows:
51
+ click.echo(json.dumps(dict(row), default=str))
52
+
53
+
54
+ @click.command("update")
55
+ @click.argument("table")
56
+ @click.argument("set_json")
57
+ @click.argument("where_json")
58
+ @click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
59
+ def update(table: str, set_json: str, where_json: str, passphrase: str):
60
+ """Update rows: parad update <table> '{"col":"val"}' '{"id":1}'."""
61
+ config = load_config()
62
+ set_data = json.loads(set_json)
63
+ where = json.loads(where_json)
64
+ with Engine(config.database_path, passphrase) as engine:
65
+ changes = engine.update(table, set_data, where)
66
+ click.echo(f"Updated {changes} row(s)")
67
+
68
+
69
+ @click.command("delete")
70
+ @click.argument("table")
71
+ @click.argument("where_json")
72
+ @click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
73
+ def delete(table: str, where_json: str, passphrase: str):
74
+ """Delete rows: parad delete <table> '{"id":1}'."""
75
+ config = load_config()
76
+ where = json.loads(where_json)
77
+ with Engine(config.database_path, passphrase) as engine:
78
+ changes = engine.delete(table, where)
79
+ click.echo(f"Deleted {changes} row(s)")
@@ -0,0 +1,40 @@
1
+ """parad shell — Interactive SQL REPL."""
2
+
3
+ import click
4
+ from parad.config import load_config
5
+ from parad.engine import Engine
6
+
7
+
8
+ @click.command("shell")
9
+ @click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
10
+ def shell(passphrase: str):
11
+ """Interactive SQL shell for the local database."""
12
+ config = load_config()
13
+
14
+ click.echo(f"parad shell — {config.database_path}")
15
+ click.echo("Type SQL commands, or 'quit' to exit.\n")
16
+
17
+ with Engine(config.database_path, passphrase) as engine:
18
+ while True:
19
+ try:
20
+ line = input("parad> ").strip()
21
+ except (EOFError, KeyboardInterrupt):
22
+ click.echo("\nBye.")
23
+ break
24
+
25
+ if not line:
26
+ continue
27
+ if line.lower() in ("quit", "exit", "\\q"):
28
+ click.echo("Bye.")
29
+ break
30
+
31
+ try:
32
+ rows = engine.execute(line)
33
+ if rows:
34
+ import json
35
+ for row in rows:
36
+ click.echo(json.dumps(dict(row), default=str))
37
+ else:
38
+ click.echo("OK")
39
+ except Exception as e:
40
+ click.echo(f"Error: {e}")
@@ -0,0 +1,91 @@
1
+ """parad status/versions/rollback — View sync status and manage versions."""
2
+
3
+ import click
4
+ from parad.config import load_config
5
+ from parad.gateway import GatewayClient, GatewayError
6
+
7
+
8
+ @click.command("status")
9
+ def status():
10
+ """Show local vs remote sync status."""
11
+ config = load_config()
12
+ gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
13
+
14
+ try:
15
+ result = gw.status()
16
+ except GatewayError as e:
17
+ click.echo(f"✗ Status check failed: {e}")
18
+ raise SystemExit(1)
19
+
20
+ click.echo(f"User: {result.user_id}\n")
21
+ if not result.databases:
22
+ click.echo("No databases found.")
23
+ return
24
+
25
+ for db in result.databases:
26
+ click.echo(f" {db.name}")
27
+ click.echo(f" Version: {db.latest_version}")
28
+ click.echo(f" Message ID: {db.latest_message_id}")
29
+ if db.file_hash:
30
+ click.echo(f" Hash: {db.file_hash[:16]}...")
31
+ if db.uploaded_at:
32
+ click.echo(f" Uploaded: {db.uploaded_at}")
33
+ click.echo()
34
+
35
+
36
+ @click.command("versions")
37
+ @click.option("--name", "-n", default=None, help="Database name")
38
+ def versions(name: str | None):
39
+ """List all remote versions."""
40
+ config = load_config()
41
+ if not name:
42
+ from pathlib import Path
43
+ name = Path(config.database_path).name
44
+
45
+ gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
46
+
47
+ try:
48
+ result = gw.versions(name)
49
+ except GatewayError as e:
50
+ click.echo(f"✗ Versions query failed: {e}")
51
+ raise SystemExit(1)
52
+
53
+ if not result.versions:
54
+ click.echo(f"No versions found for {name}.")
55
+ return
56
+
57
+ click.echo(f"Versions for {name}:\n")
58
+ for v in result.versions:
59
+ size_str = f" ({v.file_size} bytes)" if v.file_size else ""
60
+ type_str = f" [{v.version_type}]" if v.version_type else ""
61
+ click.echo(f" v{v.version}{type_str}{size_str} msg={v.message_id}")
62
+ if v.uploaded_at:
63
+ click.echo(f" {v.uploaded_at}")
64
+
65
+
66
+ @click.command("rollback")
67
+ @click.argument("version", type=int)
68
+ @click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
69
+ def rollback(version: int, passphrase: str):
70
+ """Rollback to a previous version."""
71
+ config = load_config()
72
+ from pathlib import Path
73
+ db_name = Path(config.database_path).name
74
+ db_path = Path(config.database_path).expanduser()
75
+
76
+ gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
77
+
78
+ try:
79
+ result = gw.rollback(db_name, version)
80
+ click.echo(f"✓ Rolled back to v{result.rolled_back_to} (new msg={result.new_message_id})")
81
+ except GatewayError as e:
82
+ click.echo(f"✗ Rollback failed: {e}")
83
+ raise SystemExit(1)
84
+
85
+ # Pull the rolled-back version
86
+ try:
87
+ file_bytes = gw.download(db_name)
88
+ db_path.write_bytes(file_bytes)
89
+ click.echo(f"✓ Pulled rolled-back version")
90
+ except GatewayError as e:
91
+ click.echo(f"⚠ Pull after rollback failed: {e}")
@@ -0,0 +1,98 @@
1
+ """parad push/pull/sync — Upload and download databases."""
2
+
3
+ import os
4
+ import click
5
+ from pathlib import Path
6
+ from parad.config import load_config, CONFIG_DIR
7
+ from parad.engine import Engine
8
+ from parad.gateway import GatewayClient, GatewayError
9
+
10
+
11
+ def _get_db_name(config) -> str:
12
+ return Path(config.database_path).name
13
+
14
+
15
+ @click.command("push")
16
+ @click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
17
+ def push(passphrase: str):
18
+ """Push the local database to the gateway."""
19
+ config = load_config()
20
+ db_path = Path(config.database_path).expanduser()
21
+
22
+ if not db_path.exists():
23
+ click.echo(f"✗ Database not found: {db_path}")
24
+ raise SystemExit(1)
25
+
26
+ engine = Engine(str(db_path), passphrase)
27
+ engine.open()
28
+ raw = engine.get_raw_bytes()
29
+ engine.close()
30
+
31
+ gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
32
+ db_name = _get_db_name(config)
33
+
34
+ try:
35
+ result = gw.upload(db_name, raw)
36
+ click.echo(f"✓ Pushed {db_name} v{result.version} (msg={result.message_id})")
37
+ except GatewayError as e:
38
+ click.echo(f"✗ Push failed: {e}")
39
+ raise SystemExit(1)
40
+
41
+
42
+ @click.command("pull")
43
+ @click.argument("version", required=False, type=int)
44
+ @click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
45
+ def pull(version: int | None, passphrase: str):
46
+ """Pull database from gateway. Optionally specify a version."""
47
+ config = load_config()
48
+ db_path = Path(config.database_path).expanduser()
49
+ db_name = _get_db_name(config)
50
+
51
+ gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
52
+
53
+ try:
54
+ file_bytes = gw.download(db_name, version)
55
+ except GatewayError as e:
56
+ click.echo(f"✗ Pull failed: {e}")
57
+ raise SystemExit(1)
58
+
59
+ db_path.parent.mkdir(parents=True, exist_ok=True)
60
+ db_path.write_bytes(file_bytes)
61
+
62
+ ver_str = f"v{version}" if version else "latest"
63
+ click.echo(f"✓ Pulled {db_name} {ver_str} ({len(file_bytes)} bytes)")
64
+
65
+
66
+ @click.command("sync")
67
+ @click.option("--passphrase", envvar="PARADOX_PASSPHRASE", default="default")
68
+ def sync(passphrase: str):
69
+ """Push local changes, then pull latest from gateway."""
70
+ config = load_config()
71
+ db_path = Path(config.database_path).expanduser()
72
+ db_name = _get_db_name(config)
73
+
74
+ if not db_path.exists():
75
+ click.echo(f"✗ Database not found: {db_path}")
76
+ raise SystemExit(1)
77
+
78
+ # Push
79
+ engine = Engine(str(db_path), passphrase)
80
+ engine.open()
81
+ raw = engine.get_raw_bytes()
82
+ engine.close()
83
+
84
+ gw = GatewayClient(config.sync.gateway_url, config.sync.api_key)
85
+
86
+ try:
87
+ result = gw.upload(db_name, raw)
88
+ click.echo(f"✓ Pushed v{result.version}")
89
+ except GatewayError as e:
90
+ click.echo(f"⚠ Push failed: {e}")
91
+
92
+ # Pull
93
+ try:
94
+ file_bytes = gw.download(db_name)
95
+ db_path.write_bytes(file_bytes)
96
+ click.echo(f"✓ Pulled latest ({len(file_bytes)} bytes)")
97
+ except GatewayError as e:
98
+ click.echo(f"⚠ Pull failed: {e}")
@@ -0,0 +1,81 @@
1
+ """parad configuration management."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from parad.types import Config
6
+
7
+
8
+ CONFIG_DIR = Path.home() / ".paradox"
9
+ CONFIG_FILE = CONFIG_DIR / "config.json"
10
+
11
+ DEFAULT_CONFIG = {
12
+ "database_path": "~/.paradox/data.db",
13
+ "encryption": {
14
+ "cipher": "aes-256-cbc",
15
+ "kdf_iterations": 256000,
16
+ "page_size": 4096,
17
+ },
18
+ "sync": {
19
+ "gateway_url": "https://paradox-db.onrender.com/v1",
20
+ "api_key": "",
21
+ "trigger_timer_seconds": 30,
22
+ "trigger_ops_threshold": 50,
23
+ "max_file_size_mb": 50,
24
+ "auto_sync_on_shutdown": True,
25
+ },
26
+ "conflict": {
27
+ "strategy": "last-write-wins",
28
+ "log_conflicts": True,
29
+ },
30
+ "logging": {
31
+ "level": "info",
32
+ "path": "~/.paradox/logs",
33
+ },
34
+ }
35
+
36
+
37
+ def _deep_merge(base: dict, override: dict) -> dict:
38
+ """Deep merge override into base."""
39
+ result = base.copy()
40
+ for k, v in override.items():
41
+ if k in result and isinstance(result[k], dict) and isinstance(v, dict):
42
+ result[k] = _deep_merge(result[k], v)
43
+ else:
44
+ result[k] = v
45
+ return result
46
+
47
+
48
+ def load_config() -> Config:
49
+ """Load config from ~/.paradox/config.json, merged with defaults."""
50
+ user_config = {}
51
+ if CONFIG_FILE.exists():
52
+ try:
53
+ user_config = json.loads(CONFIG_FILE.read_text())
54
+ except (json.JSONDecodeError, OSError):
55
+ pass
56
+ merged = _deep_merge(DEFAULT_CONFIG, user_config)
57
+ return Config(**merged)
58
+
59
+
60
+ def save_config(config: Config):
61
+ """Save config to ~/.paradox/config.json."""
62
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
63
+ CONFIG_FILE.write_text(json.dumps(config.model_dump(), indent=2))
64
+
65
+
66
+ def set_config_value(key: str, value: str):
67
+ """Set a single config value using dot notation (e.g. sync.api_key)."""
68
+ config = load_config()
69
+ keys = key.split(".")
70
+ d = config.model_dump()
71
+ current = d
72
+ for k in keys[:-1]:
73
+ current = current[k]
74
+ # Try to parse as appropriate type
75
+ if value.lower() in ("true", "false"):
76
+ current[keys[-1]] = value.lower() == "true"
77
+ elif value.isdigit():
78
+ current[keys[-1]] = int(value)
79
+ else:
80
+ current[keys[-1]] = value
81
+ save_config(Config(**d))
@@ -0,0 +1,49 @@
1
+ """AES-256-CBC file encryption for parad local database."""
2
+
3
+ import os
4
+ import hashlib
5
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
6
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
7
+ from cryptography.hazmat.primitives import hashes
8
+
9
+ SALT = b"paradox-salt"
10
+ KDF_ITERATIONS = 256_000
11
+ KEY_LENGTH = 32 # 256 bits
12
+ IV_LENGTH = 16 # 128 bits
13
+
14
+
15
+ def derive_key(passphrase: str) -> bytes:
16
+ """Derive a 256-bit key from passphrase using PBKDF2-HMAC-SHA512."""
17
+ kdf = PBKDF2HMAC(
18
+ algorithm=hashes.SHA512(),
19
+ length=KEY_LENGTH,
20
+ salt=SALT,
21
+ iterations=KDF_ITERATIONS,
22
+ )
23
+ return kdf.derive(passphrase.encode("utf-8"))
24
+
25
+
26
+ def encrypt_file(data: bytes, passphrase: str) -> bytes:
27
+ """Encrypt bytes with AES-256-CBC. Returns IV + ciphertext."""
28
+ key = derive_key(passphrase)
29
+ iv = os.urandom(IV_LENGTH)
30
+ cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
31
+ encryptor = cipher.encryptor()
32
+ # PKCS7 padding
33
+ pad_len = 16 - (len(data) % 16)
34
+ padded = data + bytes([pad_len] * pad_len)
35
+ ciphertext = encryptor.update(padded) + encryptor.finalize()
36
+ return iv + ciphertext
37
+
38
+
39
+ def decrypt_file(data: bytes, passphrase: str) -> bytes:
40
+ """Decrypt AES-256-CBC data (IV + ciphertext). Returns original bytes."""
41
+ key = derive_key(passphrase)
42
+ iv = data[:IV_LENGTH]
43
+ ciphertext = data[IV_LENGTH:]
44
+ cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
45
+ decryptor = cipher.decryptor()
46
+ padded = decryptor.update(ciphertext) + decryptor.finalize()
47
+ # Remove PKCS7 padding
48
+ pad_len = padded[-1]
49
+ return padded[:-pad_len]
@@ -0,0 +1,143 @@
1
+ """Local encrypted SQLite engine for parad."""
2
+
3
+ import os
4
+ import sqlite3
5
+ import tempfile
6
+ from pathlib import Path
7
+ from parad.crypto import encrypt_file, decrypt_file
8
+
9
+
10
+ class Engine:
11
+ """Encrypted SQLite database engine.
12
+
13
+ Decrypts the file to a temp location, operates on it,
14
+ then re-encrypts and writes back.
15
+ """
16
+
17
+ def __init__(self, db_path: str, passphrase: str):
18
+ self.db_path = Path(db_path).expanduser()
19
+ self.passphrase = passphrase
20
+ self._conn: sqlite3.Connection | None = None
21
+ self._tmp_path: str | None = None
22
+
23
+ def _decrypt_to_temp(self) -> str:
24
+ """Decrypt DB to a temp file, return path."""
25
+ if not self.db_path.exists():
26
+ raise FileNotFoundError(f"Database not found: {self.db_path}")
27
+ encrypted = self.db_path.read_bytes()
28
+ decrypted = decrypt_file(encrypted, self.passphrase)
29
+ tmp = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
30
+ tmp.write(decrypted)
31
+ tmp.close()
32
+ self._tmp_path = tmp.name
33
+ return self._tmp_path
34
+
35
+ def _encrypt_from_temp(self):
36
+ """Encrypt temp file back to db_path."""
37
+ if not self._tmp_path:
38
+ return
39
+ decrypted = Path(self._tmp_path).read_bytes()
40
+ encrypted = encrypt_file(decrypted, self.passphrase)
41
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
42
+ self.db_path.write_bytes(encrypted)
43
+
44
+ def open(self) -> sqlite3.Connection:
45
+ """Open the encrypted database."""
46
+ tmp_path = self._decrypt_to_temp()
47
+ self._conn = sqlite3.connect(tmp_path)
48
+ self._conn.row_factory = sqlite3.Row
49
+ return self._conn
50
+
51
+ def close(self):
52
+ """Close and re-encrypt."""
53
+ if self._conn:
54
+ self._conn.close()
55
+ self._conn = None
56
+ self._encrypt_from_temp()
57
+ if self._tmp_path:
58
+ os.unlink(self._tmp_path)
59
+ self._tmp_path = None
60
+
61
+ def __enter__(self):
62
+ self.open()
63
+ return self
64
+
65
+ def __exit__(self, *args):
66
+ self.close()
67
+
68
+ def execute(self, sql: str, params: tuple = ()) -> list[dict]:
69
+ """Execute SQL and return rows as dicts."""
70
+ if not self._conn:
71
+ raise RuntimeError("Database not open")
72
+ cursor = self._conn.execute(sql, params)
73
+ if cursor.description:
74
+ return [dict(row) for row in cursor.fetchall()]
75
+ self._conn.commit()
76
+ return []
77
+
78
+ def insert(self, table: str, data: dict) -> int:
79
+ """Insert a row, return rowid."""
80
+ cols = ", ".join(data.keys())
81
+ placeholders = ", ".join(["?"] * len(data))
82
+ sql = f"INSERT INTO {table} ({cols}) VALUES ({placeholders})"
83
+ cursor = self._conn.execute(sql, tuple(data.values()))
84
+ self._conn.commit()
85
+ return cursor.lastrowid
86
+
87
+ def update(self, table: str, set_data: dict, where: dict) -> int:
88
+ """Update rows, return changes count."""
89
+ set_clause = ", ".join(f"{k} = ?" for k in set_data)
90
+ where_clause = " AND ".join(f"{k} = ?" for k in where)
91
+ sql = f"UPDATE {table} SET {set_clause} WHERE {where_clause}"
92
+ params = tuple(set_data.values()) + tuple(where.values())
93
+ cursor = self._conn.execute(sql, params)
94
+ self._conn.commit()
95
+ return cursor.rowcount
96
+
97
+ def delete(self, table: str, where: dict) -> int:
98
+ """Delete rows, return changes count."""
99
+ where_clause = " AND ".join(f"{k} = ?" for k in where)
100
+ sql = f"DELETE FROM {table} WHERE {where_clause}"
101
+ cursor = self._conn.execute(sql, tuple(where.values()))
102
+ self._conn.commit()
103
+ return cursor.rowcount
104
+
105
+ def select(self, table: str, where: dict | None = None) -> list[dict]:
106
+ """Query rows."""
107
+ sql = f"SELECT * FROM {table}"
108
+ params = ()
109
+ if where:
110
+ where_clause = " AND ".join(f"{k} = ?" for k in where)
111
+ sql += f" WHERE {where_clause}"
112
+ params = tuple(where.values())
113
+ return self.execute(sql, params)
114
+
115
+ def get_raw_bytes(self) -> bytes:
116
+ """Get the encrypted database as raw bytes (for push)."""
117
+ if self._tmp_path:
118
+ return Path(self._tmp_path).read_bytes()
119
+ if self.db_path.exists():
120
+ encrypted = self.db_path.read_bytes()
121
+ return decrypt_file(encrypted, self.passphrase)
122
+ raise FileNotFoundError(f"Database not found: {self.db_path}")
123
+
124
+ def create_tables(self):
125
+ """Create default tables for a new database."""
126
+ self._conn.execute("""
127
+ CREATE TABLE IF NOT EXISTS _meta (
128
+ key TEXT PRIMARY KEY,
129
+ value TEXT
130
+ )
131
+ """)
132
+ self._conn.commit()
133
+
134
+ def list_tables(self) -> list[str]:
135
+ """List all user tables."""
136
+ rows = self.execute(
137
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '_%' ORDER BY name"
138
+ )
139
+ return [r["name"] for r in rows]
140
+
141
+ def table_info(self, table: str) -> list[dict]:
142
+ """Get column info for a table."""
143
+ return self.execute(f"PRAGMA table_info({table})")
@@ -0,0 +1,127 @@
1
+ """HTTP client for the Paradox-DB Gateway REST API."""
2
+
3
+ import base64
4
+ from pathlib import Path
5
+ from parad.types import (
6
+ RegisterResponse,
7
+ UploadResponse,
8
+ StatusResponse,
9
+ VersionsResponse,
10
+ RollbackResponse,
11
+ )
12
+
13
+
14
+ class GatewayError(Exception):
15
+ """Gateway API error."""
16
+ def __init__(self, status_code: int, detail: str):
17
+ self.status_code = status_code
18
+ self.detail = detail
19
+ super().__init__(f"HTTP {status_code}: {detail}")
20
+
21
+
22
+ class GatewayClient:
23
+ """Client for the Paradox-DB Gateway REST API."""
24
+
25
+ def __init__(self, gateway_url: str, api_key: str = ""):
26
+ self.gateway_url = gateway_url.rstrip("/")
27
+ self.api_key = api_key
28
+
29
+ def _headers(self) -> dict:
30
+ h = {"Content-Type": "application/json"}
31
+ if self.api_key:
32
+ h["X-API-Key"] = self.api_key
33
+ return h
34
+
35
+ def register(self, channel_id: str, bot_token_id: str = "") -> RegisterResponse:
36
+ """Register a new user with the gateway."""
37
+ import httpx
38
+ resp = httpx.post(
39
+ f"{self.gateway_url}/auth/register",
40
+ json={"channel_id": channel_id, "bot_token_id": bot_token_id},
41
+ headers=self._headers(),
42
+ timeout=30,
43
+ )
44
+ if resp.status_code != 200:
45
+ raise GatewayError(resp.status_code, resp.text)
46
+ data = resp.json()
47
+ self.api_key = data.get("api_key", self.api_key)
48
+ return RegisterResponse(**data)
49
+
50
+ def upload(
51
+ self,
52
+ database_name: str,
53
+ file_bytes: bytes,
54
+ version: int = 0,
55
+ version_type: str = "full",
56
+ ) -> UploadResponse:
57
+ """Upload a database file to the gateway."""
58
+ import httpx
59
+ file_b64 = base64.b64encode(file_bytes).decode()
60
+ resp = httpx.post(
61
+ f"{self.gateway_url}/upload",
62
+ json={
63
+ "database_name": database_name,
64
+ "file_data": file_b64,
65
+ "version_type": version_type,
66
+ "version": version,
67
+ },
68
+ headers=self._headers(),
69
+ timeout=120,
70
+ )
71
+ if resp.status_code != 200:
72
+ raise GatewayError(resp.status_code, resp.text)
73
+ return UploadResponse(**resp.json())
74
+
75
+ def download(self, database_name: str, version: int | None = None) -> bytes:
76
+ """Download a database file from the gateway."""
77
+ import httpx
78
+ params = {"database_name": database_name}
79
+ if version is not None:
80
+ params["version"] = version
81
+ resp = httpx.get(
82
+ f"{self.gateway_url}/download",
83
+ params=params,
84
+ headers=self._headers(),
85
+ timeout=120,
86
+ )
87
+ if resp.status_code != 200:
88
+ raise GatewayError(resp.status_code, resp.text)
89
+ return resp.content
90
+
91
+ def status(self) -> StatusResponse:
92
+ """Get sync status from the gateway."""
93
+ import httpx
94
+ resp = httpx.get(
95
+ f"{self.gateway_url}/status",
96
+ headers=self._headers(),
97
+ timeout=30,
98
+ )
99
+ if resp.status_code != 200:
100
+ raise GatewayError(resp.status_code, resp.text)
101
+ return StatusResponse(**resp.json())
102
+
103
+ def versions(self, database_name: str) -> VersionsResponse:
104
+ """List all versions for a database."""
105
+ import httpx
106
+ resp = httpx.get(
107
+ f"{self.gateway_url}/versions",
108
+ params={"database_name": database_name},
109
+ headers=self._headers(),
110
+ timeout=30,
111
+ )
112
+ if resp.status_code != 200:
113
+ raise GatewayError(resp.status_code, resp.text)
114
+ return VersionsResponse(**resp.json())
115
+
116
+ def rollback(self, database_name: str, target_version: int) -> RollbackResponse:
117
+ """Rollback a database to a target version."""
118
+ import httpx
119
+ resp = httpx.post(
120
+ f"{self.gateway_url}/rollback",
121
+ json={"database_name": database_name, "target_version": target_version},
122
+ headers=self._headers(),
123
+ timeout=120,
124
+ )
125
+ if resp.status_code != 200:
126
+ raise GatewayError(resp.status_code, resp.text)
127
+ return RollbackResponse(**resp.json())
@@ -0,0 +1,85 @@
1
+ """Pydantic models for parad types."""
2
+
3
+ from datetime import datetime
4
+ from pydantic import BaseModel
5
+
6
+
7
+ # ── Config ──────────────────────────────────────────────────────
8
+
9
+ class EncryptionConfig(BaseModel):
10
+ cipher: str = "aes-256-cbc"
11
+ kdf_iterations: int = 256000
12
+ page_size: int = 4096
13
+
14
+
15
+ class SyncConfig(BaseModel):
16
+ gateway_url: str = "https://paradox-db.onrender.com/v1"
17
+ api_key: str = ""
18
+ trigger_timer_seconds: int = 30
19
+ trigger_ops_threshold: int = 50
20
+ max_file_size_mb: int = 50
21
+ auto_sync_on_shutdown: bool = True
22
+
23
+
24
+ class ConflictConfig(BaseModel):
25
+ strategy: str = "last-write-wins"
26
+ log_conflicts: bool = True
27
+
28
+
29
+ class LoggingConfig(BaseModel):
30
+ level: str = "info"
31
+ path: str = "~/.paradox/logs"
32
+
33
+
34
+ class Config(BaseModel):
35
+ database_path: str = "~/.paradox/data.db"
36
+ encryption: EncryptionConfig = EncryptionConfig()
37
+ sync: SyncConfig = SyncConfig()
38
+ conflict: ConflictConfig = ConflictConfig()
39
+ logging: LoggingConfig = LoggingConfig()
40
+
41
+
42
+ # ── API Responses ───────────────────────────────────────────────
43
+
44
+ class RegisterResponse(BaseModel):
45
+ user_id: str
46
+ api_key: str
47
+ jwt: str
48
+
49
+
50
+ class UploadResponse(BaseModel):
51
+ request_id: str
52
+ message_id: str
53
+ version: int
54
+ uploaded_at: str
55
+
56
+
57
+ class DatabaseStatus(BaseModel):
58
+ name: str
59
+ latest_version: int
60
+ latest_message_id: str
61
+ file_hash: str | None = None
62
+ uploaded_at: str | None = None
63
+
64
+
65
+ class StatusResponse(BaseModel):
66
+ user_id: str
67
+ databases: list[DatabaseStatus] = []
68
+
69
+
70
+ class VersionEntry(BaseModel):
71
+ version: int
72
+ message_id: str
73
+ file_hash: str | None = None
74
+ file_size: int | None = None
75
+ version_type: str | None = None
76
+ uploaded_at: str | None = None
77
+
78
+
79
+ class VersionsResponse(BaseModel):
80
+ versions: list[VersionEntry] = []
81
+
82
+
83
+ class RollbackResponse(BaseModel):
84
+ rolled_back_to: int
85
+ new_message_id: str
@@ -0,0 +1,90 @@
1
+ Metadata-Version: 2.4
2
+ Name: parad
3
+ Version: 0.1.0
4
+ Summary: Encrypted local-first SQLite with Telegram cloud sync
5
+ Author-email: nexuss0781 <nexuss0781@gmail.com>
6
+ Project-URL: Homepage, https://github.com/nexuss0781/Paradox-DB
7
+ Project-URL: Repository, https://github.com/nexuss0781/Paradox-DB
8
+ Project-URL: Issues, https://github.com/nexuss0781/Paradox-DB/issues
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Database
16
+ Classifier: Topic :: Security :: Cryptography
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: click>=8.0
20
+ Requires-Dist: httpx>=0.25
21
+ Requires-Dist: cryptography>=41.0
22
+ Requires-Dist: pydantic>=2.0
23
+
24
+ # parad
25
+
26
+ Encrypted local-first SQLite with Telegram cloud sync.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install parad
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ ```bash
37
+ # Create an encrypted database
38
+ parad init mydb
39
+
40
+ # Push to cloud
41
+ parad push
42
+
43
+ # Pull latest
44
+ parad pull
45
+
46
+ # Check status
47
+ parad status
48
+
49
+ # Interactive SQL
50
+ parad shell
51
+ ```
52
+
53
+ ## Commands
54
+
55
+ | Command | Description |
56
+ |---|---|
57
+ | `parad init <name>` | Create encrypted DB + register with gateway |
58
+ | `parad push` | Push database to Telegram cloud |
59
+ | `parad pull [version]` | Pull latest or specific version |
60
+ | `parad sync` | Push then pull |
61
+ | `parad status` | Local vs remote version |
62
+ | `parad versions` | List all remote versions |
63
+ | `parad rollback <ver>` | Rollback to previous version |
64
+ | `parad exec <sql>` | Run raw SQL |
65
+ | `parad insert <table> <json>` | Insert a row |
66
+ | `parad select <table> [where]` | Query rows |
67
+ | `parad update <table> <set> <where>` | Update rows |
68
+ | `parad delete <table> <where>` | Delete rows |
69
+ | `parad shell` | Interactive SQL REPL |
70
+ | `parad config show/set` | Manage config |
71
+
72
+ ## Configuration
73
+
74
+ Config lives at `~/.paradox/config.json`:
75
+
76
+ ```json
77
+ {
78
+ "database_path": "~/.paradox/data.db",
79
+ "sync": {
80
+ "gateway_url": "https://paradox-db.onrender.com/v1",
81
+ "api_key": "pk_..."
82
+ }
83
+ }
84
+ ```
85
+
86
+ ## Security
87
+
88
+ - AES-256-CBC encryption at rest
89
+ - PBKDF2-HMAC-SHA512 key derivation (256k iterations)
90
+ - Your passphrase never leaves your machine
@@ -0,0 +1,23 @@
1
+ README.md
2
+ pyproject.toml
3
+ parad/__init__.py
4
+ parad/__main__.py
5
+ parad/cli.py
6
+ parad/config.py
7
+ parad/crypto.py
8
+ parad/engine.py
9
+ parad/gateway.py
10
+ parad/types.py
11
+ parad.egg-info/PKG-INFO
12
+ parad.egg-info/SOURCES.txt
13
+ parad.egg-info/dependency_links.txt
14
+ parad.egg-info/entry_points.txt
15
+ parad.egg-info/requires.txt
16
+ parad.egg-info/top_level.txt
17
+ parad/commands/__init__.py
18
+ parad/commands/config_cmd.py
19
+ parad/commands/init.py
20
+ parad/commands/query.py
21
+ parad/commands/shell.py
22
+ parad/commands/status.py
23
+ parad/commands/sync.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ parad = parad.cli:main
@@ -0,0 +1,4 @@
1
+ click>=8.0
2
+ httpx>=0.25
3
+ cryptography>=41.0
4
+ pydantic>=2.0
@@ -0,0 +1 @@
1
+ parad
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "parad"
7
+ version = "0.1.0"
8
+ description = "Encrypted local-first SQLite with Telegram cloud sync"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [
12
+ { name = "nexuss0781", email = "nexuss0781@gmail.com" },
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Topic :: Database",
22
+ "Topic :: Security :: Cryptography",
23
+ ]
24
+ dependencies = [
25
+ "click>=8.0",
26
+ "httpx>=0.25",
27
+ "cryptography>=41.0",
28
+ "pydantic>=2.0",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/nexuss0781/Paradox-DB"
33
+ Repository = "https://github.com/nexuss0781/Paradox-DB"
34
+ Issues = "https://github.com/nexuss0781/Paradox-DB/issues"
35
+
36
+ [project.scripts]
37
+ parad = "parad.cli:main"
38
+
39
+ [tool.setuptools.packages.find]
40
+ include = ["parad*"]
parad-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+