sitrtech 1.0.0__py3-none-any.whl
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.
- sitrtech/__init__.py +3 -0
- sitrtech/cli.py +492 -0
- sitrtech-1.0.0.dist-info/METADATA +170 -0
- sitrtech-1.0.0.dist-info/RECORD +7 -0
- sitrtech-1.0.0.dist-info/WHEEL +5 -0
- sitrtech-1.0.0.dist-info/entry_points.txt +3 -0
- sitrtech-1.0.0.dist-info/top_level.txt +1 -0
sitrtech/__init__.py
ADDED
sitrtech/cli.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sitrtech — Python code encryption CLI
|
|
3
|
+
Encrypt Odoo, Django, Flask, FastAPI and Tornado projects in one command.
|
|
4
|
+
|
|
5
|
+
Usage examples
|
|
6
|
+
--------------
|
|
7
|
+
sitr encrypt my_module.zip --framework odoo --secret sk-...
|
|
8
|
+
sitr encrypt my_app.zip --framework django --secret sk-...
|
|
9
|
+
sitr balance --secret sk-...
|
|
10
|
+
sitr encrypt my_module.zip --framework odoo --expiry 2026-12-31 --network 10.0.0.0/24 --secret sk-...
|
|
11
|
+
|
|
12
|
+
Credentials can also be supplied via env vars:
|
|
13
|
+
SITR_SECRET — API secret key
|
|
14
|
+
SITR_BASE — override the API base URL (default: https://sitrtech.com)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
import hashlib
|
|
19
|
+
import io
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import zipfile
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
import click
|
|
28
|
+
import requests
|
|
29
|
+
from rich.console import Console
|
|
30
|
+
from rich.progress import Progress, SpinnerColumn, BarColumn, TextColumn, TimeElapsedColumn
|
|
31
|
+
from rich.table import Table
|
|
32
|
+
from rich import print as rprint
|
|
33
|
+
|
|
34
|
+
console = Console()
|
|
35
|
+
|
|
36
|
+
API_BASE_DEFAULT = "https://sitrtech.com"
|
|
37
|
+
|
|
38
|
+
FRAMEWORK_HINTS = {
|
|
39
|
+
"odoo": "Requires __manifest__.py at the project root",
|
|
40
|
+
"django": "Requires manage.py at the project root",
|
|
41
|
+
"flask": "Requires app.py / wsgi.py / application.py at the root",
|
|
42
|
+
"fastapi": "Requires main.py / asgi.py at the project root",
|
|
43
|
+
"tornado": "Requires main.py / server.py at the project root",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
# Files that must always be included in a partial upload so the server can
|
|
47
|
+
# locate the project root and framework entry point.
|
|
48
|
+
_STRUCTURAL_FILENAMES = {
|
|
49
|
+
"__manifest__.py", "__openerp__.py", "__init__.py",
|
|
50
|
+
"manage.py", "app.py", "application.py", "wsgi.py",
|
|
51
|
+
"main.py", "asgi.py", "server.py",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
MANIFEST_SUFFIX = ".sitr_manifest.json"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ─────────────────────────────────────────────
|
|
58
|
+
# Incremental-encryption helpers
|
|
59
|
+
# ─────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
def _sha256_bytes(data: bytes) -> str:
|
|
62
|
+
return hashlib.sha256(data).hexdigest()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _get_source_checksums(zip_path: Path) -> dict[str, str]:
|
|
66
|
+
"""Return {zip_entry_name: sha256} for every .py file inside the ZIP."""
|
|
67
|
+
checksums: dict[str, str] = {}
|
|
68
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
69
|
+
for name in zf.namelist():
|
|
70
|
+
if name.endswith(".py") and not name.endswith("/"):
|
|
71
|
+
checksums[name] = _sha256_bytes(zf.read(name))
|
|
72
|
+
return checksums
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_partial_zip(source_zip: Path, changed_files: set[str]) -> bytes:
|
|
76
|
+
"""
|
|
77
|
+
Build an in-memory ZIP containing only:
|
|
78
|
+
• every file listed in *changed_files*
|
|
79
|
+
• structural files (framework entry-points, __init__.py …) so the server
|
|
80
|
+
can locate the project root even in a partial upload
|
|
81
|
+
• all non-.py assets (views, static, data …) that were originally present
|
|
82
|
+
— they are small and the server needs them to reconstruct paths
|
|
83
|
+
"""
|
|
84
|
+
buf = io.BytesIO()
|
|
85
|
+
with zipfile.ZipFile(source_zip, "r") as src:
|
|
86
|
+
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as dst:
|
|
87
|
+
for name in src.namelist():
|
|
88
|
+
if name.endswith("/"):
|
|
89
|
+
continue
|
|
90
|
+
basename = name.split("/")[-1]
|
|
91
|
+
is_py = name.endswith(".py")
|
|
92
|
+
is_changed = name in changed_files
|
|
93
|
+
is_structural = basename in _STRUCTURAL_FILENAMES
|
|
94
|
+
is_asset = not is_py # non-Python files (XML, CSV, JS …)
|
|
95
|
+
if is_changed or is_structural or is_asset:
|
|
96
|
+
dst.writestr(name, src.read(name))
|
|
97
|
+
buf.seek(0)
|
|
98
|
+
return buf.read()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _merge_encrypted_zips(
|
|
102
|
+
previous_zip: Path,
|
|
103
|
+
new_encrypted_bytes: bytes,
|
|
104
|
+
output: Path,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""
|
|
107
|
+
Merge *new_encrypted_bytes* (partial encrypted ZIP from server) into
|
|
108
|
+
*previous_zip* and write the result to *output*.
|
|
109
|
+
|
|
110
|
+
Strategy:
|
|
111
|
+
• Copy every entry from *previous_zip*.
|
|
112
|
+
• If an entry's name also appears in the new ZIP, replace it with the
|
|
113
|
+
fresh encrypted version.
|
|
114
|
+
• Append any brand-new entries that only exist in the new ZIP.
|
|
115
|
+
"""
|
|
116
|
+
new_entries: dict[str, bytes] = {}
|
|
117
|
+
with zipfile.ZipFile(io.BytesIO(new_encrypted_bytes), "r") as nz:
|
|
118
|
+
for item in nz.infolist():
|
|
119
|
+
if not item.filename.endswith("/"):
|
|
120
|
+
new_entries[item.filename] = nz.read(item.filename)
|
|
121
|
+
|
|
122
|
+
merged = io.BytesIO()
|
|
123
|
+
with zipfile.ZipFile(previous_zip, "r") as pz:
|
|
124
|
+
with zipfile.ZipFile(merged, "w", compression=zipfile.ZIP_DEFLATED) as out:
|
|
125
|
+
for item in pz.infolist():
|
|
126
|
+
if item.filename.endswith("/"):
|
|
127
|
+
continue
|
|
128
|
+
if item.filename in new_entries:
|
|
129
|
+
# Replace with freshly encrypted version
|
|
130
|
+
out.writestr(item.filename, new_entries.pop(item.filename))
|
|
131
|
+
else:
|
|
132
|
+
out.writestr(item.filename, pz.read(item.filename))
|
|
133
|
+
# Append files that are new in this increment
|
|
134
|
+
for name, data in new_entries.items():
|
|
135
|
+
out.writestr(name, data)
|
|
136
|
+
|
|
137
|
+
output.write_bytes(merged.getvalue())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _manifest_path_for(output: Path) -> Path:
|
|
141
|
+
return output.parent / (output.stem + MANIFEST_SUFFIX)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _save_manifest(output: Path, framework: str, checksums: dict[str, str]) -> Path:
|
|
145
|
+
mp = _manifest_path_for(output)
|
|
146
|
+
mp.write_text(
|
|
147
|
+
json.dumps({"version": "1.0", "framework": framework, "files": checksums}, indent=2),
|
|
148
|
+
encoding="utf-8",
|
|
149
|
+
)
|
|
150
|
+
return mp
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _load_manifest(output: Path) -> dict | None:
|
|
154
|
+
mp = _manifest_path_for(output)
|
|
155
|
+
if mp.exists():
|
|
156
|
+
try:
|
|
157
|
+
return json.loads(mp.read_text(encoding="utf-8"))
|
|
158
|
+
except Exception:
|
|
159
|
+
return None
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _resolve_base(ctx_obj: dict) -> str:
|
|
164
|
+
return (
|
|
165
|
+
ctx_obj.get("base")
|
|
166
|
+
or os.getenv("SITR_BASE", "")
|
|
167
|
+
or API_BASE_DEFAULT
|
|
168
|
+
).rstrip("/")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _resolve_secret(ctx_obj: dict, secret: Optional[str]) -> str:
|
|
172
|
+
s = secret or ctx_obj.get("secret") or os.getenv("SITR_SECRET", "")
|
|
173
|
+
if not s:
|
|
174
|
+
console.print("[red]Error:[/] No API secret provided. "
|
|
175
|
+
"Use --secret or set the SITR_SECRET environment variable.")
|
|
176
|
+
sys.exit(2)
|
|
177
|
+
return s
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# ─────────────────────────────────────────────
|
|
181
|
+
# Root group
|
|
182
|
+
# ─────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
@click.group(invoke_without_command=False)
|
|
185
|
+
@click.option("--secret", "-s", envvar="SITR_SECRET", help="Your SitrTech API secret key")
|
|
186
|
+
@click.option("--base", "-b", envvar="SITR_BASE", default=None, help="API base URL")
|
|
187
|
+
@click.pass_context
|
|
188
|
+
def main(ctx: click.Context, secret: Optional[str], base: Optional[str]):
|
|
189
|
+
"""
|
|
190
|
+
\b
|
|
191
|
+
SitrTech code encryption CLI
|
|
192
|
+
Docs: https://sitrtech.com/docs
|
|
193
|
+
"""
|
|
194
|
+
ctx.ensure_object(dict)
|
|
195
|
+
ctx.obj["secret"] = secret
|
|
196
|
+
ctx.obj["base"] = base
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ─────────────────────────────────────────────
|
|
200
|
+
# encrypt command
|
|
201
|
+
# ─────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
@main.command()
|
|
204
|
+
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
205
|
+
@click.option("--framework", "-f", default="odoo",
|
|
206
|
+
type=click.Choice(["odoo", "django", "flask", "fastapi", "tornado"],
|
|
207
|
+
case_sensitive=False),
|
|
208
|
+
show_default=True,
|
|
209
|
+
help="Python framework of the project")
|
|
210
|
+
@click.option("--version", "-v", default="", help="Framework version (e.g. 16.0 for Odoo)")
|
|
211
|
+
@click.option("--expiry", "-e", default=None,
|
|
212
|
+
metavar="DATE",
|
|
213
|
+
help="Expiry date/time (e.g. 2026-12-31T23:59:00Z)")
|
|
214
|
+
@click.option("--network", "-n", multiple=True,
|
|
215
|
+
metavar="CIDR",
|
|
216
|
+
help="Allowed IP/CIDR (may repeat: --network 10.0.0.1 --network 10.0.1.0/24)")
|
|
217
|
+
@click.option("--output", "-o", default=None,
|
|
218
|
+
type=click.Path(path_type=Path),
|
|
219
|
+
help="Output path for the encrypted ZIP (default: <input>_encrypted.zip)")
|
|
220
|
+
@click.option("--incremental", "-i", default=None,
|
|
221
|
+
type=click.Path(path_type=Path),
|
|
222
|
+
metavar="PREV_ZIP",
|
|
223
|
+
help="Previous encrypted ZIP to merge changed files into (auto-detected when a "
|
|
224
|
+
"manifest exists next to the output file)")
|
|
225
|
+
@click.option("--full", is_flag=True, default=False,
|
|
226
|
+
help="Force a full re-encryption even if a manifest exists")
|
|
227
|
+
@click.option("--secret", "-s", envvar="SITR_SECRET", default=None,
|
|
228
|
+
help="API secret key (overrides group-level --secret)")
|
|
229
|
+
@click.pass_context
|
|
230
|
+
def encrypt(
|
|
231
|
+
ctx: click.Context,
|
|
232
|
+
file: Path,
|
|
233
|
+
framework: str,
|
|
234
|
+
version: str,
|
|
235
|
+
expiry: Optional[str],
|
|
236
|
+
network: tuple[str, ...],
|
|
237
|
+
output: Optional[Path],
|
|
238
|
+
incremental: Optional[Path],
|
|
239
|
+
full: bool,
|
|
240
|
+
secret: Optional[str],
|
|
241
|
+
):
|
|
242
|
+
"""
|
|
243
|
+
Encrypt a Python project ZIP file.
|
|
244
|
+
|
|
245
|
+
\b
|
|
246
|
+
On the first run a full encryption is performed and a manifest file
|
|
247
|
+
(.sitr_manifest.json) is saved alongside the output. On subsequent runs
|
|
248
|
+
the CLI automatically detects the manifest, computes which .py files
|
|
249
|
+
changed, sends only those files to the server, and merges the result back
|
|
250
|
+
into the previous encrypted ZIP — saving time and tokens.
|
|
251
|
+
|
|
252
|
+
\b
|
|
253
|
+
Examples:
|
|
254
|
+
sitr encrypt my_module.zip --framework odoo
|
|
255
|
+
sitr encrypt my_app.zip --framework django --expiry 2026-12-31
|
|
256
|
+
sitr encrypt api.zip --framework fastapi --network 192.168.1.0/24
|
|
257
|
+
sitr encrypt my_module.zip --framework odoo --full # force full re-encrypt
|
|
258
|
+
"""
|
|
259
|
+
resolved_secret = _resolve_secret(ctx.obj, secret)
|
|
260
|
+
base = _resolve_base(ctx.obj)
|
|
261
|
+
fw = framework.lower()
|
|
262
|
+
|
|
263
|
+
if not file.name.endswith(".zip"):
|
|
264
|
+
console.print(f"[red]Error:[/] File must be a .zip archive (got {file.name})")
|
|
265
|
+
sys.exit(1)
|
|
266
|
+
|
|
267
|
+
hint = FRAMEWORK_HINTS.get(fw, "")
|
|
268
|
+
console.print(f"\n[bold]SitrTech Encrypt[/] · framework: [cyan]{fw}[/]")
|
|
269
|
+
if hint:
|
|
270
|
+
console.print(f"[dim]Hint: {hint}[/]")
|
|
271
|
+
|
|
272
|
+
output_path: Path = output or file.with_name(file.stem + "_encrypted.zip")
|
|
273
|
+
|
|
274
|
+
# ─── Detect incremental mode ───────────────────────
|
|
275
|
+
manifest = _load_manifest(output_path)
|
|
276
|
+
prev_zip: Optional[Path] = None
|
|
277
|
+
|
|
278
|
+
if not full:
|
|
279
|
+
if incremental is not None:
|
|
280
|
+
prev_zip = incremental
|
|
281
|
+
if not prev_zip.exists():
|
|
282
|
+
console.print(f"[red]Error:[/] --incremental path not found: {prev_zip}")
|
|
283
|
+
sys.exit(1)
|
|
284
|
+
elif manifest is not None and output_path.exists():
|
|
285
|
+
# Auto-detect: previous output + manifest both exist
|
|
286
|
+
prev_zip = output_path
|
|
287
|
+
console.print("[dim]Manifest detected — running incremental diff…[/]")
|
|
288
|
+
|
|
289
|
+
# ─── Preflight: check balance ──────────────────────
|
|
290
|
+
try:
|
|
291
|
+
bal_r = requests.get(
|
|
292
|
+
f"{base}/api/balance",
|
|
293
|
+
headers={"Authorization": f"Bearer {resolved_secret}"},
|
|
294
|
+
timeout=15,
|
|
295
|
+
)
|
|
296
|
+
if bal_r.status_code == 401:
|
|
297
|
+
console.print("[red]Error:[/] Invalid API secret.")
|
|
298
|
+
sys.exit(2)
|
|
299
|
+
if bal_r.ok:
|
|
300
|
+
bal = bal_r.json()
|
|
301
|
+
tokens = bal.get("tokens", 0)
|
|
302
|
+
console.print(f"Balance: [green]{tokens:,}[/] tokens")
|
|
303
|
+
if tokens <= 0:
|
|
304
|
+
console.print("[red]Error:[/] Insufficient token balance.")
|
|
305
|
+
sys.exit(3)
|
|
306
|
+
except requests.exceptions.ConnectionError:
|
|
307
|
+
console.print(f"[red]Error:[/] Cannot connect to {base}. Check your connection.")
|
|
308
|
+
sys.exit(4)
|
|
309
|
+
|
|
310
|
+
# ─── Build the payload ZIP ─────────────────────────
|
|
311
|
+
current_checksums = _get_source_checksums(file)
|
|
312
|
+
|
|
313
|
+
if prev_zip is not None:
|
|
314
|
+
# Incremental: find changed / new files
|
|
315
|
+
prev_checksums: dict[str, str] = (manifest or {}).get("files", {})
|
|
316
|
+
changed = {
|
|
317
|
+
name
|
|
318
|
+
for name, digest in current_checksums.items()
|
|
319
|
+
if prev_checksums.get(name) != digest
|
|
320
|
+
}
|
|
321
|
+
if not changed:
|
|
322
|
+
console.print("[green]✓ No changes detected.[/] Encrypted file is already up to date.")
|
|
323
|
+
return
|
|
324
|
+
|
|
325
|
+
console.print(
|
|
326
|
+
f"\n[bold cyan]Incremental mode[/] — "
|
|
327
|
+
f"[yellow]{len(changed)}[/] file(s) changed / added:"
|
|
328
|
+
)
|
|
329
|
+
for name in sorted(changed):
|
|
330
|
+
prev = prev_checksums.get(name)
|
|
331
|
+
tag = "[green]+new[/]" if prev is None else "[yellow]~mod[/]"
|
|
332
|
+
console.print(f" {tag} {name}")
|
|
333
|
+
|
|
334
|
+
payload_bytes = _build_partial_zip(file, changed)
|
|
335
|
+
upload_name = file.stem + "_partial.zip"
|
|
336
|
+
upload_bytes = payload_bytes
|
|
337
|
+
incremental_mode = True
|
|
338
|
+
else:
|
|
339
|
+
# Full encryption
|
|
340
|
+
with open(file, "rb") as fh:
|
|
341
|
+
upload_bytes = fh.read()
|
|
342
|
+
upload_name = file.name
|
|
343
|
+
incremental_mode = False
|
|
344
|
+
|
|
345
|
+
# ─── Upload & encrypt ──────────────────────────────
|
|
346
|
+
with Progress(
|
|
347
|
+
SpinnerColumn(),
|
|
348
|
+
TextColumn("[progress.description]{task.description}"),
|
|
349
|
+
BarColumn(bar_width=32),
|
|
350
|
+
TimeElapsedColumn(),
|
|
351
|
+
console=console,
|
|
352
|
+
transient=True,
|
|
353
|
+
) as progress:
|
|
354
|
+
task = progress.add_task(
|
|
355
|
+
"Encrypting changes…" if incremental_mode else "Encrypting…",
|
|
356
|
+
total=None,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
form: dict = {}
|
|
360
|
+
if version:
|
|
361
|
+
form["version"] = version
|
|
362
|
+
if expiry:
|
|
363
|
+
form["expires_at"] = expiry
|
|
364
|
+
if network:
|
|
365
|
+
form["network_scopes"] = ",".join(network)
|
|
366
|
+
form["all_platforms"] = "true"
|
|
367
|
+
form["all_pythons"] = "true"
|
|
368
|
+
form["framework"] = fw
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
resp = requests.post(
|
|
372
|
+
f"{base}/api/encrypt",
|
|
373
|
+
headers={
|
|
374
|
+
"Authorization": f"Bearer {resolved_secret}",
|
|
375
|
+
"x-file-name": upload_name,
|
|
376
|
+
"x-framework": fw,
|
|
377
|
+
"x-version": version or "",
|
|
378
|
+
},
|
|
379
|
+
data=form,
|
|
380
|
+
files={"file": (upload_name, io.BytesIO(upload_bytes), "application/zip")},
|
|
381
|
+
stream=True,
|
|
382
|
+
timeout=300,
|
|
383
|
+
)
|
|
384
|
+
except requests.exceptions.ReadTimeout:
|
|
385
|
+
console.print("[red]Error:[/] Request timed out. The file may be too large.")
|
|
386
|
+
sys.exit(5)
|
|
387
|
+
|
|
388
|
+
if resp.status_code == 400:
|
|
389
|
+
detail = resp.json().get("detail", resp.text)
|
|
390
|
+
console.print(f"[red]Error 400:[/] {detail}")
|
|
391
|
+
sys.exit(1)
|
|
392
|
+
elif resp.status_code == 402:
|
|
393
|
+
console.print("[red]Error:[/] Payment required — insufficient tokens.")
|
|
394
|
+
sys.exit(3)
|
|
395
|
+
elif not resp.ok:
|
|
396
|
+
console.print(f"[red]Error {resp.status_code}:[/] {resp.text[:300]}")
|
|
397
|
+
sys.exit(1)
|
|
398
|
+
|
|
399
|
+
progress.update(task, description="Downloading result…")
|
|
400
|
+
result_bytes = b"".join(resp.iter_content(chunk_size=1 << 16))
|
|
401
|
+
|
|
402
|
+
# ─── Write output ──────────────────────────────────
|
|
403
|
+
if incremental_mode and prev_zip is not None:
|
|
404
|
+
_merge_encrypted_zips(prev_zip, result_bytes, output_path)
|
|
405
|
+
console.print(
|
|
406
|
+
f"\n[green]✓ Incremental update complete![/] "
|
|
407
|
+
f"Merged [yellow]{len(changed)}[/] file(s) into [bold]{output_path}[/]"
|
|
408
|
+
)
|
|
409
|
+
else:
|
|
410
|
+
output_path.write_bytes(result_bytes)
|
|
411
|
+
size_kb = output_path.stat().st_size / 1024
|
|
412
|
+
console.print(f"\n[green]✓ Success![/] Saved to [bold]{output_path}[/] ({size_kb:.1f} KB)")
|
|
413
|
+
|
|
414
|
+
# ─── Save / update manifest ────────────────────────
|
|
415
|
+
mp = _save_manifest(output_path, fw, current_checksums)
|
|
416
|
+
console.print(f"[dim]Manifest saved: {mp.name}[/]")
|
|
417
|
+
|
|
418
|
+
# Loader info from header
|
|
419
|
+
info_hdr = resp.headers.get("X-Loader-Info", "")
|
|
420
|
+
if info_hdr:
|
|
421
|
+
try:
|
|
422
|
+
info = json.loads(info_hdr)
|
|
423
|
+
console.print(f"[dim]Prebuilts: {', '.join(info.get('prebuilts_used', [])[:3])}…[/]")
|
|
424
|
+
except Exception:
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
# ─────────────────────────────────────────────
|
|
429
|
+
# balance command
|
|
430
|
+
# ─────────────────────────────────────────────
|
|
431
|
+
|
|
432
|
+
@main.command()
|
|
433
|
+
@click.option("--secret", "-s", envvar="SITR_SECRET", default=None,
|
|
434
|
+
help="API secret key (overrides group-level --secret)")
|
|
435
|
+
@click.pass_context
|
|
436
|
+
def balance(ctx: click.Context, secret: Optional[str]):
|
|
437
|
+
"""Show your current token balance and plan."""
|
|
438
|
+
resolved_secret = _resolve_secret(ctx.obj, secret)
|
|
439
|
+
base = _resolve_base(ctx.obj)
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
r = requests.get(
|
|
443
|
+
f"{base}/api/balance",
|
|
444
|
+
headers={"Authorization": f"Bearer {resolved_secret}"},
|
|
445
|
+
timeout=15,
|
|
446
|
+
)
|
|
447
|
+
except requests.exceptions.ConnectionError:
|
|
448
|
+
console.print(f"[red]Error:[/] Cannot connect to {base}.")
|
|
449
|
+
sys.exit(4)
|
|
450
|
+
|
|
451
|
+
if r.status_code == 401:
|
|
452
|
+
console.print("[red]Error:[/] Invalid API secret.")
|
|
453
|
+
sys.exit(2)
|
|
454
|
+
|
|
455
|
+
if not r.ok:
|
|
456
|
+
console.print(f"[red]Error {r.status_code}:[/] {r.text[:200]}")
|
|
457
|
+
sys.exit(1)
|
|
458
|
+
|
|
459
|
+
data = r.json()
|
|
460
|
+
table = Table(title="SitrTech Account", show_header=False)
|
|
461
|
+
table.add_column("Key", style="bold cyan")
|
|
462
|
+
table.add_column("Value")
|
|
463
|
+
table.add_row("Tokens", f"{data.get('tokens', 0):,}")
|
|
464
|
+
table.add_row("Plan", str(data.get("plan_tier", "—")).capitalize())
|
|
465
|
+
table.add_row("Rate", f"{data.get('rate', 0.6)} tokens / line")
|
|
466
|
+
console.print(table)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
# ─────────────────────────────────────────────
|
|
470
|
+
# info command (show framework hints)
|
|
471
|
+
# ─────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
@main.command()
|
|
474
|
+
def info():
|
|
475
|
+
"""Show supported frameworks and their required project structure."""
|
|
476
|
+
table = Table(title="Supported Frameworks", highlight=True)
|
|
477
|
+
table.add_column("Framework", style="bold cyan")
|
|
478
|
+
table.add_column("Status", style="green")
|
|
479
|
+
table.add_column("Required file at project root")
|
|
480
|
+
|
|
481
|
+
for fw, hint in FRAMEWORK_HINTS.items():
|
|
482
|
+
table.add_row(fw.capitalize(), "✓ Available", hint)
|
|
483
|
+
|
|
484
|
+
console.print(table)
|
|
485
|
+
console.print(
|
|
486
|
+
"\n[dim]Zip your project folder and run:[/]\n"
|
|
487
|
+
" [bold]sitr encrypt your_project.zip --framework <name>[/]\n"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
if __name__ == "__main__":
|
|
492
|
+
main()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sitrtech
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: SitrTech Python code encryption CLI — protect Odoo, Django, Flask, FastAPI and Tornado projects
|
|
5
|
+
Author-email: SitrTech <support@sitrtech.com>
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Project-URL: Homepage, https://sitrtech.com
|
|
8
|
+
Project-URL: Docs, https://sitrtech.com/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/sohaib929/sitr_loader
|
|
10
|
+
Project-URL: Bug Tracker, https://github.com/sohaib929/sitr_loader/issues
|
|
11
|
+
Keywords: odoo,django,flask,fastapi,tornado,encryption,obfuscation,code-protection
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Topic :: Security :: Cryptography
|
|
24
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
25
|
+
Requires-Python: >=3.8
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
Requires-Dist: requests>=2.31
|
|
28
|
+
Requires-Dist: rich>=13
|
|
29
|
+
Requires-Dist: click>=8
|
|
30
|
+
|
|
31
|
+
# SitrTech — Python Code Encryption CLI
|
|
32
|
+
|
|
33
|
+
Protect **Odoo, Django, Flask, FastAPI and Tornado** projects with AES-256 encryption, licensing controls, and anti-reverse-engineering in one command.
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install sitrtech
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Get your API secret key from [sitrtech.com/api-keys](https://sitrtech.com/api-keys), then:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Encrypt an Odoo addon
|
|
45
|
+
sitr encrypt my_module.zip --framework odoo --secret sk-...
|
|
46
|
+
|
|
47
|
+
# Encrypt a Django project
|
|
48
|
+
sitr encrypt my_project.zip --framework django --secret sk-...
|
|
49
|
+
|
|
50
|
+
# Check your token balance
|
|
51
|
+
sitr balance --secret sk-...
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Installation
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install sitrtech # latest stable
|
|
58
|
+
pip install sitrtech==1.0.0 # pin a specific version
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Python 3.8 → 3.14 · Windows, macOS, Linux
|
|
62
|
+
|
|
63
|
+
## Usage
|
|
64
|
+
|
|
65
|
+
### `sitr encrypt`
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
sitr encrypt <FILE.zip> [OPTIONS]
|
|
69
|
+
|
|
70
|
+
Arguments:
|
|
71
|
+
FILE.zip Zipped project folder
|
|
72
|
+
|
|
73
|
+
Options:
|
|
74
|
+
-f, --framework TEXT odoo | django | flask | fastapi | tornado [default: odoo]
|
|
75
|
+
-v, --version TEXT Framework version (e.g. 16.0 for Odoo)
|
|
76
|
+
-e, --expiry DATE Expiry: 2026-12-31 or 2026-12-31T23:59:00Z
|
|
77
|
+
-n, --network CIDR Allowed IP/CIDR (repeatable)
|
|
78
|
+
-o, --output PATH Output file (default: <input>_encrypted.zip)
|
|
79
|
+
-s, --secret TEXT API secret key [env: SITR_SECRET]
|
|
80
|
+
-b, --base URL API base URL [env: SITR_BASE]
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Examples
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
# Odoo (default)
|
|
87
|
+
sitr encrypt my_addon.zip --framework odoo
|
|
88
|
+
|
|
89
|
+
# Django with expiry and IP restriction
|
|
90
|
+
sitr encrypt backend.zip --framework django \
|
|
91
|
+
--expiry 2026-12-31 \
|
|
92
|
+
--network 10.0.0.0/24
|
|
93
|
+
|
|
94
|
+
# FastAPI project
|
|
95
|
+
sitr encrypt api.zip --framework fastapi
|
|
96
|
+
|
|
97
|
+
# Flask with multiple IP ranges
|
|
98
|
+
sitr encrypt webapp.zip --framework flask \
|
|
99
|
+
--network 192.168.1.10 \
|
|
100
|
+
--network 10.0.0.0/8
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### `sitr balance`
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
sitr balance --secret sk-...
|
|
107
|
+
# ┌─────────────────────────────┐
|
|
108
|
+
# │ SitrTech Account │
|
|
109
|
+
# │ Tokens │ 5,000 │
|
|
110
|
+
# │ Plan │ Pro │
|
|
111
|
+
# │ Rate │ 0.3 tokens/line │
|
|
112
|
+
# └─────────────────────────────┘
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### `sitr info`
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
sitr info
|
|
119
|
+
# Shows all supported frameworks and required project structure
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Environment variables
|
|
123
|
+
|
|
124
|
+
| Variable | Description |
|
|
125
|
+
|-----------------|--------------------------------------|
|
|
126
|
+
| `SITR_SECRET` | API secret key (avoids typing it) |
|
|
127
|
+
| `SITR_BASE` | Override API URL (default: sitrtech.com) |
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
export SITR_SECRET=sk-your-key-here
|
|
131
|
+
sitr encrypt my_project.zip --framework django
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Project structure requirements
|
|
135
|
+
|
|
136
|
+
| Framework | Required entry-point file |
|
|
137
|
+
|-----------|--------------------------|
|
|
138
|
+
| Odoo | `__manifest__.py` |
|
|
139
|
+
| Django | `manage.py` |
|
|
140
|
+
| Flask | `app.py` or `wsgi.py` |
|
|
141
|
+
| FastAPI | `main.py` or `asgi.py` |
|
|
142
|
+
| Tornado | `main.py` or `server.py` |
|
|
143
|
+
|
|
144
|
+
**Always zip the root folder** — not its contents:
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
# Correct ✓
|
|
148
|
+
zip -r my_addon.zip my_addon/
|
|
149
|
+
|
|
150
|
+
# Wrong ✗
|
|
151
|
+
cd my_addon && zip -r ../my_addon.zip .
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## CI/CD integration
|
|
155
|
+
|
|
156
|
+
```yaml
|
|
157
|
+
# GitHub Actions
|
|
158
|
+
- name: Encrypt Odoo addon
|
|
159
|
+
env:
|
|
160
|
+
SITR_SECRET: ${{ secrets.SITR_SECRET }}
|
|
161
|
+
run: |
|
|
162
|
+
pip install sitrtech
|
|
163
|
+
sitr encrypt dist/my_addon.zip --framework odoo --output dist/my_addon_encrypted.zip
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Links
|
|
167
|
+
|
|
168
|
+
- **Documentation**: <https://sitrtech.com/docs>
|
|
169
|
+
- **API Keys**: <https://sitrtech.com/api-keys>
|
|
170
|
+
- **Support**: support@sitrtech.com
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
sitrtech/__init__.py,sha256=FvFfb7xfAwUWNm5nD-JEwe55uT7N8xLDs6ta7OYLGNM,105
|
|
2
|
+
sitrtech/cli.py,sha256=flD7-RR0wm6Cn6RP06JVxp_huqnZIgBIqD8pCeZjsAE,19689
|
|
3
|
+
sitrtech-1.0.0.dist-info/METADATA,sha256=w1zVZstvd56OhRptQneQCbm3Y9UZJn044xL6DPushLc,4849
|
|
4
|
+
sitrtech-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
sitrtech-1.0.0.dist-info/entry_points.txt,sha256=FNAhFalQbzwZs5-wsGZeOUMaZp9WJMblvSd5LbYVAmg,72
|
|
6
|
+
sitrtech-1.0.0.dist-info/top_level.txt,sha256=p6vbqFjHIkVdrJP_mzU8NiDXPvg7JEO5qhG7jP2pm9o,9
|
|
7
|
+
sitrtech-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sitrtech
|