refactor: split cli.py into package (ref.md 3.7)
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import sys
|
||||
from devplacepy.cli._shared import _audit_cli
|
||||
|
||||
|
||||
def _crc32(path):
|
||||
import zlib
|
||||
|
||||
crc = 0
|
||||
with open(path, "rb") as handle:
|
||||
while True:
|
||||
chunk = handle.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
crc = zlib.crc32(chunk, crc)
|
||||
return crc & 0xFFFFFFFF
|
||||
|
||||
|
||||
def _migrate_file(source, dest, dry_run, report):
|
||||
import os
|
||||
import shutil
|
||||
|
||||
if not source.exists():
|
||||
return
|
||||
if source.resolve() == dest.resolve():
|
||||
return
|
||||
size = source.stat().st_size
|
||||
if dest.exists():
|
||||
if dest.stat().st_size == size and _crc32(dest) == _crc32(source):
|
||||
report.append(("done", source, dest, size))
|
||||
if not dry_run:
|
||||
source.unlink()
|
||||
return
|
||||
report.append(("conflict", source, dest, size))
|
||||
return
|
||||
report.append(("move", source, dest, size))
|
||||
if dry_run:
|
||||
return
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dest.with_name(dest.name + ".migrating")
|
||||
shutil.copyfile(source, tmp)
|
||||
with open(tmp, "rb") as handle:
|
||||
os.fsync(handle.fileno())
|
||||
if tmp.stat().st_size != size or _crc32(tmp) != _crc32(source):
|
||||
tmp.unlink(missing_ok=True)
|
||||
raise RuntimeError(f"verification failed copying {source} -> {dest}")
|
||||
os.replace(tmp, dest)
|
||||
source.unlink()
|
||||
|
||||
|
||||
def _prune_empty_dirs(root):
|
||||
if not root.exists():
|
||||
return
|
||||
for path in sorted(root.rglob("*"), reverse=True):
|
||||
if path.is_dir():
|
||||
try:
|
||||
path.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
root.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _migrate_tree(source, dest, dry_run, report):
|
||||
if not source.exists():
|
||||
return
|
||||
if source.resolve() == dest.resolve():
|
||||
return
|
||||
for child in sorted(source.rglob("*")):
|
||||
if child.is_file():
|
||||
_migrate_file(child, dest / child.relative_to(source), dry_run, report)
|
||||
if not dry_run:
|
||||
_prune_empty_dirs(source)
|
||||
|
||||
|
||||
def _db_is_locked(path):
|
||||
import sqlite3
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(path), timeout=0.5)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
conn.close()
|
||||
except sqlite3.OperationalError:
|
||||
return True
|
||||
|
||||
|
||||
def _checkpoint(path):
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(str(path), timeout=5)
|
||||
try:
|
||||
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _migrate_db(source, dest, dry_run, report):
|
||||
if not source.exists():
|
||||
return
|
||||
if source.resolve() == dest.resolve():
|
||||
return
|
||||
if _db_is_locked(source):
|
||||
raise RuntimeError(
|
||||
f"{source} is locked - stop the app before running migrate-data"
|
||||
)
|
||||
if not dry_run:
|
||||
_checkpoint(source)
|
||||
_migrate_file(source, dest, dry_run, report)
|
||||
for suffix in ("-wal", "-shm"):
|
||||
_migrate_file(
|
||||
source.with_name(source.name + suffix),
|
||||
dest.with_name(dest.name + suffix),
|
||||
dry_run,
|
||||
report,
|
||||
)
|
||||
|
||||
|
||||
def cmd_emoji_sync(args):
|
||||
from devplacepy.rendering import EMOJI_JS_PATH, write_emoji_module
|
||||
|
||||
count = write_emoji_module()
|
||||
_audit_cli("cli.emoji.sync", f"CLI regenerated {count} emoji shortcodes", metadata={"count": count})
|
||||
print(f"Wrote {count} emoji shortcodes to {EMOJI_JS_PATH}")
|
||||
|
||||
|
||||
def cmd_migrate_data(args):
|
||||
import os
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
from devplacepy import config
|
||||
|
||||
base = config.BASE_DIR
|
||||
home = Path.home()
|
||||
dry = args.dry_run
|
||||
report = []
|
||||
|
||||
config.ensure_data_dirs()
|
||||
|
||||
db_items = []
|
||||
if config.DATABASE_URL == f"sqlite:///{config.DATA_DIR / 'devplace.db'}":
|
||||
db_items.append((base / "devplace.db", config.DATA_DIR / "devplace.db"))
|
||||
else:
|
||||
print("Skipping main DB: DEVPLACE_DATABASE_URL points outside the data dir.")
|
||||
if not os.environ.get("DEVII_TASKS_DB"):
|
||||
db_items.append((base / "devii_tasks.db", config.DEVII_TASKS_DB))
|
||||
if not os.environ.get("DEVII_LESSONS_DB"):
|
||||
db_items.append((base / "devii_lessons.db", config.DEVII_LESSONS_DB))
|
||||
|
||||
file_items = [
|
||||
(base / name, config.KEYS_DIR / name)
|
||||
for name in (
|
||||
"notification-private.pem",
|
||||
"notification-private.pkcs8.pem",
|
||||
"notification-public.pem",
|
||||
)
|
||||
]
|
||||
registry_dest = config.BOT_DIR / "article_registry.json"
|
||||
registry_sources = [
|
||||
path
|
||||
for path in (
|
||||
home / ".dpbot_article_registry.json",
|
||||
base / ".dpbot_article_registry.json",
|
||||
)
|
||||
if path.exists()
|
||||
]
|
||||
registry_sources.sort(key=lambda path: path.stat().st_mtime, reverse=True)
|
||||
if registry_sources:
|
||||
file_items.append((registry_sources[0], registry_dest))
|
||||
for stale in registry_sources[1:]:
|
||||
print(f"Leaving older duplicate registry untouched: {stale}")
|
||||
|
||||
legacy_var = base / "var"
|
||||
tree_items = [
|
||||
(base / "devplacepy" / "static" / "uploads", config.UPLOADS_DIR),
|
||||
(home / ".devplace_bots", config.BOT_DIR),
|
||||
]
|
||||
for sub_name in ("container_workspaces", "zips", "zip_staging", "fork_staging"):
|
||||
tree_items.append((legacy_var / sub_name, config.DATA_PATHS[sub_name]))
|
||||
|
||||
try:
|
||||
for source, dest in db_items:
|
||||
_migrate_db(source, dest, dry, report)
|
||||
for source, dest in file_items:
|
||||
_migrate_file(source, dest, dry, report)
|
||||
for source, dest in tree_items:
|
||||
_migrate_tree(source, dest, dry, report)
|
||||
except RuntimeError as exc:
|
||||
print(f"ERROR: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
if not report:
|
||||
print("Nothing to migrate; the data directory is already consolidated.")
|
||||
return
|
||||
for status, source, dest, size in report:
|
||||
print(f" [{status}] {source} -> {dest} ({size} bytes)")
|
||||
counts = Counter(status for status, *_ in report)
|
||||
print()
|
||||
print(
|
||||
("Planned: " if dry else "Migrated: ")
|
||||
+ ", ".join(f"{count} {status}" for status, count in sorted(counts.items()))
|
||||
)
|
||||
if any(status == "conflict" for status, *_ in report):
|
||||
print(
|
||||
"Conflicts left both source and destination untouched; resolve them by hand."
|
||||
)
|
||||
if dry:
|
||||
print("Dry run - nothing changed. Re-run without --dry-run to apply.")
|
||||
|
||||
|
||||
def register_migrate(subparsers):
|
||||
subparsers.add_parser(
|
||||
"emoji-sync",
|
||||
help="Regenerate static/js/emoji-shortcodes.js from the emoji library",
|
||||
).set_defaults(func=cmd_emoji_sync)
|
||||
|
||||
migrate = subparsers.add_parser(
|
||||
"migrate-data",
|
||||
help="Relocate legacy runtime files into the consolidated data/ directory",
|
||||
)
|
||||
migrate.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print the source-to-destination plan without changing anything",
|
||||
)
|
||||
migrate.set_defaults(func=cmd_migrate_data)
|
||||
Reference in New Issue
Block a user