Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 128 additions & 12 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,30 @@
from commands.analyze_kinds import analyze_kinds, print_summary_table
from commands.analyze_entity_fields import analyze_field_contributions, print_field_summary
from commands.cleanup_expired import cleanup_expired
from commands.drive_sync import push_to_drive, pull_from_drive

app = typer.Typer(help="Utilities for analyzing and managing local Datastore/Firestore (Datastore mode)", no_args_is_help=True)
app = typer.Typer(
help="Utilities for analyzing and managing local Datastore/Firestore (Datastore mode)",
no_args_is_help=True,
)

# Aliases with flags only — no defaults here
ConfigOpt = Annotated[Optional[str], typer.Option("--config", help="Path to config.yaml")]
ProjectOpt = Annotated[Optional[str], typer.Option("--project", help="GCP/Emulator project id")]
EmulatorHostOpt = Annotated[Optional[str], typer.Option("--emulator-host", help="Emulator host, e.g. localhost:8010")]
EmulatorHostOpt = Annotated[
Optional[str], typer.Option("--emulator-host", help="Emulator host, e.g. localhost:8010")
]
LogLevelOpt = Annotated[Optional[str], typer.Option("--log-level", help="Logging level")]
KindsOpt = Annotated[
Optional[List[str]],
typer.Option("--kind", "-k", help="Kinds to process (omit or empty to process all in each namespace)")
typer.Option(
"--kind", "-k", help="Kinds to process (omit or empty to process all in each namespace)"
),
]
SingleKindOpt = Annotated[
Optional[str], typer.Option("--kind", "-k", help="Kind to analyze (falls back to config.kind)")
]
SingleKindOpt = Annotated[Optional[str], typer.Option("--kind", "-k", help="Kind to analyze (falls back to config.kind)")]


def _load_cfg(
config_path: Optional[str],
Expand All @@ -38,6 +49,7 @@ def _load_cfg(
overrides["log_level"] = log_level
return load_config(config_path, overrides)


@app.command("analyze-kinds")
def cmd_analyze_kinds(
config: ConfigOpt = None,
Expand All @@ -64,17 +76,31 @@ def cmd_analyze_kinds(
else:
print_summary_table(rows)


@app.command("analyze-fields")
def cmd_analyze_fields(
kind: SingleKindOpt = None,
namespace: Annotated[Optional[str], typer.Option("--namespace", "-n", help="Namespace to query (omit to use all)")] = None,
group_by: Annotated[Optional[str], typer.Option("--group-by", help="Group results by this field value (falls back to config.group_by_field)")] = None,
only_field: Annotated[Optional[List[str]], typer.Option("--only-field", help="Only consider these fields")] = None,
namespace: Annotated[
Optional[str],
typer.Option("--namespace", "-n", help="Namespace to query (omit to use all)"),
] = None,
group_by: Annotated[
Optional[str],
typer.Option(
"--group-by",
help="Group results by this field value (falls back to config.group_by_field)",
),
] = None,
only_field: Annotated[
Optional[List[str]], typer.Option("--only-field", help="Only consider these fields")
] = None,
config: ConfigOpt = None,
project: ProjectOpt = None,
emulator_host: EmulatorHostOpt = None,
log_level: LogLevelOpt = None,
output_json: Annotated[Optional[str], typer.Option("--output-json", help="Write raw JSON results to file")] = None,
output_json: Annotated[
Optional[str], typer.Option("--output-json", help="Write raw JSON results to file")
] = None,
):
cfg = _load_cfg(config, project, emulator_host, log_level)

Expand All @@ -100,17 +126,32 @@ def cmd_analyze_fields(
else:
print_field_summary(result)


@app.command("cleanup")
def cmd_cleanup(
config: ConfigOpt = None,
project: ProjectOpt = None,
emulator_host: EmulatorHostOpt = None,
log_level: LogLevelOpt = None,
kind: KindsOpt = None,
ttl_field: Annotated[Optional[str], typer.Option("--ttl-field", help="TTL field name (falls back to config.ttl_field)")] = None,
delete_missing_ttl: Annotated[Optional[bool], typer.Option("--delete-missing-ttl", help="Delete when TTL field is missing (falls back to config.delete_missing_ttl)")] = None,
batch_size: Annotated[Optional[int], typer.Option("--batch-size", help="Delete batch size (falls back to config.batch_size)")] = None,
dry_run: Annotated[bool, typer.Option("--dry-run", help="Only report counts; do not delete")] = False,
ttl_field: Annotated[
Optional[str],
typer.Option("--ttl-field", help="TTL field name (falls back to config.ttl_field)"),
] = None,
delete_missing_ttl: Annotated[
Optional[bool],
typer.Option(
"--delete-missing-ttl",
help="Delete when TTL field is missing (falls back to config.delete_missing_ttl)",
),
] = None,
batch_size: Annotated[
Optional[int],
typer.Option("--batch-size", help="Delete batch size (falls back to config.batch_size)"),
] = None,
dry_run: Annotated[
bool, typer.Option("--dry-run", help="Only report counts; do not delete")
] = False,
):
cfg = _load_cfg(config, project, emulator_host, log_level)

Expand All @@ -127,6 +168,81 @@ def cmd_cleanup(
deleted_sum = sum(totals.values())
typer.echo(f"Total entities {'to delete' if dry_run else 'deleted'}: {deleted_sum}")


db_app = typer.Typer(help="Database backup management commands", no_args_is_help=True)


@db_app.command("push")
def db_push(
version: Annotated[
Optional[str], typer.Argument(help="Version name (defaults to today's date YYYY-mm-DD)")
Copy link

Copilot AI Nov 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The date format in the help text should be YYYY-MM-DD (capital M for months), not YYYY-mm-DD. The lowercase 'mm' typically represents minutes, while 'MM' represents months. The actual code implementation at line 64 of drive_sync.py correctly uses %Y-%m-%d format.

Suggested change
Optional[str], typer.Argument(help="Version name (defaults to today's date YYYY-mm-DD)")
Optional[str], typer.Argument(help="Version name (defaults to today's date YYYY-MM-DD)")

Copilot uses AI. Check for mistakes.
] = None,
overwrite: Annotated[
bool, typer.Option("-o", "--overwrite", help="Overwrite existing file with same name")
] = False,
local_db: Annotated[
Optional[str],
typer.Option(
"--local-db",
help="Optional helper script path (e.g. tools/dev-env/local-db). This script may stash/restore; the actual data file comes from config.local_db_path."
),
] = None,
dry_run: Annotated[
bool,
typer.Option(
"--dry-run", help="Do not upload to Drive, just show what would be uploaded"
),
] = False,
config: ConfigOpt = None,
log_level: LogLevelOpt = None,
):
cfg = _load_cfg(config, None, None, log_level)
push_to_drive(cfg, version, overwrite, local_db, dry_run=dry_run)


@db_app.command("pull")
def db_pull(
version: Annotated[
Optional[str], typer.Argument(help="Version name (omit to download latest)")
] = None,
local_db: Annotated[
Optional[str],
typer.Option(
"--local-db",
help="Optional helper script path (e.g. tools/dev-env/local-db). This script may stash/restore; the actual data file comes from config.local_db_path.",
),
] = None,
overwrite: Annotated[
bool,
typer.Option(
"--overwrite/--no-overwrite",
help="Whether to overwrite the local data file when restoring from Drive (default: overwrite)",
show_default=True,
),
] = True,
config: ConfigOpt = None,
log_level: LogLevelOpt = None,
):
cfg = _load_cfg(config, None, None, log_level)
pull_from_drive(cfg, version, local_db, overwrite=overwrite)


@db_app.command("list")
def db_list(
config: ConfigOpt = None,
log_level: LogLevelOpt = None,
):
cfg = _load_cfg(config, None, None, log_level)
from commands.drive_sync import list_backups

backups = list_backups(cfg)
for b in backups:
typer.echo(b)


app.add_typer(db_app, name="db")


if __name__ == "__main__":
import sys

Expand Down
29 changes: 16 additions & 13 deletions commands/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,23 @@
from .analyze_kinds import analyze_kinds, get_kind_stats, estimate_entity_count_and_size
from .analyze_entity_fields import analyze_field_contributions, print_field_summary
from .cleanup_expired import cleanup_expired
from .drive_sync import push_to_drive, pull_from_drive
from . import config as config

__all__ = [
"AppConfig",
"load_config",
"build_client",
"list_namespaces",
"list_kinds",
"format_size",
"analyze_kinds",
"get_kind_stats",
"estimate_entity_count_and_size",
"analyze_field_contributions",
"print_field_summary",
"cleanup_expired",
"config",
"AppConfig",
"load_config",
"build_client",
"list_namespaces",
"list_kinds",
"format_size",
"analyze_kinds",
"get_kind_stats",
"estimate_entity_count_and_size",
"analyze_field_contributions",
"print_field_summary",
"cleanup_expired",
"push_to_drive",
"pull_from_drive",
"config",
]
9 changes: 8 additions & 1 deletion commands/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ class AppConfig:
# Logging
log_level: str = "INFO"

# Drive sync settings
local_db_path: Optional[str] = None
# Google Drive folder name where backups are stored
gdrive_directory: str = "datastore"


def _as_list(value: Optional[Iterable[str]]) -> List[str]:
if value is None:
Expand Down Expand Up @@ -90,6 +95,9 @@ def load_config(path: Optional[str] = None, overrides: Optional[Dict] = None) ->

config.log_level = str(merged.get("log_level", config.log_level)).upper()

config.local_db_path = merged.get("local_db_path", config.local_db_path)
config.gdrive_directory = merged.get("gdrive_directory", config.gdrive_directory)

_configure_logging(config.log_level)
return config

Expand Down Expand Up @@ -155,4 +163,3 @@ def format_size(bytes_size: int) -> str:
return f"{size:.2f} {unit}"
size /= 1024
return f"{size:.2f} PB"

Loading
Loading