From 4b011884b88af9b16e138dd6bf0c7f98c218e930 Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Fri, 8 May 2026 13:44:40 -0500 Subject: [PATCH 01/62] add bely command-line tool --- tools/developer_tools/bely-cli/auth.py | 81 +++++ tools/developer_tools/bely-cli/bely | 148 ++++++++ tools/developer_tools/bely-cli/commands.py | 400 +++++++++++++++++++++ tools/developer_tools/bely-cli/install.txt | 1 + tools/developer_tools/bely-cli/settings.py | 39 ++ 5 files changed, 669 insertions(+) create mode 100644 tools/developer_tools/bely-cli/auth.py create mode 100755 tools/developer_tools/bely-cli/bely create mode 100644 tools/developer_tools/bely-cli/commands.py create mode 100644 tools/developer_tools/bely-cli/install.txt create mode 100644 tools/developer_tools/bely-cli/settings.py diff --git a/tools/developer_tools/bely-cli/auth.py b/tools/developer_tools/bely-cli/auth.py new file mode 100644 index 000000000..91d09f492 --- /dev/null +++ b/tools/developer_tools/bely-cli/auth.py @@ -0,0 +1,81 @@ +import getpass +import os +import sys +from contextlib import contextmanager + +import belyApi + +from BelyApiFactory import BelyApiFactory +from settings import get_setting + + +def get_host(): + """Return the BELY server URL from env var or settings.""" + host = os.environ.get("BELY_HOST") or get_setting("host") + if not host: + print("Error: no host configured. Set BELY_HOST or add 'host' to settings.yaml.", + file=sys.stderr) + sys.exit(1) + return host + + +def get_username(): + """Return the BELY username from env var, settings, or interactive prompt.""" + username = os.environ.get("BELY_USER") or get_setting("user") + if not username: + username = input("Username: ").strip() + return username + + +def get_password(username): + """Return the BELY password from env var or interactive prompt.""" + password = os.environ.get("BELY_PASSWORD") + if not password: + print(f"Logging in as '{username}'") + try: + password = getpass.getpass("Password: ") + except (EOFError, KeyboardInterrupt): + print("\nAborted.", file=sys.stderr) + sys.exit(1) + return password + + + +def get_factory(): + """Create and return an unauthenticated BelyApiFactory.""" + return BelyApiFactory(bely_url=get_host()) + + +@contextmanager +def get_authenticated_factory(): + """Create and yield an authenticated BelyApiFactory, logging out on exit. + + Usage:: + + with auth.get_authenticated_factory() as factory: + logbook_api = factory.get_logbook_api() + ... + + Credentials come from: + 1. BELY_USER + BELY_PASSWORD env vars + 2. Interactive prompt + """ + factory = BelyApiFactory(bely_url=get_host()) + + username = get_username() + password = get_password(username) + + try: + factory.authenticate_user(username, password) + except belyApi.exceptions.UnauthorizedException: + print(f"Authentication failed: invalid credentials for user '{username}'", + file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Authentication failed: {e}", file=sys.stderr) + sys.exit(1) + + try: + yield factory + finally: + factory.logout_user() diff --git a/tools/developer_tools/bely-cli/bely b/tools/developer_tools/bely-cli/bely new file mode 100755 index 000000000..44aa3c453 --- /dev/null +++ b/tools/developer_tools/bely-cli/bely @@ -0,0 +1,148 @@ +#!/home/phoebus/ECHANDLER/sandbox/hla/bely-client/conda/bin/python + +import click + +from commands import ( + cmd_new_doc, + cmd_list_docs, + cmd_update_entry, + cmd_add_entry, + cmd_list_types, + cmd_list_systems, + cmd_list_templates, + cmd_show_config, + cmd_edit_config, +) + + +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) + + +@click.group(context_settings=CONTEXT_SETTINGS) +def cli(): + """BELY logbook CLI""" + pass + + +# -- doc -- + +@cli.group("doc") +def doc_group(): + """Log document commands.""" + pass + + +@doc_group.command("new") +@click.option("--type", "type_", required=True, help="Logbook type (e.g. ops, controls)") +@click.option("--name", required=True, help="Name for the new document") +@click.option("--file", "file", default=None, help="Markdown file for the first log entry") +@click.option("--template", default=None, help="Template name to use") +@click.option("--systems", default=None, help="Comma-separated system list (e.g. SR,software)") +@click.option("--no-template", is_flag=True, help="Skip template selection") +@click.option("--no-prompt", is_flag=True, help="Non-interactive mode (no prompts)") +def doc_new(**kwargs): + """Create a new log document.""" + cmd_new_doc(**kwargs) + + +@doc_group.command("list") +@click.option("--limit", default=20, type=int, help="Max documents to return (default 20)") +def doc_list(**kwargs): + """List recent log documents created by you.""" + cmd_list_docs(**kwargs) + + +# -- entry -- + +@cli.group("entry") +def entry_group(): + """Log entry commands.""" + pass + + +@entry_group.command("add") +@click.option("--name", required=True, help="Log document name") +@click.option("--file", "file", default=None, help="Markdown file with entry content") +@click.option("--text", default=None, help="Inline text for the entry") +@click.option("--add-attachment", default=None, help="File to attach to the entry") +def entry_add(**kwargs): + """Add a new log entry to an existing document.""" + cmd_add_entry(**kwargs) + + +@entry_group.command("update") +@click.option("--name", default=None, help="Log document name") +@click.option("--entry-id", default=None, type=int, help="Specific log entry ID to update") +@click.option("--file", "file", default=None, help="Markdown file with updated content") +@click.option("--text", default=None, help="Inline text for the entry") +@click.option("--add-attachment", default=None, help="File to attach to the entry") +def entry_update(**kwargs): + """Update an existing log entry.""" + cmd_update_entry(**kwargs) + + +# -- type -- + +@cli.group("type") +def type_group(): + """Logbook type commands.""" + pass + + +@type_group.command("list") +def type_list(): + """List available logbook types.""" + cmd_list_types() + + +# -- system -- + +@cli.group("system") +def system_group(): + """Logbook system commands.""" + pass + + +@system_group.command("list") +def system_list(): + """List available logbook systems.""" + cmd_list_systems() + + +# -- template -- + +@cli.group("template") +def template_group(): + """Logbook template commands.""" + pass + + +@template_group.command("list") +def template_list(): + """List available logbook templates.""" + cmd_list_templates() + + +# -- config -- + +@cli.group("config") +def config_group(): + """Configuration commands.""" + pass + + +@config_group.command("show") +def config_show(): + """Show current configuration.""" + cmd_show_config() + + +@config_group.command("edit") +def config_edit(): + """Open the settings file in your editor.""" + cmd_edit_config() + + + +if __name__ == "__main__": + cli() diff --git a/tools/developer_tools/bely-cli/commands.py b/tools/developer_tools/bely-cli/commands.py new file mode 100644 index 000000000..b4e2f52d8 --- /dev/null +++ b/tools/developer_tools/bely-cli/commands.py @@ -0,0 +1,400 @@ +import os +import subprocess +import sys +import tempfile + +import belyApi + +import auth +import settings + + +ENV_VARS = ["BELY_HOST", "BELY_USER", "BELY_PASSWORD"] + + +def cmd_show_config(): + """Show current configuration from settings file and environment.""" + print(f"Settings file: {settings.SETTINGS_FILE}") + data = settings.load_settings() + if data: + for key, value in data.items(): + print(f" {key} = {value}") + else: + print(" (no settings)") + + print() + print("Environment variables:") + found = False + for var in ENV_VARS: + val = os.environ.get(var) + if val is not None: + display = "****" if "PASSWORD" in var else val + print(f" {var} = {display}") + found = True + if not found: + print(" (none set)") + + +def find_logbook_type(logbook_api, name): + """Find a logbook type by name (case-insensitive). Raises ValueError if not found.""" + types = logbook_api.get_logbook_types() + for t in types: + if t.name and t.name.lower() == name.lower(): + return t + available = ", ".join(t.name for t in types if t.name) + raise ValueError(f"Unknown logbook type '{name}'. Available: {available}") + + +def find_systems(logbook_api, names_csv): + """Resolve comma-separated system names to IDs. Raises ValueError on unknown name.""" + all_systems = logbook_api.get_logbook_systems() + by_name = {s.name.lower(): s for s in all_systems} + ids = [] + for name in names_csv.split(","): + name = name.strip() + if name.lower() not in by_name: + available = ", ".join(s.name for s in all_systems) + raise ValueError(f"Unknown system '{name}'. Available: {available}") + ids.append(by_name[name.lower()].id) + return ids + + +def find_template(logbook_api, name): + """Find a template by name (case-insensitive). Raises ValueError if not found.""" + templates = logbook_api.get_logbook_templates() + for t in templates: + if t.name and t.name.lower() == name.lower(): + return t + available = ", ".join(t.name for t in templates if t.name) + raise ValueError(f"Unknown template '{name}'. Available: {available}") + +def find_logdoc(logbook_api, name): + try: + existing = logbook_api.get_log_document_by_name(name=name) + return existing + except belyApi.exceptions.NotFoundException: + return None + + +def cmd_edit_config(): + """Open the settings file in the user's editor.""" + settings._ensure_config_dir() + if not os.path.exists(settings.SETTINGS_FILE): + settings.save_settings({}) + editor = os.environ.get("EDITOR", "vi") + os.execvp(editor, [editor, settings.SETTINGS_FILE]) + + + +def cmd_new_doc(type_, name, file, template, systems, no_template, no_prompt): + """Create a new log document, optionally adding a first log entry.""" + if template and no_template: + print("Error: --template and --no-template are mutually exclusive.", file=sys.stderr) + sys.exit(1) + + # Resolve names to IDs using unauthenticated API + factory = auth.get_factory() + logbook_api = factory.get_logbook_api() + + try: + logbook_type = find_logbook_type(logbook_api, type_) + system_id_list = find_systems(logbook_api, systems) if systems else None + template_id = find_template(logbook_api, template).id if template else None + if find_logdoc(logbook_api, name): + raise ValueError(f"A log document named '{name}' already exists") + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + + # Build document options + doc_opts = belyApi.LogDocumentOptions( + name=name, + logbook_type_id=logbook_type.id, + ) + if system_id_list: + doc_opts.system_id_list = system_id_list + if template_id: + doc_opts.template_id = template_id + if no_template: + doc_opts.skip_default_logbook_type_template = True + + # Authenticate and create document + with auth.get_authenticated_factory() as auth_factory: + logbook_api = auth_factory.get_logbook_api() + doc = logbook_api.create_logbook_document(log_document_options=doc_opts) + print(f'New document "{doc.name}" created, id={doc.id}') + + # Determine entry content from --file or --text + content = None + if file: + file = os.path.expanduser(file) + with open(file, "r") as f: + content = f.read() + + # Check if creating the doc already produced a default entry + entries = logbook_api.get_log_entries(log_document_id=doc.id) + + if content: + if entries: + entry = entries[0] + entry.log_entry = content + else: + entry = logbook_api.get_log_entry_template(log_document_id=doc.id) + entry.log_entry = content + entry = logbook_api.add_update_log_entry(log_entry=entry) + print(f"Log entry added, log_id={entry.log_id}") + elif entries: + entry = entries[0] + print(f"Default entry (log_id={entry.log_id}):") + print(entry.log_entry) + + +def cmd_list_docs(limit): + """List recent log documents created by the current user.""" + username = auth.get_username() + if not username: + print("Error: cannot determine username. Set BELY_USER or 'user' in settings.", + file=sys.stderr) + sys.exit(1) + + factory = auth.get_factory() + users_api = factory.get_users_api() + try: + user_info = users_api.get_user_by_username(username=username) + except Exception as e: + print(f"Error: could not look up user '{username}': {e}", file=sys.stderr) + sys.exit(1) + + search_api = factory.get_search_api() + results = search_api.search_logbook(search_text="*", user_id=[user_info.id]) + + docs = results.document_results or [] + # Sort by last_modified_on descending + docs.sort(key=lambda d: d.last_modified_on or "", reverse=True) + docs = docs[:limit] + + if not docs: + print("No documents found.") + return + + print(f"{'ID':<8} {'Name':<50} {'Type':<15} {'Last Modified'}") + print(f"{'--':<8} {'----':<50} {'----':<15} {'-------------'}") + for d in docs: + modified = d.last_modified_on.strftime("%Y-%m-%d %H:%M") if d.last_modified_on else "" + doc_type = d.logbook_type or "" + name = d.object_name or "" + print(f"{d.object_id:<8} {name:<50} {doc_type:<15} {modified}") + + +def cmd_update_entry(name, entry_id, file, text, add_attachment): + """Update an existing log entry.""" + if file and text: + print("Error: --file and --text are mutually exclusive.", file=sys.stderr) + sys.exit(1) + if not name: + print("Error: --name is required.", file=sys.stderr) + sys.exit(1) + + # Validate files exist before any network calls + if file: + file = os.path.expanduser(file) + if not os.path.isfile(file): + print(f"Error: file not found: {file}", file=sys.stderr) + sys.exit(1) + if add_attachment: + add_attachment = os.path.expanduser(add_attachment) + if not os.path.isfile(add_attachment): + print(f"Error: attachment file not found: {add_attachment}", file=sys.stderr) + sys.exit(1) + + # Determine content + content = None + if file: + with open(file, "r") as f: + content = f.read() + elif text: + content = text + + # Look up document (unauthenticated) + factory = auth.get_factory() + logbook_api = factory.get_logbook_api() + doc = find_logdoc(logbook_api, name) + if not doc: + print(f'Error: log document "{name}" not found.', file=sys.stderr) + sys.exit(1) + + # Authenticate and find/update entry + with auth.get_authenticated_factory() as auth_factory: + logbook_api = auth_factory.get_logbook_api() + entries = logbook_api.get_log_entries(log_document_id=doc.id) + + if entry_id: + # Find specific entry by log_id + entry = None + for e in entries: + if e.log_id == entry_id: + entry = e + break + if not entry: + print(f'Error: entry with log_id={entry_id} not found in document "{name}".', + file=sys.stderr) + sys.exit(1) + else: + # Find last entry by current user + username = auth.get_username() + if not username: + print("Error: cannot determine username. Set BELY_USER or 'user' in settings.", + file=sys.stderr) + sys.exit(1) + user_entries = [e for e in entries + if e.entered_by_username + and e.entered_by_username.lower() == username.lower()] + if not user_entries: + print(f'Error: no entries by user "{username}" found in document "{name}".', + file=sys.stderr) + sys.exit(1) + entry = user_entries[-1] + + # Update entry content + if content: + entry.log_entry = content + entry = logbook_api.add_update_log_entry(log_entry=entry) + print(f'Log entry updated in "{doc.name}", log_id={entry.log_id}') + elif add_attachment: + logbook_api.upload_attachment( + log_document_id=doc.id, + log_id=entry.log_id, + body=add_attachment, + append_reference=True, + file_name=os.path.basename(add_attachment), + ) + print(f'Attachment "{os.path.basename(add_attachment)}" uploaded') + else: + # Interactive edit: open entry in editor + original = entry.log_entry or "" + with tempfile.NamedTemporaryFile(suffix=".md", mode="w", delete=False) as tmp: + tmp.write(original) + tmp_path = tmp.name + try: + editor = os.environ.get("EDITOR", "vi") + subprocess.call([editor, tmp_path]) + with open(tmp_path, "r") as f: + edited = f.read() + finally: + os.unlink(tmp_path) + if edited != original: + entry.log_entry = edited + entry = logbook_api.add_update_log_entry(log_entry=entry) + print(f'Log entry updated in "{doc.name}", log_id={entry.log_id}') + else: + print("No changes made.") + + +def cmd_add_entry(name, file, text, add_attachment): + """Add a new log entry to an existing document.""" + if file and text: + print("Error: --file and --text are mutually exclusive.", file=sys.stderr) + sys.exit(1) + if not file and not text and not add_attachment: + print("Error: at least one of --file, --text, or --add-attachment is required.", + file=sys.stderr) + sys.exit(1) + + # Validate files exist before any network calls + if file: + file = os.path.expanduser(file) + if not os.path.isfile(file): + print(f"Error: file not found: {file}", file=sys.stderr) + sys.exit(1) + if add_attachment: + add_attachment = os.path.expanduser(add_attachment) + if not os.path.isfile(add_attachment): + print(f"Error: attachment file not found: {add_attachment}", file=sys.stderr) + sys.exit(1) + + # Determine content + content = None + if file: + with open(file, "r") as f: + content = f.read() + elif text: + content = text + + # Look up document (unauthenticated) + factory = auth.get_factory() + logbook_api = factory.get_logbook_api() + doc = find_logdoc(logbook_api, name) + if not doc: + print(f'Error: log document "{name}" not found.', file=sys.stderr) + sys.exit(1) + + # Authenticate and create entry + with auth.get_authenticated_factory() as auth_factory: + logbook_api = auth_factory.get_logbook_api() + + entry = logbook_api.get_log_entry_template(log_document_id=doc.id) + entry.log_entry = content or "" + entry = logbook_api.add_update_log_entry(log_entry=entry) + print(f'Log entry added to "{doc.name}", log_id={entry.log_id}') + + if add_attachment: + logbook_api.upload_attachment( + log_document_id=doc.id, + log_id=entry.log_id, + body=add_attachment, + append_reference=True, + file_name=os.path.basename(add_attachment), + ) + print(f'Attachment "{os.path.basename(add_attachment)}" uploaded') + + +def cmd_list_types(): + """List all logbook types.""" + factory = auth.get_factory() + logbook_api = factory.get_logbook_api() + types = logbook_api.get_logbook_types() + + if not types: + print("No logbook types found.") + return + + print(f"{'ID':<6} {'Name':<20} {'Display Name':<30} {'Description'}") + print(f"{'--':<6} {'----':<20} {'------------':<30} {'-----------'}") + for t in types: + desc = t.description or "" + print(f"{t.id:<6} {t.name:<20} {t.display_name:<30} {desc}") + + +def cmd_list_systems(): + """List all logbook systems.""" + factory = auth.get_factory() + logbook_api = factory.get_logbook_api() + systems = logbook_api.get_logbook_systems() + + if not systems: + print("No logbook systems found.") + return + + print(f"{'ID':<6} {'Name':<30} {'Description'}") + print(f"{'--':<6} {'----':<30} {'-----------'}") + for s in systems: + desc = s.description or "" + print(f"{s.id:<6} {s.name:<30} {desc}") + + +def cmd_list_templates(): + """List all logbook templates.""" + factory = auth.get_factory() + logbook_api = factory.get_logbook_api() + templates = logbook_api.get_logbook_templates() + + if not templates: + print("No logbook templates found.") + return + + print(f"{'ID':<6} {'Name':<30} {'Description'}") + print(f"{'--':<6} {'----':<30} {'-----------'}") + for t in templates: + desc = t.description or "" + print(f"{t.id:<6} {t.name:<30} {desc}") diff --git a/tools/developer_tools/bely-cli/install.txt b/tools/developer_tools/bely-cli/install.txt new file mode 100644 index 000000000..330dabfcb --- /dev/null +++ b/tools/developer_tools/bely-cli/install.txt @@ -0,0 +1 @@ +bely diff --git a/tools/developer_tools/bely-cli/settings.py b/tools/developer_tools/bely-cli/settings.py new file mode 100644 index 000000000..9724446ed --- /dev/null +++ b/tools/developer_tools/bely-cli/settings.py @@ -0,0 +1,39 @@ +import os +import yaml + +CONFIG_DIR = os.path.expanduser("~/.config/bely") +SETTINGS_FILE = os.path.join(CONFIG_DIR, "settings.yaml") + + +def _ensure_config_dir(): + if not os.path.exists(CONFIG_DIR): + os.makedirs(CONFIG_DIR, mode=0o700) + + +def load_settings(): + """Read settings.yaml and return as a dict (empty dict if missing).""" + try: + with open(SETTINGS_FILE, "r") as f: + return yaml.safe_load(f) or {} + except FileNotFoundError: + return {} + + +def save_settings(data): + """Write a dict to settings.yaml, creating the config dir if needed.""" + _ensure_config_dir() + with open(SETTINGS_FILE, "w") as f: + yaml.dump(data, f, default_flow_style=False) + os.chmod(SETTINGS_FILE, 0o600) + + +def get_setting(key): + """Get a single setting value, or None if not set.""" + return load_settings().get(key) + + +def set_setting(key, value): + """Update a single setting and save.""" + data = load_settings() + data[key] = value + save_settings(data) From 8308842f5a248d736d267765aeca3b5216fc5d61 Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Fri, 8 May 2026 16:49:56 -0500 Subject: [PATCH 02/62] moving entry code to different file, clean up argument names, prompt user didn't specify certain options --- tools/developer_tools/bely-cli/bely | 50 +++-- tools/developer_tools/bely-cli/commands.py | 198 +++-------------- tools/developer_tools/bely-cli/entry.py | 234 +++++++++++++++++++++ 3 files changed, 302 insertions(+), 180 deletions(-) create mode 100644 tools/developer_tools/bely-cli/entry.py diff --git a/tools/developer_tools/bely-cli/bely b/tools/developer_tools/bely-cli/bely index 44aa3c453..053e28028 100755 --- a/tools/developer_tools/bely-cli/bely +++ b/tools/developer_tools/bely-cli/bely @@ -1,18 +1,22 @@ -#!/home/phoebus/ECHANDLER/sandbox/hla/bely-client/conda/bin/python +#!/Users/echandler/sandbox/auxiliary-scripts/scripts/bely-cli/conda/bin/python import click from commands import ( cmd_new_doc, cmd_list_docs, - cmd_update_entry, - cmd_add_entry, cmd_list_types, cmd_list_systems, cmd_list_templates, cmd_show_config, cmd_edit_config, ) +from entry import ( + cmd_add_entry, + cmd_get_entry, + cmd_list_entries, + cmd_update_entry, +) CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) @@ -33,9 +37,9 @@ def doc_group(): @doc_group.command("new") -@click.option("--type", "type_", required=True, help="Logbook type (e.g. ops, controls)") -@click.option("--name", required=True, help="Name for the new document") -@click.option("--file", "file", default=None, help="Markdown file for the first log entry") +@click.option("--type", "type_", default=None, help="Logbook type (e.g. ops, controls)") +@click.option("--name", "-n", default=None, help="Name for the new document") +@click.option("--file", "-f", "file", default=None, help="Markdown file for the first log entry") @click.option("--template", default=None, help="Template name to use") @click.option("--systems", default=None, help="Comma-separated system list (e.g. SR,software)") @click.option("--no-template", is_flag=True, help="Skip template selection") @@ -61,9 +65,10 @@ def entry_group(): @entry_group.command("add") -@click.option("--name", required=True, help="Log document name") -@click.option("--file", "file", default=None, help="Markdown file with entry content") -@click.option("--text", default=None, help="Inline text for the entry") +@click.option("--doc-name", "-n", default=None, help="Log document name") +@click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") +@click.option("--file", "-f", "file", default=None, help="Markdown file with entry content") +@click.option("--text", "-t", default=None, help="Inline text for the entry") @click.option("--add-attachment", default=None, help="File to attach to the entry") def entry_add(**kwargs): """Add a new log entry to an existing document.""" @@ -71,16 +76,35 @@ def entry_add(**kwargs): @entry_group.command("update") -@click.option("--name", default=None, help="Log document name") -@click.option("--entry-id", default=None, type=int, help="Specific log entry ID to update") -@click.option("--file", "file", default=None, help="Markdown file with updated content") -@click.option("--text", default=None, help="Inline text for the entry") +@click.option("--doc-name", "-n", default=None, help="Log document name") +@click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") +@click.option("--id", "entry_id", default=None, type=int, help="Specific log entry ID to update") +@click.option("--file", "-f", "file", default=None, help="Markdown file with updated content") +@click.option("--text", "-t", default=None, help="Inline text for the entry") @click.option("--add-attachment", default=None, help="File to attach to the entry") def entry_update(**kwargs): """Update an existing log entry.""" cmd_update_entry(**kwargs) +@entry_group.command("list") +@click.option("--doc-name", "-n", default=None, help="Log document name") +@click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") +def entry_list(**kwargs): + """List entries in a log document.""" + cmd_list_entries(**kwargs) + + +@entry_group.command("get") +@click.option("--doc-name", "-n", default=None, help="Log document name") +@click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") +@click.option("--id", "entry_id", default=None, type=int, help="Specific log entry ID (default: latest)") +@click.option("--output", "-o", default=None, help="Output file (default: entry_.md)") +def entry_get(**kwargs): + """Write the markdown of a log entry to a file (latest by default).""" + cmd_get_entry(**kwargs) + + # -- type -- @cli.group("type") diff --git a/tools/developer_tools/bely-cli/commands.py b/tools/developer_tools/bely-cli/commands.py index b4e2f52d8..693c49a23 100644 --- a/tools/developer_tools/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/commands.py @@ -1,7 +1,5 @@ import os -import subprocess import sys -import tempfile import belyApi @@ -96,6 +94,34 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, no_prompt): factory = auth.get_factory() logbook_api = factory.get_logbook_api() + # Prompt for required fields if missing + if not type_: + if no_prompt: + print("Error: --type is required.", file=sys.stderr) + sys.exit(1) + types = logbook_api.get_logbook_types() + print("Available logbook types:") + for i, t in enumerate(types, 1): + print(f" {i}) {t.name} ({t.display_name})") + choice = input("Select type (number or name): ").strip() + if choice.isdigit(): + idx = int(choice) - 1 + if not (0 <= idx < len(types)): + print("Error: invalid selection.", file=sys.stderr) + sys.exit(1) + type_ = types[idx].name + else: + type_ = choice + + if not name: + if no_prompt: + print("Error: --name is required.", file=sys.stderr) + sys.exit(1) + name = input("Document name: ").strip() + if not name: + print("Error: name cannot be empty.", file=sys.stderr) + sys.exit(1) + try: logbook_type = find_logbook_type(logbook_api, type_) system_id_list = find_systems(logbook_api, systems) if systems else None @@ -145,9 +171,9 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, no_prompt): entry = logbook_api.add_update_log_entry(log_entry=entry) print(f"Log entry added, log_id={entry.log_id}") elif entries: - entry = entries[0] - print(f"Default entry (log_id={entry.log_id}):") - print(entry.log_entry) + from entry import write_entry_to_file + print(f"Template generated a default log entry (log_id={entries[0].log_id})") + write_entry_to_file(entries[0]) def cmd_list_docs(limit): @@ -187,168 +213,6 @@ def cmd_list_docs(limit): print(f"{d.object_id:<8} {name:<50} {doc_type:<15} {modified}") -def cmd_update_entry(name, entry_id, file, text, add_attachment): - """Update an existing log entry.""" - if file and text: - print("Error: --file and --text are mutually exclusive.", file=sys.stderr) - sys.exit(1) - if not name: - print("Error: --name is required.", file=sys.stderr) - sys.exit(1) - - # Validate files exist before any network calls - if file: - file = os.path.expanduser(file) - if not os.path.isfile(file): - print(f"Error: file not found: {file}", file=sys.stderr) - sys.exit(1) - if add_attachment: - add_attachment = os.path.expanduser(add_attachment) - if not os.path.isfile(add_attachment): - print(f"Error: attachment file not found: {add_attachment}", file=sys.stderr) - sys.exit(1) - - # Determine content - content = None - if file: - with open(file, "r") as f: - content = f.read() - elif text: - content = text - - # Look up document (unauthenticated) - factory = auth.get_factory() - logbook_api = factory.get_logbook_api() - doc = find_logdoc(logbook_api, name) - if not doc: - print(f'Error: log document "{name}" not found.', file=sys.stderr) - sys.exit(1) - - # Authenticate and find/update entry - with auth.get_authenticated_factory() as auth_factory: - logbook_api = auth_factory.get_logbook_api() - entries = logbook_api.get_log_entries(log_document_id=doc.id) - - if entry_id: - # Find specific entry by log_id - entry = None - for e in entries: - if e.log_id == entry_id: - entry = e - break - if not entry: - print(f'Error: entry with log_id={entry_id} not found in document "{name}".', - file=sys.stderr) - sys.exit(1) - else: - # Find last entry by current user - username = auth.get_username() - if not username: - print("Error: cannot determine username. Set BELY_USER or 'user' in settings.", - file=sys.stderr) - sys.exit(1) - user_entries = [e for e in entries - if e.entered_by_username - and e.entered_by_username.lower() == username.lower()] - if not user_entries: - print(f'Error: no entries by user "{username}" found in document "{name}".', - file=sys.stderr) - sys.exit(1) - entry = user_entries[-1] - - # Update entry content - if content: - entry.log_entry = content - entry = logbook_api.add_update_log_entry(log_entry=entry) - print(f'Log entry updated in "{doc.name}", log_id={entry.log_id}') - elif add_attachment: - logbook_api.upload_attachment( - log_document_id=doc.id, - log_id=entry.log_id, - body=add_attachment, - append_reference=True, - file_name=os.path.basename(add_attachment), - ) - print(f'Attachment "{os.path.basename(add_attachment)}" uploaded') - else: - # Interactive edit: open entry in editor - original = entry.log_entry or "" - with tempfile.NamedTemporaryFile(suffix=".md", mode="w", delete=False) as tmp: - tmp.write(original) - tmp_path = tmp.name - try: - editor = os.environ.get("EDITOR", "vi") - subprocess.call([editor, tmp_path]) - with open(tmp_path, "r") as f: - edited = f.read() - finally: - os.unlink(tmp_path) - if edited != original: - entry.log_entry = edited - entry = logbook_api.add_update_log_entry(log_entry=entry) - print(f'Log entry updated in "{doc.name}", log_id={entry.log_id}') - else: - print("No changes made.") - - -def cmd_add_entry(name, file, text, add_attachment): - """Add a new log entry to an existing document.""" - if file and text: - print("Error: --file and --text are mutually exclusive.", file=sys.stderr) - sys.exit(1) - if not file and not text and not add_attachment: - print("Error: at least one of --file, --text, or --add-attachment is required.", - file=sys.stderr) - sys.exit(1) - - # Validate files exist before any network calls - if file: - file = os.path.expanduser(file) - if not os.path.isfile(file): - print(f"Error: file not found: {file}", file=sys.stderr) - sys.exit(1) - if add_attachment: - add_attachment = os.path.expanduser(add_attachment) - if not os.path.isfile(add_attachment): - print(f"Error: attachment file not found: {add_attachment}", file=sys.stderr) - sys.exit(1) - - # Determine content - content = None - if file: - with open(file, "r") as f: - content = f.read() - elif text: - content = text - - # Look up document (unauthenticated) - factory = auth.get_factory() - logbook_api = factory.get_logbook_api() - doc = find_logdoc(logbook_api, name) - if not doc: - print(f'Error: log document "{name}" not found.', file=sys.stderr) - sys.exit(1) - - # Authenticate and create entry - with auth.get_authenticated_factory() as auth_factory: - logbook_api = auth_factory.get_logbook_api() - - entry = logbook_api.get_log_entry_template(log_document_id=doc.id) - entry.log_entry = content or "" - entry = logbook_api.add_update_log_entry(log_entry=entry) - print(f'Log entry added to "{doc.name}", log_id={entry.log_id}') - - if add_attachment: - logbook_api.upload_attachment( - log_document_id=doc.id, - log_id=entry.log_id, - body=add_attachment, - append_reference=True, - file_name=os.path.basename(add_attachment), - ) - print(f'Attachment "{os.path.basename(add_attachment)}" uploaded') - - def cmd_list_types(): """List all logbook types.""" factory = auth.get_factory() diff --git a/tools/developer_tools/bely-cli/entry.py b/tools/developer_tools/bely-cli/entry.py new file mode 100644 index 000000000..706ee953d --- /dev/null +++ b/tools/developer_tools/bely-cli/entry.py @@ -0,0 +1,234 @@ +import os +import subprocess +import sys +import tempfile +from types import SimpleNamespace + +import auth +from commands import find_logdoc + + +def resolve_doc(logbook_api, doc_name, doc_id): + """Resolve a document by name or ID. Exits on error.""" + if doc_name and doc_id: + print("Error: --doc-name and --doc-id are mutually exclusive.", file=sys.stderr) + sys.exit(1) + if not doc_name and not doc_id: + print("Error: --doc-name or --doc-id is required.", file=sys.stderr) + sys.exit(1) + if doc_id: + return SimpleNamespace(id=doc_id, name=f"id={doc_id}") + doc = find_logdoc(logbook_api, doc_name) + if not doc: + print(f'Error: log document "{doc_name}" not found.', file=sys.stderr) + sys.exit(1) + return doc + + +def write_entry_to_file(entry, output=None): + """Write entry markdown to a file. Returns the path written.""" + out_path = os.path.expanduser(output) if output else f"entry_{entry.log_id}.md" + with open(out_path, "w") as f: + f.write(entry.log_entry or "") + print(f'Wrote log entry id={entry.log_id} to {out_path}') + return out_path + + +def upload_and_print_attachment(logbook_api, doc_id, log_id, path): + basename = os.path.basename(path) + att = logbook_api.upload_attachment( + log_document_id=doc_id, + log_id=log_id, + body=path, + append_reference=True, + file_name=basename, + ) + print(f'Attachment "{basename}" uploaded') + print(f" original_filename: {att.original_filename}") + print(f" stored_filename: {att.stored_filename}") + print(f" download_path: {att.download_path}") + print(f" markdown_reference: {att.markdown_reference}") + + +def cmd_update_entry(doc_name, doc_id, entry_id, file, text, add_attachment): + """Update an existing log entry.""" + if file and text: + print("Error: --file and --text are mutually exclusive.", file=sys.stderr) + sys.exit(1) + + # Validate files exist before any network calls + if file: + file = os.path.expanduser(file) + if not os.path.isfile(file): + print(f"Error: file not found: {file}", file=sys.stderr) + sys.exit(1) + if add_attachment: + add_attachment = os.path.expanduser(add_attachment) + if not os.path.isfile(add_attachment): + print(f"Error: attachment file not found: {add_attachment}", file=sys.stderr) + sys.exit(1) + + # Determine content + content = None + if file: + with open(file, "r") as f: + content = f.read() + elif text: + content = text + + # Resolve document (unauthenticated) + factory = auth.get_factory() + logbook_api = factory.get_logbook_api() + doc = resolve_doc(logbook_api, doc_name, doc_id) + + # Authenticate and find/update entry + with auth.get_authenticated_factory() as auth_factory: + logbook_api = auth_factory.get_logbook_api() + entries = logbook_api.get_log_entries(log_document_id=doc.id) + + if entry_id: + # Find specific entry by log_id + entry = None + for e in entries: + if e.log_id == entry_id: + entry = e + break + if not entry: + print(f'Error: entry with log_id={entry_id} not found in document "{doc.name}".', + file=sys.stderr) + sys.exit(1) + else: + # Find last entry by current user + username = auth.get_username() + if not username: + print("Error: cannot determine username. Set BELY_USER or 'user' in settings.", + file=sys.stderr) + sys.exit(1) + user_entries = [e for e in entries + if e.entered_by_username + and e.entered_by_username.lower() == username.lower()] + if not user_entries: + print(f'Error: no entries by user "{username}" found in document "{doc.name}".', + file=sys.stderr) + sys.exit(1) + entry = user_entries[-1] + + # Update entry content + if content: + entry.log_entry = content + entry = logbook_api.add_update_log_entry(log_entry=entry) + print(f'Log entry updated in "{doc.name}", log_id={entry.log_id}') + elif add_attachment: + upload_and_print_attachment(logbook_api, doc.id, entry.log_id, add_attachment) + else: + # Interactive edit: open entry in editor + original = entry.log_entry or "" + with tempfile.NamedTemporaryFile(suffix=".md", mode="w", delete=False) as tmp: + tmp.write(original) + tmp_path = tmp.name + try: + editor = os.environ.get("EDITOR", "vi") + subprocess.call([editor, tmp_path]) + with open(tmp_path, "r") as f: + edited = f.read() + finally: + os.unlink(tmp_path) + if edited != original: + entry.log_entry = edited + entry = logbook_api.add_update_log_entry(log_entry=entry) + print(f'Log entry updated in "{doc.name}", log_id={entry.log_id}') + else: + print("No changes made.") + + +def cmd_add_entry(doc_name, doc_id, file, text, add_attachment): + """Add a new log entry to an existing document.""" + if file and text: + print("Error: --file and --text are mutually exclusive.", file=sys.stderr) + sys.exit(1) + if not file and not text and not add_attachment: + print("Error: at least one of --file, --text, or --add-attachment is required.", + file=sys.stderr) + sys.exit(1) + + # Validate files exist before any network calls + if file: + file = os.path.expanduser(file) + if not os.path.isfile(file): + print(f"Error: file not found: {file}", file=sys.stderr) + sys.exit(1) + if add_attachment: + add_attachment = os.path.expanduser(add_attachment) + if not os.path.isfile(add_attachment): + print(f"Error: attachment file not found: {add_attachment}", file=sys.stderr) + sys.exit(1) + + # Determine content + content = None + if file: + with open(file, "r") as f: + content = f.read() + elif text: + content = text + + # Resolve document (unauthenticated) + factory = auth.get_factory() + logbook_api = factory.get_logbook_api() + doc = resolve_doc(logbook_api, doc_name, doc_id) + + # Authenticate and create entry + with auth.get_authenticated_factory() as auth_factory: + logbook_api = auth_factory.get_logbook_api() + + entry = logbook_api.get_log_entry_template(log_document_id=doc.id) + entry.log_entry = content or "" + entry = logbook_api.add_update_log_entry(log_entry=entry) + print(f'Log entry added to "{doc.name}", log_id={entry.log_id}') + + if add_attachment: + upload_and_print_attachment(logbook_api, doc.id, entry.log_id, add_attachment) + + +def cmd_list_entries(doc_name, doc_id): + """List entries in a log document.""" + factory = auth.get_factory() + logbook_api = factory.get_logbook_api() + doc = resolve_doc(logbook_api, doc_name, doc_id) + entries = logbook_api.get_log_entries(log_document_id=doc.id) + + if not entries: + print(f'No entries found in document {doc.name}.') + return + + print(f"{'Log ID':<10} {'Date':<18} {'Author':<20} {'Snippet'}") + print(f"{'------':<10} {'----':<18} {'------':<20} {'-------'}") + for e in entries: + date = e.entered_on_date_time.strftime("%Y-%m-%d %H:%M") if e.entered_on_date_time else "" + author = e.entered_by_username or "" + snippet = (e.log_entry or "").strip().splitlines()[0] if e.log_entry else "" + if len(snippet) > 60: + snippet = snippet[:57] + "..." + print(f"{e.log_id:<10} {date:<18} {author:<20} {snippet}") + + +def cmd_get_entry(doc_name, doc_id, entry_id, output): + """Write the markdown of a log entry to a file (latest by default).""" + factory = auth.get_factory() + logbook_api = factory.get_logbook_api() + doc = resolve_doc(logbook_api, doc_name, doc_id) + entries = logbook_api.get_log_entries(log_document_id=doc.id) + + if not entries: + print(f'No entries found in document {doc.name}.', file=sys.stderr) + sys.exit(1) + + if entry_id: + entry = next((e for e in entries if e.log_id == entry_id), None) + if not entry: + print(f'Error: entry with log_id={entry_id} not found in document {doc.name}.', + file=sys.stderr) + sys.exit(1) + else: + entry = entries[-1] + + write_entry_to_file(entry, output) From 72e347a62439127180552050a556b665cb8f8eb3 Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Fri, 8 May 2026 17:44:38 -0500 Subject: [PATCH 03/62] bely-cli: cache login session --- tools/developer_tools/bely-cli/auth.py | 78 +++++++++++++++++++------- 1 file changed, 57 insertions(+), 21 deletions(-) diff --git a/tools/developer_tools/bely-cli/auth.py b/tools/developer_tools/bely-cli/auth.py index 91d09f492..a1b4c5d90 100644 --- a/tools/developer_tools/bely-cli/auth.py +++ b/tools/developer_tools/bely-cli/auth.py @@ -6,7 +6,9 @@ import belyApi from BelyApiFactory import BelyApiFactory -from settings import get_setting +from settings import CONFIG_DIR, _ensure_config_dir, get_setting + +TOKEN_FILE = os.path.join(CONFIG_DIR, "token") def get_host(): @@ -40,31 +42,40 @@ def get_password(username): return password +def load_token(): + """Return the cached auth token from disk, or None if not present.""" + try: + with open(TOKEN_FILE, "r") as f: + return f.read().strip() or None + except FileNotFoundError: + return None -def get_factory(): - """Create and return an unauthenticated BelyApiFactory.""" - return BelyApiFactory(bely_url=get_host()) +def save_token(token): + """Persist the auth token to disk with restrictive permissions.""" + _ensure_config_dir() + with open(TOKEN_FILE, "w") as f: + f.write(token) + os.chmod(TOKEN_FILE, 0o600) -@contextmanager -def get_authenticated_factory(): - """Create and yield an authenticated BelyApiFactory, logging out on exit. - Usage:: +def delete_token(): + """Remove the cached token file.""" + try: + os.remove(TOKEN_FILE) + except FileNotFoundError: + pass - with auth.get_authenticated_factory() as factory: - logbook_api = factory.get_logbook_api() - ... - Credentials come from: - 1. BELY_USER + BELY_PASSWORD env vars - 2. Interactive prompt - """ - factory = BelyApiFactory(bely_url=get_host()) +def get_factory(): + """Create and return an unauthenticated BelyApiFactory.""" + return BelyApiFactory(bely_url=get_host()) + +def _login_and_cache(factory): + """Prompt for credentials, authenticate the factory, and persist the new token.""" username = get_username() password = get_password(username) - try: factory.authenticate_user(username, password) except belyApi.exceptions.UnauthorizedException: @@ -74,8 +85,33 @@ def get_authenticated_factory(): except Exception as e: print(f"Authentication failed: {e}", file=sys.stderr) sys.exit(1) + save_token(factory.get_authenticate_token()) - try: - yield factory - finally: - factory.logout_user() + +@contextmanager +def get_authenticated_factory(): + """Create and yield an authenticated BelyApiFactory. + + Uses a cached token from disk if it is still valid; otherwise prompts + for credentials and saves the new token. The token persists across + runs, so we deliberately do not log out on exit. + + Credentials, when needed, come from: + 1. BELY_USER + BELY_PASSWORD env vars + 2. Interactive prompt + """ + factory = BelyApiFactory(bely_url=get_host()) + + token = load_token() + if token: + factory.api_client.set_default_header(BelyApiFactory.HEADER_TOKEN_KEY, token) + try: + factory.test_authenticated() + except belyApi.exceptions.UnauthorizedException: + delete_token() + factory.api_client.default_headers.pop(BelyApiFactory.HEADER_TOKEN_KEY, None) + _login_and_cache(factory) + else: + _login_and_cache(factory) + + yield factory From 5c8839256af4627e2368b95b4256eb2c488cbcf6 Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Fri, 8 May 2026 17:53:14 -0500 Subject: [PATCH 04/62] bely-api: add config set command --- tools/developer_tools/bely-cli/bely | 10 ++++++++++ tools/developer_tools/bely-cli/commands.py | 10 ++++++++++ tools/developer_tools/bely-cli/settings.py | 2 ++ 3 files changed, 22 insertions(+) diff --git a/tools/developer_tools/bely-cli/bely b/tools/developer_tools/bely-cli/bely index 053e28028..29aabf64e 100755 --- a/tools/developer_tools/bely-cli/bely +++ b/tools/developer_tools/bely-cli/bely @@ -2,6 +2,7 @@ import click +from settings import VALID_FIELDS from commands import ( cmd_new_doc, cmd_list_docs, @@ -10,6 +11,7 @@ from commands import ( cmd_list_templates, cmd_show_config, cmd_edit_config, + cmd_set_config, ) from entry import ( cmd_add_entry, @@ -167,6 +169,14 @@ def config_edit(): cmd_edit_config() +@config_group.command("set") +@click.argument("field", type=click.Choice(VALID_FIELDS)) +@click.argument("value") +def config_set(field, value): + """Set a configuration field to a value.""" + cmd_set_config(field, value) + + if __name__ == "__main__": cli() diff --git a/tools/developer_tools/bely-cli/commands.py b/tools/developer_tools/bely-cli/commands.py index 693c49a23..2cb9b6ead 100644 --- a/tools/developer_tools/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/commands.py @@ -83,6 +83,16 @@ def cmd_edit_config(): os.execvp(editor, [editor, settings.SETTINGS_FILE]) +def cmd_set_config(field, value): + """Set a single configuration field in settings.yaml.""" + if field not in settings.VALID_FIELDS: + valid = ", ".join(settings.VALID_FIELDS) + print(f"Error: unknown field '{field}'. Valid fields: {valid}", file=sys.stderr) + sys.exit(1) + settings.set_setting(field, value) + print(f"Set {field} = {value}") + + def cmd_new_doc(type_, name, file, template, systems, no_template, no_prompt): """Create a new log document, optionally adding a first log entry.""" diff --git a/tools/developer_tools/bely-cli/settings.py b/tools/developer_tools/bely-cli/settings.py index 9724446ed..1ded10d67 100644 --- a/tools/developer_tools/bely-cli/settings.py +++ b/tools/developer_tools/bely-cli/settings.py @@ -4,6 +4,8 @@ CONFIG_DIR = os.path.expanduser("~/.config/bely") SETTINGS_FILE = os.path.join(CONFIG_DIR, "settings.yaml") +VALID_FIELDS = ("host", "user") + def _ensure_config_dir(): if not os.path.exists(CONFIG_DIR): From d68e714c7a407dfa885929889ab09c34f5c17efc Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Wed, 13 May 2026 16:02:50 -0500 Subject: [PATCH 05/62] renaming settings.py to config.py --- tools/developer_tools/bely-cli/auth.py | 2 +- tools/developer_tools/bely-cli/bely | 2 +- tools/developer_tools/bely-cli/commands.py | 20 +++++++++---------- .../bely-cli/{settings.py => config.py} | 0 4 files changed, 12 insertions(+), 12 deletions(-) rename tools/developer_tools/bely-cli/{settings.py => config.py} (100%) diff --git a/tools/developer_tools/bely-cli/auth.py b/tools/developer_tools/bely-cli/auth.py index a1b4c5d90..3534d83fa 100644 --- a/tools/developer_tools/bely-cli/auth.py +++ b/tools/developer_tools/bely-cli/auth.py @@ -6,7 +6,7 @@ import belyApi from BelyApiFactory import BelyApiFactory -from settings import CONFIG_DIR, _ensure_config_dir, get_setting +from config import CONFIG_DIR, _ensure_config_dir, get_setting TOKEN_FILE = os.path.join(CONFIG_DIR, "token") diff --git a/tools/developer_tools/bely-cli/bely b/tools/developer_tools/bely-cli/bely index 29aabf64e..5316ec6c3 100755 --- a/tools/developer_tools/bely-cli/bely +++ b/tools/developer_tools/bely-cli/bely @@ -2,7 +2,7 @@ import click -from settings import VALID_FIELDS +from config import VALID_FIELDS from commands import ( cmd_new_doc, cmd_list_docs, diff --git a/tools/developer_tools/bely-cli/commands.py b/tools/developer_tools/bely-cli/commands.py index 2cb9b6ead..dced75954 100644 --- a/tools/developer_tools/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/commands.py @@ -4,7 +4,7 @@ import belyApi import auth -import settings +import config ENV_VARS = ["BELY_HOST", "BELY_USER", "BELY_PASSWORD"] @@ -12,8 +12,8 @@ def cmd_show_config(): """Show current configuration from settings file and environment.""" - print(f"Settings file: {settings.SETTINGS_FILE}") - data = settings.load_settings() + print(f"Settings file: {config.SETTINGS_FILE}") + data = config.load_settings() if data: for key, value in data.items(): print(f" {key} = {value}") @@ -76,20 +76,20 @@ def find_logdoc(logbook_api, name): def cmd_edit_config(): """Open the settings file in the user's editor.""" - settings._ensure_config_dir() - if not os.path.exists(settings.SETTINGS_FILE): - settings.save_settings({}) + config._ensure_config_dir() + if not os.path.exists(config.SETTINGS_FILE): + config.save_settings({}) editor = os.environ.get("EDITOR", "vi") - os.execvp(editor, [editor, settings.SETTINGS_FILE]) + os.execvp(editor, [editor, config.SETTINGS_FILE]) def cmd_set_config(field, value): """Set a single configuration field in settings.yaml.""" - if field not in settings.VALID_FIELDS: - valid = ", ".join(settings.VALID_FIELDS) + if field not in config.VALID_FIELDS: + valid = ", ".join(config.VALID_FIELDS) print(f"Error: unknown field '{field}'. Valid fields: {valid}", file=sys.stderr) sys.exit(1) - settings.set_setting(field, value) + config.set_setting(field, value) print(f"Set {field} = {value}") diff --git a/tools/developer_tools/bely-cli/settings.py b/tools/developer_tools/bely-cli/config.py similarity index 100% rename from tools/developer_tools/bely-cli/settings.py rename to tools/developer_tools/bely-cli/config.py From d65cb476c5e6efa11eabb82b7494d06d07d3bfb6 Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Wed, 13 May 2026 16:34:13 -0500 Subject: [PATCH 06/62] add -o option for writing files --- tools/developer_tools/bely-cli/bely | 5 ++++- tools/developer_tools/bely-cli/commands.py | 4 ++-- tools/developer_tools/bely-cli/entry.py | 14 +++++++++----- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/tools/developer_tools/bely-cli/bely b/tools/developer_tools/bely-cli/bely index 5316ec6c3..4ab023a2b 100755 --- a/tools/developer_tools/bely-cli/bely +++ b/tools/developer_tools/bely-cli/bely @@ -46,6 +46,8 @@ def doc_group(): @click.option("--systems", default=None, help="Comma-separated system list (e.g. SR,software)") @click.option("--no-template", is_flag=True, help="Skip template selection") @click.option("--no-prompt", is_flag=True, help="Non-interactive mode (no prompts)") +@click.option("--output", "-o", "output_dir", default=None, + help="Directory to write template-generated entry into (default: cwd)") def doc_new(**kwargs): """Create a new log document.""" cmd_new_doc(**kwargs) @@ -101,7 +103,8 @@ def entry_list(**kwargs): @click.option("--doc-name", "-n", default=None, help="Log document name") @click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") @click.option("--id", "entry_id", default=None, type=int, help="Specific log entry ID (default: latest)") -@click.option("--output", "-o", default=None, help="Output file (default: entry_.md)") +@click.option("--output", "-o", "output_dir", default=None, + help="Directory to write entry_.md into (default: cwd)") def entry_get(**kwargs): """Write the markdown of a log entry to a file (latest by default).""" cmd_get_entry(**kwargs) diff --git a/tools/developer_tools/bely-cli/commands.py b/tools/developer_tools/bely-cli/commands.py index dced75954..800236a16 100644 --- a/tools/developer_tools/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/commands.py @@ -94,7 +94,7 @@ def cmd_set_config(field, value): -def cmd_new_doc(type_, name, file, template, systems, no_template, no_prompt): +def cmd_new_doc(type_, name, file, template, systems, no_template, no_prompt, output_dir): """Create a new log document, optionally adding a first log entry.""" if template and no_template: print("Error: --template and --no-template are mutually exclusive.", file=sys.stderr) @@ -183,7 +183,7 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, no_prompt): elif entries: from entry import write_entry_to_file print(f"Template generated a default log entry (log_id={entries[0].log_id})") - write_entry_to_file(entries[0]) + write_entry_to_file(entries[0], output_dir) def cmd_list_docs(limit): diff --git a/tools/developer_tools/bely-cli/entry.py b/tools/developer_tools/bely-cli/entry.py index 706ee953d..eb2756117 100644 --- a/tools/developer_tools/bely-cli/entry.py +++ b/tools/developer_tools/bely-cli/entry.py @@ -25,9 +25,13 @@ def resolve_doc(logbook_api, doc_name, doc_id): return doc -def write_entry_to_file(entry, output=None): - """Write entry markdown to a file. Returns the path written.""" - out_path = os.path.expanduser(output) if output else f"entry_{entry.log_id}.md" +def write_entry_to_file(entry, output_dir=None): + """Write entry markdown to a file in output_dir (cwd if None). Returns the path written.""" + directory = os.path.expanduser(output_dir) if output_dir else "." + if not os.path.isdir(directory): + print(f"Error: output directory not found: {directory}", file=sys.stderr) + sys.exit(1) + out_path = os.path.join(directory, f"entry_{entry.log_id}.md") with open(out_path, "w") as f: f.write(entry.log_entry or "") print(f'Wrote log entry id={entry.log_id} to {out_path}') @@ -211,7 +215,7 @@ def cmd_list_entries(doc_name, doc_id): print(f"{e.log_id:<10} {date:<18} {author:<20} {snippet}") -def cmd_get_entry(doc_name, doc_id, entry_id, output): +def cmd_get_entry(doc_name, doc_id, entry_id, output_dir): """Write the markdown of a log entry to a file (latest by default).""" factory = auth.get_factory() logbook_api = factory.get_logbook_api() @@ -231,4 +235,4 @@ def cmd_get_entry(doc_name, doc_id, entry_id, output): else: entry = entries[-1] - write_entry_to_file(entry, output) + write_entry_to_file(entry, output_dir) From 3fc2fefe3613a0d293ce97868b6f510474e51a8e Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Wed, 13 May 2026 16:47:44 -0500 Subject: [PATCH 07/62] add doc name in generated entry file --- tools/developer_tools/bely-cli/bely | 2 +- tools/developer_tools/bely-cli/commands.py | 2 +- tools/developer_tools/bely-cli/entry.py | 14 ++++++++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/tools/developer_tools/bely-cli/bely b/tools/developer_tools/bely-cli/bely index 4ab023a2b..b00bb86b7 100755 --- a/tools/developer_tools/bely-cli/bely +++ b/tools/developer_tools/bely-cli/bely @@ -104,7 +104,7 @@ def entry_list(**kwargs): @click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") @click.option("--id", "entry_id", default=None, type=int, help="Specific log entry ID (default: latest)") @click.option("--output", "-o", "output_dir", default=None, - help="Directory to write entry_.md into (default: cwd)") + help="Directory to write _entry_.md into (default: cwd)") def entry_get(**kwargs): """Write the markdown of a log entry to a file (latest by default).""" cmd_get_entry(**kwargs) diff --git a/tools/developer_tools/bely-cli/commands.py b/tools/developer_tools/bely-cli/commands.py index 800236a16..c440e0cfc 100644 --- a/tools/developer_tools/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/commands.py @@ -183,7 +183,7 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, no_prompt, ou elif entries: from entry import write_entry_to_file print(f"Template generated a default log entry (log_id={entries[0].log_id})") - write_entry_to_file(entries[0], output_dir) + write_entry_to_file(entries[0], doc.name, output_dir) def cmd_list_docs(limit): diff --git a/tools/developer_tools/bely-cli/entry.py b/tools/developer_tools/bely-cli/entry.py index eb2756117..5d9f60a67 100644 --- a/tools/developer_tools/bely-cli/entry.py +++ b/tools/developer_tools/bely-cli/entry.py @@ -25,13 +25,18 @@ def resolve_doc(logbook_api, doc_name, doc_id): return doc -def write_entry_to_file(entry, output_dir=None): - """Write entry markdown to a file in output_dir (cwd if None). Returns the path written.""" +def _sanitize_for_filename(name): + return "".join(c if c.isalnum() or c in "-_." else "_" for c in name) + + +def write_entry_to_file(entry, doc_name, output_dir=None): + """Write entry markdown to _entry_.md in output_dir (cwd if None).""" directory = os.path.expanduser(output_dir) if output_dir else "." if not os.path.isdir(directory): print(f"Error: output directory not found: {directory}", file=sys.stderr) sys.exit(1) - out_path = os.path.join(directory, f"entry_{entry.log_id}.md") + safe_doc = _sanitize_for_filename(doc_name) + out_path = os.path.join(directory, f"{safe_doc}_entry_{entry.log_id}.md") with open(out_path, "w") as f: f.write(entry.log_entry or "") print(f'Wrote log entry id={entry.log_id} to {out_path}') @@ -235,4 +240,5 @@ def cmd_get_entry(doc_name, doc_id, entry_id, output_dir): else: entry = entries[-1] - write_entry_to_file(entry, output_dir) + name_for_file = doc_name if doc_name else str(doc.id) + write_entry_to_file(entry, name_for_file, output_dir) From 4d5759b736323faa7db201714fe2ed16b8a8d7de Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Thu, 14 May 2026 09:41:32 -0500 Subject: [PATCH 08/62] moved files to bely-cli directory. changed system|template|type commands to option in bely doc new --- tools/developer_tools/bely-cli/bely | 188 +----------------- .../bely-cli/{ => bely-cli}/auth.py | 0 .../developer_tools/bely-cli/bely-cli/bely.py | 143 +++++++++++++ .../bely-cli/{ => bely-cli}/commands.py | 22 +- .../bely-cli/{ => bely-cli}/config.py | 0 .../bely-cli/{ => bely-cli}/entry.py | 0 6 files changed, 162 insertions(+), 191 deletions(-) rename tools/developer_tools/bely-cli/{ => bely-cli}/auth.py (100%) create mode 100755 tools/developer_tools/bely-cli/bely-cli/bely.py rename tools/developer_tools/bely-cli/{ => bely-cli}/commands.py (96%) rename tools/developer_tools/bely-cli/{ => bely-cli}/config.py (100%) rename tools/developer_tools/bely-cli/{ => bely-cli}/entry.py (100%) diff --git a/tools/developer_tools/bely-cli/bely b/tools/developer_tools/bely-cli/bely index b00bb86b7..624b09847 100755 --- a/tools/developer_tools/bely-cli/bely +++ b/tools/developer_tools/bely-cli/bely @@ -1,185 +1,5 @@ -#!/Users/echandler/sandbox/auxiliary-scripts/scripts/bely-cli/conda/bin/python +#!/bin/bash -import click - -from config import VALID_FIELDS -from commands import ( - cmd_new_doc, - cmd_list_docs, - cmd_list_types, - cmd_list_systems, - cmd_list_templates, - cmd_show_config, - cmd_edit_config, - cmd_set_config, -) -from entry import ( - cmd_add_entry, - cmd_get_entry, - cmd_list_entries, - cmd_update_entry, -) - - -CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) - - -@click.group(context_settings=CONTEXT_SETTINGS) -def cli(): - """BELY logbook CLI""" - pass - - -# -- doc -- - -@cli.group("doc") -def doc_group(): - """Log document commands.""" - pass - - -@doc_group.command("new") -@click.option("--type", "type_", default=None, help="Logbook type (e.g. ops, controls)") -@click.option("--name", "-n", default=None, help="Name for the new document") -@click.option("--file", "-f", "file", default=None, help="Markdown file for the first log entry") -@click.option("--template", default=None, help="Template name to use") -@click.option("--systems", default=None, help="Comma-separated system list (e.g. SR,software)") -@click.option("--no-template", is_flag=True, help="Skip template selection") -@click.option("--no-prompt", is_flag=True, help="Non-interactive mode (no prompts)") -@click.option("--output", "-o", "output_dir", default=None, - help="Directory to write template-generated entry into (default: cwd)") -def doc_new(**kwargs): - """Create a new log document.""" - cmd_new_doc(**kwargs) - - -@doc_group.command("list") -@click.option("--limit", default=20, type=int, help="Max documents to return (default 20)") -def doc_list(**kwargs): - """List recent log documents created by you.""" - cmd_list_docs(**kwargs) - - -# -- entry -- - -@cli.group("entry") -def entry_group(): - """Log entry commands.""" - pass - - -@entry_group.command("add") -@click.option("--doc-name", "-n", default=None, help="Log document name") -@click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") -@click.option("--file", "-f", "file", default=None, help="Markdown file with entry content") -@click.option("--text", "-t", default=None, help="Inline text for the entry") -@click.option("--add-attachment", default=None, help="File to attach to the entry") -def entry_add(**kwargs): - """Add a new log entry to an existing document.""" - cmd_add_entry(**kwargs) - - -@entry_group.command("update") -@click.option("--doc-name", "-n", default=None, help="Log document name") -@click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") -@click.option("--id", "entry_id", default=None, type=int, help="Specific log entry ID to update") -@click.option("--file", "-f", "file", default=None, help="Markdown file with updated content") -@click.option("--text", "-t", default=None, help="Inline text for the entry") -@click.option("--add-attachment", default=None, help="File to attach to the entry") -def entry_update(**kwargs): - """Update an existing log entry.""" - cmd_update_entry(**kwargs) - - -@entry_group.command("list") -@click.option("--doc-name", "-n", default=None, help="Log document name") -@click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") -def entry_list(**kwargs): - """List entries in a log document.""" - cmd_list_entries(**kwargs) - - -@entry_group.command("get") -@click.option("--doc-name", "-n", default=None, help="Log document name") -@click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") -@click.option("--id", "entry_id", default=None, type=int, help="Specific log entry ID (default: latest)") -@click.option("--output", "-o", "output_dir", default=None, - help="Directory to write _entry_.md into (default: cwd)") -def entry_get(**kwargs): - """Write the markdown of a log entry to a file (latest by default).""" - cmd_get_entry(**kwargs) - - -# -- type -- - -@cli.group("type") -def type_group(): - """Logbook type commands.""" - pass - - -@type_group.command("list") -def type_list(): - """List available logbook types.""" - cmd_list_types() - - -# -- system -- - -@cli.group("system") -def system_group(): - """Logbook system commands.""" - pass - - -@system_group.command("list") -def system_list(): - """List available logbook systems.""" - cmd_list_systems() - - -# -- template -- - -@cli.group("template") -def template_group(): - """Logbook template commands.""" - pass - - -@template_group.command("list") -def template_list(): - """List available logbook templates.""" - cmd_list_templates() - - -# -- config -- - -@cli.group("config") -def config_group(): - """Configuration commands.""" - pass - - -@config_group.command("show") -def config_show(): - """Show current configuration.""" - cmd_show_config() - - -@config_group.command("edit") -def config_edit(): - """Open the settings file in your editor.""" - cmd_edit_config() - - -@config_group.command("set") -@click.argument("field", type=click.Choice(VALID_FIELDS)) -@click.argument("value") -def config_set(field, value): - """Set a configuration field to a value.""" - cmd_set_config(field, value) - - - -if __name__ == "__main__": - cli() +SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}") +export BELY_HOST=https://tinkerbox.aps.anl.gov:8181/bely +${SCRIPT_DIR}/bely-cli/bely.py "$@" diff --git a/tools/developer_tools/bely-cli/auth.py b/tools/developer_tools/bely-cli/bely-cli/auth.py similarity index 100% rename from tools/developer_tools/bely-cli/auth.py rename to tools/developer_tools/bely-cli/bely-cli/auth.py diff --git a/tools/developer_tools/bely-cli/bely-cli/bely.py b/tools/developer_tools/bely-cli/bely-cli/bely.py new file mode 100755 index 000000000..56377c13f --- /dev/null +++ b/tools/developer_tools/bely-cli/bely-cli/bely.py @@ -0,0 +1,143 @@ +#!/Users/echandler/sandbox/auxiliary-scripts/scripts/bely-cli/conda/bin/python + +import click + +from config import VALID_FIELDS +from commands import ( + cmd_new_doc, + cmd_list_docs, + cmd_show_config, + cmd_edit_config, + cmd_set_config, +) +from entry import ( + cmd_add_entry, + cmd_get_entry, + cmd_list_entries, + cmd_update_entry, +) + + +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) + + +@click.group(context_settings=CONTEXT_SETTINGS) +def cli(): + """BELY logbook CLI""" + pass + + +# -- doc -- + +@cli.group("doc") +def doc_group(): + """Log document commands.""" + pass + + +@doc_group.command("new") +@click.option("--type", "type_", default=None, help="Logbook type (e.g. ops, controls)") +@click.option("--name", "-n", default=None, help="Name for the new document") +@click.option("--file", "-f", "file", default=None, help="Markdown file for the first log entry") +@click.option("--template", default=None, help="Template name to use") +@click.option("--systems", default=None, help="Comma-separated system list (e.g. SR,software)") +@click.option("--no-template", is_flag=True, help="Skip template selection") +@click.option("--output", "-o", "output_dir", default=None, + help="Directory to write template-generated entry into (default: cwd)") +@click.option("--list-options", "list_options", + type=click.Choice(["system", "type", "template"]), + default=None, + help="List available values for the given option and exit") +def doc_new(**kwargs): + """Create a new log document.""" + cmd_new_doc(**kwargs) + + +@doc_group.command("list") +@click.option("--limit", default=20, type=int, help="Max documents to return (default 20)") +def doc_list(**kwargs): + """List recent log documents created by you.""" + cmd_list_docs(**kwargs) + + +# -- entry -- + +@cli.group("entry") +def entry_group(): + """Log entry commands.""" + pass + + +@entry_group.command("add") +@click.option("--doc-name", "-n", default=None, help="Log document name") +@click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") +@click.option("--file", "-f", "file", default=None, help="Markdown file with entry content") +@click.option("--text", "-t", default=None, help="Inline text for the entry") +@click.option("--add-attachment", default=None, help="File to attach to the entry") +def entry_add(**kwargs): + """Add a new log entry to an existing document.""" + cmd_add_entry(**kwargs) + + +@entry_group.command("update") +@click.option("--doc-name", "-n", default=None, help="Log document name") +@click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") +@click.option("--id", "entry_id", default=None, type=int, help="Specific log entry ID to update") +@click.option("--file", "-f", "file", default=None, help="Markdown file with updated content") +@click.option("--text", "-t", default=None, help="Inline text for the entry") +@click.option("--add-attachment", default=None, help="File to attach to the entry") +def entry_update(**kwargs): + """Update an existing log entry.""" + cmd_update_entry(**kwargs) + + +@entry_group.command("list") +@click.option("--doc-name", "-n", default=None, help="Log document name") +@click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") +def entry_list(**kwargs): + """List entries in a log document.""" + cmd_list_entries(**kwargs) + + +@entry_group.command("get") +@click.option("--doc-name", "-n", default=None, help="Log document name") +@click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") +@click.option("--id", "entry_id", default=None, type=int, help="Specific log entry ID (default: latest)") +@click.option("--output", "-o", "output_dir", default=None, + help="Directory to write _entry_.md into (default: cwd)") +def entry_get(**kwargs): + """Write the markdown of a log entry to a file (latest by default).""" + cmd_get_entry(**kwargs) + + +# -- config -- + +@cli.group("config") +def config_group(): + """Configuration commands.""" + pass + + +@config_group.command("show") +def config_show(): + """Show current configuration.""" + cmd_show_config() + + +@config_group.command("edit") +def config_edit(): + """Open the settings file in your editor.""" + cmd_edit_config() + + +@config_group.command("set") +@click.argument("field", type=click.Choice(VALID_FIELDS)) +@click.argument("value") +def config_set(field, value): + """Set a configuration field to a value.""" + cmd_set_config(field, value) + + + +if __name__ == "__main__": + cli() diff --git a/tools/developer_tools/bely-cli/commands.py b/tools/developer_tools/bely-cli/bely-cli/commands.py similarity index 96% rename from tools/developer_tools/bely-cli/commands.py rename to tools/developer_tools/bely-cli/bely-cli/commands.py index c440e0cfc..4653ddb66 100644 --- a/tools/developer_tools/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/bely-cli/commands.py @@ -94,8 +94,13 @@ def cmd_set_config(field, value): -def cmd_new_doc(type_, name, file, template, systems, no_template, no_prompt, output_dir): +def cmd_new_doc(type_, name, file, template, systems, no_template, + output_dir, list_options): """Create a new log document, optionally adding a first log entry.""" + if list_options: + _list_doc_option(list_options) + return + if template and no_template: print("Error: --template and --no-template are mutually exclusive.", file=sys.stderr) sys.exit(1) @@ -106,9 +111,6 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, no_prompt, ou # Prompt for required fields if missing if not type_: - if no_prompt: - print("Error: --type is required.", file=sys.stderr) - sys.exit(1) types = logbook_api.get_logbook_types() print("Available logbook types:") for i, t in enumerate(types, 1): @@ -124,9 +126,6 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, no_prompt, ou type_ = choice if not name: - if no_prompt: - print("Error: --name is required.", file=sys.stderr) - sys.exit(1) name = input("Document name: ").strip() if not name: print("Error: name cannot be empty.", file=sys.stderr) @@ -223,6 +222,15 @@ def cmd_list_docs(limit): print(f"{d.object_id:<8} {name:<50} {doc_type:<15} {modified}") +def _list_doc_option(option): + """Dispatch --list-options choice to the matching listing helper.""" + { + "system": cmd_list_systems, + "type": cmd_list_types, + "template": cmd_list_templates, + }[option]() + + def cmd_list_types(): """List all logbook types.""" factory = auth.get_factory() diff --git a/tools/developer_tools/bely-cli/config.py b/tools/developer_tools/bely-cli/bely-cli/config.py similarity index 100% rename from tools/developer_tools/bely-cli/config.py rename to tools/developer_tools/bely-cli/bely-cli/config.py diff --git a/tools/developer_tools/bely-cli/entry.py b/tools/developer_tools/bely-cli/bely-cli/entry.py similarity index 100% rename from tools/developer_tools/bely-cli/entry.py rename to tools/developer_tools/bely-cli/bely-cli/entry.py From 03bad3aa2bb90c4899696a26617df73b0b8b8b18 Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Thu, 14 May 2026 10:33:08 -0500 Subject: [PATCH 09/62] add unit tests, change the install script path --- tools/developer_tools/bely-cli/install.txt | 2 +- .../developer_tools/bely-cli/test/__init__.py | 0 .../bely-cli/test/test_commands.py | 94 +++++++++++++ .../bely-cli/test/test_entry.py | 132 ++++++++++++++++++ 4 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 tools/developer_tools/bely-cli/test/__init__.py create mode 100644 tools/developer_tools/bely-cli/test/test_commands.py create mode 100644 tools/developer_tools/bely-cli/test/test_entry.py diff --git a/tools/developer_tools/bely-cli/install.txt b/tools/developer_tools/bely-cli/install.txt index 330dabfcb..04a3f8cf1 100644 --- a/tools/developer_tools/bely-cli/install.txt +++ b/tools/developer_tools/bely-cli/install.txt @@ -1 +1 @@ -bely +bely-cli/bely.py diff --git a/tools/developer_tools/bely-cli/test/__init__.py b/tools/developer_tools/bely-cli/test/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tools/developer_tools/bely-cli/test/test_commands.py b/tools/developer_tools/bely-cli/test/test_commands.py new file mode 100644 index 000000000..3996ca745 --- /dev/null +++ b/tools/developer_tools/bely-cli/test/test_commands.py @@ -0,0 +1,94 @@ +import io +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bely-cli")) + +import commands + + +class _NF(Exception): + """Stand-in for belyApi.exceptions.NotFoundException.""" + + +class FakeApi: + def __init__(self): + self.created = None + self.entry_saved = None + + def get_logbook_types(self): + return [SimpleNamespace(id=1, name="ops", display_name="Ops")] + + def get_log_document_by_name(self, name): + raise _NF() + + def create_logbook_document(self, log_document_options): + self.created = log_document_options + return SimpleNamespace(id=42, name="My Doc") + + def get_log_entries(self, log_document_id): + return [] + + def get_log_entry_template(self, log_document_id): + return SimpleNamespace(log_id=None, log_entry="") + + def add_update_log_entry(self, log_entry): + self.entry_saved = log_entry + log_entry.log_id = 99 + return log_entry + + +class CmdNewDocTests(unittest.TestCase): + def test_creates_doc_and_first_entry_from_file(self): + api = FakeApi() + + factory = MagicMock() + factory.get_logbook_api.return_value = api + + auth_factory = MagicMock() + auth_factory.get_logbook_api.return_value = api + auth_ctx = MagicMock() + auth_ctx.__enter__.return_value = auth_factory + auth_ctx.__exit__.return_value = False + + with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f: + f.write("hello\n") + tmp_path = f.name + + try: + with patch.object(commands.auth, "get_factory", return_value=factory), \ + patch.object(commands.auth, "get_authenticated_factory", return_value=auth_ctx), \ + patch.object(commands.belyApi, "LogDocumentOptions") as opts_cls, \ + patch.object(commands.belyApi.exceptions, "NotFoundException", _NF): + buf = io.StringIO() + with redirect_stdout(buf): + commands.cmd_new_doc( + type_="ops", + name="My Doc", + file=tmp_path, + template=None, + systems=None, + no_template=False, + output_dir=None, + list_options=None, + ) + finally: + os.unlink(tmp_path) + + opts_cls.assert_called_once_with(name="My Doc", logbook_type_id=1) + self.assertIs(api.created, opts_cls.return_value) + self.assertEqual(api.entry_saved.log_entry, "hello\n") + self.assertEqual(api.entry_saved.log_id, 99) + + out = buf.getvalue() + self.assertIn('New document "My Doc" created, id=42', out) + self.assertIn("Log entry added, log_id=99", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/developer_tools/bely-cli/test/test_entry.py b/tools/developer_tools/bely-cli/test/test_entry.py new file mode 100644 index 000000000..18a9b9ca2 --- /dev/null +++ b/tools/developer_tools/bely-cli/test/test_entry.py @@ -0,0 +1,132 @@ +import io +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bely-cli")) + +import entry + + +class FakeApi: + """Minimal fake exposing only the methods entry.py touches.""" + + def __init__(self, existing_entries=None): + self.doc = SimpleNamespace(id=42, name="My Doc") + self.existing_entries = existing_entries or [] + self.entry_saved = None + + def get_log_document_by_name(self, name): + return self.doc + + def get_log_entry_template(self, log_document_id): + return SimpleNamespace(log_id=None, log_entry="") + + def get_log_entries(self, log_document_id): + return self.existing_entries + + def add_update_log_entry(self, log_entry): + self.entry_saved = log_entry + if log_entry.log_id is None: + log_entry.log_id = 99 + return log_entry + + +def _patch_auth(api): + """Patch entry.auth.get_factory and get_authenticated_factory to yield `api`.""" + factory = MagicMock() + factory.get_logbook_api.return_value = api + + auth_factory = MagicMock() + auth_factory.get_logbook_api.return_value = api + auth_ctx = MagicMock() + auth_ctx.__enter__.return_value = auth_factory + auth_ctx.__exit__.return_value = False + + return [ + patch.object(entry.auth, "get_factory", return_value=factory), + patch.object(entry.auth, "get_authenticated_factory", return_value=auth_ctx), + ] + + +def _write_tmp(content): + with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f: + f.write(content) + return f.name + + +class CmdAddEntryTests(unittest.TestCase): + def test_add_entry_with_file_and_name(self): + api = FakeApi() + tmp_path = _write_tmp("added content\n") + + try: + patches = _patch_auth(api) + for p in patches: + p.start() + try: + buf = io.StringIO() + with redirect_stdout(buf): + entry.cmd_add_entry( + doc_name="My Doc", + doc_id=None, + file=tmp_path, + text=None, + add_attachment=None, + ) + finally: + for p in patches: + p.stop() + finally: + os.unlink(tmp_path) + + self.assertIsNotNone(api.entry_saved) + self.assertEqual(api.entry_saved.log_entry, "added content\n") + self.assertEqual(api.entry_saved.log_id, 99) + self.assertIn('Log entry added to "My Doc", log_id=99', buf.getvalue()) + + +class CmdUpdateEntryTests(unittest.TestCase): + def test_update_entry_with_file_and_name(self): + existing = SimpleNamespace( + log_id=10, + log_entry="old content", + entered_by_username="alice", + ) + api = FakeApi(existing_entries=[existing]) + tmp_path = _write_tmp("updated content\n") + + try: + patches = _patch_auth(api) + patches.append(patch.object(entry.auth, "get_username", return_value="alice")) + for p in patches: + p.start() + try: + buf = io.StringIO() + with redirect_stdout(buf): + entry.cmd_update_entry( + doc_name="My Doc", + doc_id=None, + entry_id=None, + file=tmp_path, + text=None, + add_attachment=None, + ) + finally: + for p in patches: + p.stop() + finally: + os.unlink(tmp_path) + + self.assertIs(api.entry_saved, existing) + self.assertEqual(api.entry_saved.log_entry, "updated content\n") + self.assertEqual(api.entry_saved.log_id, 10) + self.assertIn('Log entry updated in "My Doc", log_id=10', buf.getvalue()) + + +if __name__ == "__main__": + unittest.main() From de42abc271543b22f019f94ac2461b1f0bd21686 Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Thu, 14 May 2026 10:57:33 -0500 Subject: [PATCH 10/62] beli-cli add run_test script --- tools/developer_tools/bely-cli/bely-cli/bely.py | 2 +- tools/developer_tools/bely-cli/run_test.sh | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 tools/developer_tools/bely-cli/run_test.sh diff --git a/tools/developer_tools/bely-cli/bely-cli/bely.py b/tools/developer_tools/bely-cli/bely-cli/bely.py index 56377c13f..c643d71f5 100755 --- a/tools/developer_tools/bely-cli/bely-cli/bely.py +++ b/tools/developer_tools/bely-cli/bely-cli/bely.py @@ -1,4 +1,4 @@ -#!/Users/echandler/sandbox/auxiliary-scripts/scripts/bely-cli/conda/bin/python +#!/C2/conda/envs/bely/bin/python import click diff --git a/tools/developer_tools/bely-cli/run_test.sh b/tools/developer_tools/bely-cli/run_test.sh new file mode 100644 index 000000000..ee6ef2838 --- /dev/null +++ b/tools/developer_tools/bely-cli/run_test.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +PYTHON=/C2/conda/envs/bely/bin/ptyhon + +$PYTHON -m unittest + +bely.py -h From 06a2973a91907cae5cc4dfaaa8763a517110aea2 Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Thu, 21 May 2026 12:15:02 -0500 Subject: [PATCH 11/62] update bely doc new and bely entry add to automatically open an editor if no data is provided --- .../bely-cli/bely-cli/commands.py | 36 ++++++++---- .../bely-cli/bely-cli/common.py | 46 +++++++++++++++ .../bely-cli/bely-cli/entry.py | 58 ++++++------------- .../bely-cli/test/test_commands.py | 3 +- 4 files changed, 91 insertions(+), 52 deletions(-) create mode 100644 tools/developer_tools/bely-cli/bely-cli/common.py diff --git a/tools/developer_tools/bely-cli/bely-cli/commands.py b/tools/developer_tools/bely-cli/bely-cli/commands.py index 4653ddb66..171a8043e 100644 --- a/tools/developer_tools/bely-cli/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/bely-cli/commands.py @@ -5,6 +5,7 @@ import auth import config +from common import find_logdoc, write_entry_to_file, open_in_editor ENV_VARS = ["BELY_HOST", "BELY_USER", "BELY_PASSWORD"] @@ -66,14 +67,6 @@ def find_template(logbook_api, name): available = ", ".join(t.name for t in templates if t.name) raise ValueError(f"Unknown template '{name}'. Available: {available}") -def find_logdoc(logbook_api, name): - try: - existing = logbook_api.get_log_document_by_name(name=name) - return existing - except belyApi.exceptions.NotFoundException: - return None - - def cmd_edit_config(): """Open the settings file in the user's editor.""" config._ensure_config_dir() @@ -180,9 +173,30 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, entry = logbook_api.add_update_log_entry(log_entry=entry) print(f"Log entry added, log_id={entry.log_id}") elif entries: - from entry import write_entry_to_file - print(f"Template generated a default log entry (log_id={entries[0].log_id})") - write_entry_to_file(entries[0], doc.name, output_dir) + entry = entries[0] + print(f"Template generated a default log entry (log_id={entry.log_id})") + answer = input("Update the entry? [y/N] ").strip().lower() + if answer in ("y", "yes"): + edited = open_in_editor(entry.log_entry or "") + if edited != (entry.log_entry or ""): + entry.log_entry = edited + entry = logbook_api.add_update_log_entry(log_entry=entry) + print(f"Log entry updated, log_id={entry.log_id}") + else: + print("No changes made.") + else: + write_entry_to_file(entry, doc.name, output_dir) + else: + answer = input("Create a log entry? [y/N] ").strip().lower() + if answer in ("y", "yes"): + entry = logbook_api.get_log_entry_template(log_document_id=doc.id) + edited = open_in_editor(entry.log_entry or "") + if edited.strip(): + entry.log_entry = edited + entry = logbook_api.add_update_log_entry(log_entry=entry) + print(f"Log entry added, log_id={entry.log_id}") + else: + print("Empty entry, skipped.") def cmd_list_docs(limit): diff --git a/tools/developer_tools/bely-cli/bely-cli/common.py b/tools/developer_tools/bely-cli/bely-cli/common.py new file mode 100644 index 000000000..28d8ceede --- /dev/null +++ b/tools/developer_tools/bely-cli/bely-cli/common.py @@ -0,0 +1,46 @@ +import os +import subprocess +import sys +import tempfile + +import belyApi + + +def find_logdoc(logbook_api, name): + try: + existing = logbook_api.get_log_document_by_name(name=name) + return existing + except belyApi.exceptions.NotFoundException: + return None + + +def _sanitize_for_filename(name): + return "".join(c if c.isalnum() or c in "-_." else "_" for c in name) + + +def write_entry_to_file(entry, doc_name, output_dir=None): + """Write entry markdown to _entry_.md in output_dir (cwd if None).""" + directory = os.path.expanduser(output_dir) if output_dir else "." + if not os.path.isdir(directory): + print(f"Error: output directory not found: {directory}", file=sys.stderr) + sys.exit(1) + safe_doc = _sanitize_for_filename(doc_name) + out_path = os.path.join(directory, f"{safe_doc}_entry_{entry.log_id}.md") + with open(out_path, "w") as f: + f.write(entry.log_entry or "") + print(f'Wrote log entry id={entry.log_id} to {out_path}') + return out_path + + +def open_in_editor(initial_content=""): + """Open initial_content in $EDITOR and return the edited text.""" + with tempfile.NamedTemporaryFile(suffix=".md", mode="w", delete=False) as tmp: + tmp.write(initial_content) + tmp_path = tmp.name + try: + editor = os.environ.get("EDITOR", "vi") + subprocess.call([editor, tmp_path]) + with open(tmp_path, "r") as f: + return f.read() + finally: + os.unlink(tmp_path) diff --git a/tools/developer_tools/bely-cli/bely-cli/entry.py b/tools/developer_tools/bely-cli/bely-cli/entry.py index 5d9f60a67..a553e2f05 100644 --- a/tools/developer_tools/bely-cli/bely-cli/entry.py +++ b/tools/developer_tools/bely-cli/bely-cli/entry.py @@ -1,11 +1,9 @@ import os -import subprocess import sys -import tempfile from types import SimpleNamespace import auth -from commands import find_logdoc +from common import find_logdoc, write_entry_to_file, open_in_editor def resolve_doc(logbook_api, doc_name, doc_id): @@ -25,24 +23,6 @@ def resolve_doc(logbook_api, doc_name, doc_id): return doc -def _sanitize_for_filename(name): - return "".join(c if c.isalnum() or c in "-_." else "_" for c in name) - - -def write_entry_to_file(entry, doc_name, output_dir=None): - """Write entry markdown to _entry_.md in output_dir (cwd if None).""" - directory = os.path.expanduser(output_dir) if output_dir else "." - if not os.path.isdir(directory): - print(f"Error: output directory not found: {directory}", file=sys.stderr) - sys.exit(1) - safe_doc = _sanitize_for_filename(doc_name) - out_path = os.path.join(directory, f"{safe_doc}_entry_{entry.log_id}.md") - with open(out_path, "w") as f: - f.write(entry.log_entry or "") - print(f'Wrote log entry id={entry.log_id} to {out_path}') - return out_path - - def upload_and_print_attachment(logbook_api, doc_id, log_id, path): basename = os.path.basename(path) att = logbook_api.upload_attachment( @@ -132,16 +112,7 @@ def cmd_update_entry(doc_name, doc_id, entry_id, file, text, add_attachment): else: # Interactive edit: open entry in editor original = entry.log_entry or "" - with tempfile.NamedTemporaryFile(suffix=".md", mode="w", delete=False) as tmp: - tmp.write(original) - tmp_path = tmp.name - try: - editor = os.environ.get("EDITOR", "vi") - subprocess.call([editor, tmp_path]) - with open(tmp_path, "r") as f: - edited = f.read() - finally: - os.unlink(tmp_path) + edited = open_in_editor(original) if edited != original: entry.log_entry = edited entry = logbook_api.add_update_log_entry(log_entry=entry) @@ -155,10 +126,7 @@ def cmd_add_entry(doc_name, doc_id, file, text, add_attachment): if file and text: print("Error: --file and --text are mutually exclusive.", file=sys.stderr) sys.exit(1) - if not file and not text and not add_attachment: - print("Error: at least one of --file, --text, or --add-attachment is required.", - file=sys.stderr) - sys.exit(1) + use_editor = not file and not text and not add_attachment # Validate files exist before any network calls if file: @@ -190,12 +158,22 @@ def cmd_add_entry(doc_name, doc_id, file, text, add_attachment): logbook_api = auth_factory.get_logbook_api() entry = logbook_api.get_log_entry_template(log_document_id=doc.id) - entry.log_entry = content or "" - entry = logbook_api.add_update_log_entry(log_entry=entry) - print(f'Log entry added to "{doc.name}", log_id={entry.log_id}') - if add_attachment: - upload_and_print_attachment(logbook_api, doc.id, entry.log_id, add_attachment) + if use_editor: + edited = open_in_editor(entry.log_entry or "") + if edited.strip(): + entry.log_entry = edited + entry = logbook_api.add_update_log_entry(log_entry=entry) + print(f'Log entry added to "{doc.name}", log_id={entry.log_id}') + else: + print("Empty entry, skipped.") + else: + entry.log_entry = content or "" + entry = logbook_api.add_update_log_entry(log_entry=entry) + print(f'Log entry added to "{doc.name}", log_id={entry.log_id}') + + if add_attachment: + upload_and_print_attachment(logbook_api, doc.id, entry.log_id, add_attachment) def cmd_list_entries(doc_name, doc_id): diff --git a/tools/developer_tools/bely-cli/test/test_commands.py b/tools/developer_tools/bely-cli/test/test_commands.py index 3996ca745..7ee5b3d3c 100644 --- a/tools/developer_tools/bely-cli/test/test_commands.py +++ b/tools/developer_tools/bely-cli/test/test_commands.py @@ -10,6 +10,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bely-cli")) import commands +import common class _NF(Exception): @@ -64,7 +65,7 @@ def test_creates_doc_and_first_entry_from_file(self): with patch.object(commands.auth, "get_factory", return_value=factory), \ patch.object(commands.auth, "get_authenticated_factory", return_value=auth_ctx), \ patch.object(commands.belyApi, "LogDocumentOptions") as opts_cls, \ - patch.object(commands.belyApi.exceptions, "NotFoundException", _NF): + patch.object(common.belyApi.exceptions, "NotFoundException", _NF): buf = io.StringIO() with redirect_stdout(buf): commands.cmd_new_doc( From 7c754632dec4466de96990d2e8fc40e5f5c6b04f Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Wed, 10 Jun 2026 16:17:33 -0500 Subject: [PATCH 12/62] fill in build and test cicd jobs --- .../developer_tools/bely-cli/bely-cli/bely.py | 37 +++++--- .../bely-cli/bely-cli/commands.py | 88 +++++++++++-------- .../bely-cli/bely-cli/common.py | 25 ++++++ 3 files changed, 101 insertions(+), 49 deletions(-) diff --git a/tools/developer_tools/bely-cli/bely-cli/bely.py b/tools/developer_tools/bely-cli/bely-cli/bely.py index c643d71f5..3c09350a2 100755 --- a/tools/developer_tools/bely-cli/bely-cli/bely.py +++ b/tools/developer_tools/bely-cli/bely-cli/bely.py @@ -22,9 +22,12 @@ @click.group(context_settings=CONTEXT_SETTINGS) -def cli(): +@click.option("--json", "json_output", is_flag=True, help="Output in JSON format") +@click.pass_context +def cli(ctx, json_output): """BELY logbook CLI""" - pass + ctx.ensure_object(dict) + ctx.obj["json"] = json_output # -- doc -- @@ -48,16 +51,18 @@ def doc_group(): type=click.Choice(["system", "type", "template"]), default=None, help="List available values for the given option and exit") -def doc_new(**kwargs): +@click.pass_context +def doc_new(ctx, **kwargs): """Create a new log document.""" - cmd_new_doc(**kwargs) + cmd_new_doc(ctx, **kwargs) @doc_group.command("list") @click.option("--limit", default=20, type=int, help="Max documents to return (default 20)") -def doc_list(**kwargs): +@click.pass_context +def doc_list(ctx, **kwargs): """List recent log documents created by you.""" - cmd_list_docs(**kwargs) + cmd_list_docs(ctx, **kwargs) # -- entry -- @@ -74,9 +79,10 @@ def entry_group(): @click.option("--file", "-f", "file", default=None, help="Markdown file with entry content") @click.option("--text", "-t", default=None, help="Inline text for the entry") @click.option("--add-attachment", default=None, help="File to attach to the entry") -def entry_add(**kwargs): +@click.pass_context +def entry_add(ctx, **kwargs): """Add a new log entry to an existing document.""" - cmd_add_entry(**kwargs) + cmd_add_entry(ctx, **kwargs) @entry_group.command("update") @@ -86,17 +92,19 @@ def entry_add(**kwargs): @click.option("--file", "-f", "file", default=None, help="Markdown file with updated content") @click.option("--text", "-t", default=None, help="Inline text for the entry") @click.option("--add-attachment", default=None, help="File to attach to the entry") -def entry_update(**kwargs): +@click.pass_context +def entry_update(ctx, **kwargs): """Update an existing log entry.""" - cmd_update_entry(**kwargs) + cmd_update_entry(ctx, **kwargs) @entry_group.command("list") @click.option("--doc-name", "-n", default=None, help="Log document name") @click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") -def entry_list(**kwargs): +@click.pass_context +def entry_list(ctx, **kwargs): """List entries in a log document.""" - cmd_list_entries(**kwargs) + cmd_list_entries(ctx, **kwargs) @entry_group.command("get") @@ -105,9 +113,10 @@ def entry_list(**kwargs): @click.option("--id", "entry_id", default=None, type=int, help="Specific log entry ID (default: latest)") @click.option("--output", "-o", "output_dir", default=None, help="Directory to write _entry_.md into (default: cwd)") -def entry_get(**kwargs): +@click.pass_context +def entry_get(ctx, **kwargs): """Write the markdown of a log entry to a file (latest by default).""" - cmd_get_entry(**kwargs) + cmd_get_entry(ctx, **kwargs) # -- config -- diff --git a/tools/developer_tools/bely-cli/bely-cli/commands.py b/tools/developer_tools/bely-cli/bely-cli/commands.py index 171a8043e..8de5285f5 100644 --- a/tools/developer_tools/bely-cli/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/bely-cli/commands.py @@ -5,7 +5,7 @@ import auth import config -from common import find_logdoc, write_entry_to_file, open_in_editor +from common import find_logdoc, write_entry_to_file, open_in_editor, print_items, print_result ENV_VARS = ["BELY_HOST", "BELY_USER", "BELY_PASSWORD"] @@ -87,11 +87,12 @@ def cmd_set_config(field, value): -def cmd_new_doc(type_, name, file, template, systems, no_template, +def cmd_new_doc(ctx, type_, name, file, template, systems, no_template, output_dir, list_options): """Create a new log document, optionally adding a first log entry.""" + json_output = ctx.obj["json"] if list_options: - _list_doc_option(list_options) + _list_doc_option(list_options, json_output) return if template and no_template: @@ -148,10 +149,14 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, doc_opts.skip_default_logbook_type_template = True # Authenticate and create document + result = {"id": None, "name": name} with auth.get_authenticated_factory() as auth_factory: logbook_api = auth_factory.get_logbook_api() doc = logbook_api.create_logbook_document(log_document_options=doc_opts) - print(f'New document "{doc.name}" created, id={doc.id}') + result["id"] = doc.id + result["name"] = doc.name + if not json_output: + print(f'New document "{doc.name}" created, id={doc.id}') # Determine entry content from --file or --text content = None @@ -171,36 +176,46 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, entry = logbook_api.get_log_entry_template(log_document_id=doc.id) entry.log_entry = content entry = logbook_api.add_update_log_entry(log_entry=entry) - print(f"Log entry added, log_id={entry.log_id}") + result["log_id"] = entry.log_id + if not json_output: + print(f"Log entry added, log_id={entry.log_id}") elif entries: entry = entries[0] - print(f"Template generated a default log entry (log_id={entry.log_id})") - answer = input("Update the entry? [y/N] ").strip().lower() - if answer in ("y", "yes"): - edited = open_in_editor(entry.log_entry or "") - if edited != (entry.log_entry or ""): - entry.log_entry = edited - entry = logbook_api.add_update_log_entry(log_entry=entry) - print(f"Log entry updated, log_id={entry.log_id}") + result["log_id"] = entry.log_id + if not json_output: + print(f"Template generated a default log entry (log_id={entry.log_id})") + answer = input("Update the entry? [y/N] ").strip().lower() + if answer in ("y", "yes"): + edited = open_in_editor(entry.log_entry or "") + if edited != (entry.log_entry or ""): + entry.log_entry = edited + entry = logbook_api.add_update_log_entry(log_entry=entry) + print(f"Log entry updated, log_id={entry.log_id}") + else: + print("No changes made.") else: - print("No changes made.") - else: - write_entry_to_file(entry, doc.name, output_dir) + write_entry_to_file(entry, doc.name, output_dir) else: - answer = input("Create a log entry? [y/N] ").strip().lower() - if answer in ("y", "yes"): - entry = logbook_api.get_log_entry_template(log_document_id=doc.id) - edited = open_in_editor(entry.log_entry or "") - if edited.strip(): - entry.log_entry = edited - entry = logbook_api.add_update_log_entry(log_entry=entry) - print(f"Log entry added, log_id={entry.log_id}") - else: - print("Empty entry, skipped.") - - -def cmd_list_docs(limit): + if not json_output: + answer = input("Create a log entry? [y/N] ").strip().lower() + if answer in ("y", "yes"): + entry = logbook_api.get_log_entry_template(log_document_id=doc.id) + edited = open_in_editor(entry.log_entry or "") + if edited.strip(): + entry.log_entry = edited + entry = logbook_api.add_update_log_entry(log_entry=entry) + result["log_id"] = entry.log_id + print(f"Log entry added, log_id={entry.log_id}") + else: + print("Empty entry, skipped.") + + if json_output: + print_result(result, "", json_output) + + +def cmd_list_docs(ctx, limit): """List recent log documents created by the current user.""" + json_output = ctx.obj["json"] username = auth.get_username() if not username: print("Error: cannot determine username. Set BELY_USER or 'user' in settings.", @@ -227,13 +242,16 @@ def cmd_list_docs(limit): print("No documents found.") return - print(f"{'ID':<8} {'Name':<50} {'Type':<15} {'Last Modified'}") - print(f"{'--':<8} {'----':<50} {'----':<15} {'-------------'}") + items = [] for d in docs: - modified = d.last_modified_on.strftime("%Y-%m-%d %H:%M") if d.last_modified_on else "" - doc_type = d.logbook_type or "" - name = d.object_name or "" - print(f"{d.object_id:<8} {name:<50} {doc_type:<15} {modified}") + items.append({ + "id": d.object_id, + "name": d.object_name or "", + "type": d.logbook_type or "", + "last_modified": d.last_modified_on.strftime("%Y-%m-%d %H:%M") if d.last_modified_on else "", + }) + columns = [("id", "ID", 8), ("name", "Name", 50), ("type", "Type", 15), ("last_modified", "Last Modified", 20)] + print_items(items, columns, json_output) def _list_doc_option(option): diff --git a/tools/developer_tools/bely-cli/bely-cli/common.py b/tools/developer_tools/bely-cli/bely-cli/common.py index 28d8ceede..fc0a472f7 100644 --- a/tools/developer_tools/bely-cli/bely-cli/common.py +++ b/tools/developer_tools/bely-cli/bely-cli/common.py @@ -1,3 +1,4 @@ +import json import os import subprocess import sys @@ -6,6 +7,30 @@ import belyApi +def print_items(items, columns, json_output=False): + """Print a list of dicts as a table or JSON array. + + columns: list of (key, header, width) tuples for table format. + """ + if json_output: + print(json.dumps(items, indent=2)) + return + header = " ".join(f"{h:<{w}}" for _, h, w in columns) + sep = " ".join(f"{'-' * len(h):<{w}}" for _, h, w in columns) + print(header) + print(sep) + for item in items: + print(" ".join(f"{str(item.get(k, '')):<{w}}" for k, _, w in columns)) + + +def print_result(data, message, json_output=False): + """Print a confirmation message or JSON object.""" + if json_output: + print(json.dumps(data)) + else: + print(message) + + def find_logdoc(logbook_api, name): try: existing = logbook_api.get_log_document_by_name(name=name) From c82a16479d2f49e1049db85f3e2452b379a55b9e Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 19 Jun 2026 11:15:49 -0500 Subject: [PATCH 13/62] bely-cli: add global --format option (text/json/yaml) Replace the --json flag with a --format choice and make every output-producing command honor it via a fmt argument. Also fixes the _list_doc_option arg-count bug so doc new --list-options works. --- .../developer_tools/bely-cli/bely-cli/bely.py | 30 ++-- .../bely-cli/bely-cli/commands.py | 129 ++++++++++-------- .../bely-cli/bely-cli/common.py | 40 ++++-- .../bely-cli/bely-cli/entry.py | 105 ++++++++++---- 4 files changed, 200 insertions(+), 104 deletions(-) diff --git a/tools/developer_tools/bely-cli/bely-cli/bely.py b/tools/developer_tools/bely-cli/bely-cli/bely.py index 3c09350a2..987a6b2f5 100755 --- a/tools/developer_tools/bely-cli/bely-cli/bely.py +++ b/tools/developer_tools/bely-cli/bely-cli/bely.py @@ -2,6 +2,7 @@ import click +from common import FORMATS from config import VALID_FIELDS from commands import ( cmd_new_doc, @@ -22,12 +23,13 @@ @click.group(context_settings=CONTEXT_SETTINGS) -@click.option("--json", "json_output", is_flag=True, help="Output in JSON format") +@click.option("--format", "output_format", type=click.Choice(FORMATS), + default="text", help="Output format (default: text)") @click.pass_context -def cli(ctx, json_output): +def cli(ctx, output_format): """BELY logbook CLI""" ctx.ensure_object(dict) - ctx.obj["json"] = json_output + ctx.obj["format"] = output_format # -- doc -- @@ -54,7 +56,7 @@ def doc_group(): @click.pass_context def doc_new(ctx, **kwargs): """Create a new log document.""" - cmd_new_doc(ctx, **kwargs) + cmd_new_doc(fmt=ctx.obj["format"], **kwargs) @doc_group.command("list") @@ -62,7 +64,7 @@ def doc_new(ctx, **kwargs): @click.pass_context def doc_list(ctx, **kwargs): """List recent log documents created by you.""" - cmd_list_docs(ctx, **kwargs) + cmd_list_docs(fmt=ctx.obj["format"], **kwargs) # -- entry -- @@ -82,7 +84,7 @@ def entry_group(): @click.pass_context def entry_add(ctx, **kwargs): """Add a new log entry to an existing document.""" - cmd_add_entry(ctx, **kwargs) + cmd_add_entry(fmt=ctx.obj["format"], **kwargs) @entry_group.command("update") @@ -95,7 +97,7 @@ def entry_add(ctx, **kwargs): @click.pass_context def entry_update(ctx, **kwargs): """Update an existing log entry.""" - cmd_update_entry(ctx, **kwargs) + cmd_update_entry(fmt=ctx.obj["format"], **kwargs) @entry_group.command("list") @@ -104,7 +106,7 @@ def entry_update(ctx, **kwargs): @click.pass_context def entry_list(ctx, **kwargs): """List entries in a log document.""" - cmd_list_entries(ctx, **kwargs) + cmd_list_entries(fmt=ctx.obj["format"], **kwargs) @entry_group.command("get") @@ -116,7 +118,7 @@ def entry_list(ctx, **kwargs): @click.pass_context def entry_get(ctx, **kwargs): """Write the markdown of a log entry to a file (latest by default).""" - cmd_get_entry(ctx, **kwargs) + cmd_get_entry(fmt=ctx.obj["format"], **kwargs) # -- config -- @@ -128,9 +130,10 @@ def config_group(): @config_group.command("show") -def config_show(): +@click.pass_context +def config_show(ctx): """Show current configuration.""" - cmd_show_config() + cmd_show_config(fmt=ctx.obj["format"]) @config_group.command("edit") @@ -142,9 +145,10 @@ def config_edit(): @config_group.command("set") @click.argument("field", type=click.Choice(VALID_FIELDS)) @click.argument("value") -def config_set(field, value): +@click.pass_context +def config_set(ctx, field, value): """Set a configuration field to a value.""" - cmd_set_config(field, value) + cmd_set_config(field, value, fmt=ctx.obj["format"]) diff --git a/tools/developer_tools/bely-cli/bely-cli/commands.py b/tools/developer_tools/bely-cli/bely-cli/commands.py index 8de5285f5..a31174699 100644 --- a/tools/developer_tools/bely-cli/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/bely-cli/commands.py @@ -11,26 +11,40 @@ ENV_VARS = ["BELY_HOST", "BELY_USER", "BELY_PASSWORD"] -def cmd_show_config(): +def cmd_show_config(fmt="text"): """Show current configuration from settings file and environment.""" + settings = config.load_settings() + environment = {} + for var in ENV_VARS: + val = os.environ.get(var) + if val is not None: + environment[var] = "****" if "PASSWORD" in var else val + + if fmt != "text": + print_result( + { + "settings_file": config.SETTINGS_FILE, + "settings": settings, + "environment": environment, + }, + "", + fmt, + ) + return + print(f"Settings file: {config.SETTINGS_FILE}") - data = config.load_settings() - if data: - for key, value in data.items(): + if settings: + for key, value in settings.items(): print(f" {key} = {value}") else: print(" (no settings)") print() print("Environment variables:") - found = False - for var in ENV_VARS: - val = os.environ.get(var) - if val is not None: - display = "****" if "PASSWORD" in var else val + if environment: + for var, display in environment.items(): print(f" {var} = {display}") - found = True - if not found: + else: print(" (none set)") @@ -76,23 +90,22 @@ def cmd_edit_config(): os.execvp(editor, [editor, config.SETTINGS_FILE]) -def cmd_set_config(field, value): +def cmd_set_config(field, value, fmt="text"): """Set a single configuration field in settings.yaml.""" if field not in config.VALID_FIELDS: valid = ", ".join(config.VALID_FIELDS) print(f"Error: unknown field '{field}'. Valid fields: {valid}", file=sys.stderr) sys.exit(1) config.set_setting(field, value) - print(f"Set {field} = {value}") + print_result({field: value}, f"Set {field} = {value}", fmt) -def cmd_new_doc(ctx, type_, name, file, template, systems, no_template, - output_dir, list_options): +def cmd_new_doc(type_, name, file, template, systems, no_template, + output_dir, list_options, fmt="text"): """Create a new log document, optionally adding a first log entry.""" - json_output = ctx.obj["json"] if list_options: - _list_doc_option(list_options, json_output) + _list_doc_option(list_options, fmt) return if template and no_template: @@ -155,7 +168,7 @@ def cmd_new_doc(ctx, type_, name, file, template, systems, no_template, doc = logbook_api.create_logbook_document(log_document_options=doc_opts) result["id"] = doc.id result["name"] = doc.name - if not json_output: + if fmt == "text": print(f'New document "{doc.name}" created, id={doc.id}') # Determine entry content from --file or --text @@ -177,12 +190,12 @@ def cmd_new_doc(ctx, type_, name, file, template, systems, no_template, entry.log_entry = content entry = logbook_api.add_update_log_entry(log_entry=entry) result["log_id"] = entry.log_id - if not json_output: + if fmt == "text": print(f"Log entry added, log_id={entry.log_id}") elif entries: entry = entries[0] result["log_id"] = entry.log_id - if not json_output: + if fmt == "text": print(f"Template generated a default log entry (log_id={entry.log_id})") answer = input("Update the entry? [y/N] ").strip().lower() if answer in ("y", "yes"): @@ -194,9 +207,9 @@ def cmd_new_doc(ctx, type_, name, file, template, systems, no_template, else: print("No changes made.") else: - write_entry_to_file(entry, doc.name, output_dir) + write_entry_to_file(entry, doc.name, output_dir, fmt) else: - if not json_output: + if fmt == "text": answer = input("Create a log entry? [y/N] ").strip().lower() if answer in ("y", "yes"): entry = logbook_api.get_log_entry_template(log_document_id=doc.id) @@ -209,13 +222,12 @@ def cmd_new_doc(ctx, type_, name, file, template, systems, no_template, else: print("Empty entry, skipped.") - if json_output: - print_result(result, "", json_output) + if fmt != "text": + print_result(result, "", fmt) -def cmd_list_docs(ctx, limit): +def cmd_list_docs(limit, fmt="text"): """List recent log documents created by the current user.""" - json_output = ctx.obj["json"] username = auth.get_username() if not username: print("Error: cannot determine username. Set BELY_USER or 'user' in settings.", @@ -239,7 +251,10 @@ def cmd_list_docs(ctx, limit): docs = docs[:limit] if not docs: - print("No documents found.") + if fmt == "text": + print("No documents found.") + else: + print_items([], [], fmt) return items = [] @@ -251,64 +266,70 @@ def cmd_list_docs(ctx, limit): "last_modified": d.last_modified_on.strftime("%Y-%m-%d %H:%M") if d.last_modified_on else "", }) columns = [("id", "ID", 8), ("name", "Name", 50), ("type", "Type", 15), ("last_modified", "Last Modified", 20)] - print_items(items, columns, json_output) + print_items(items, columns, fmt) -def _list_doc_option(option): +def _list_doc_option(option, fmt="text"): """Dispatch --list-options choice to the matching listing helper.""" { "system": cmd_list_systems, "type": cmd_list_types, "template": cmd_list_templates, - }[option]() + }[option](fmt) -def cmd_list_types(): +def cmd_list_types(fmt="text"): """List all logbook types.""" factory = auth.get_factory() logbook_api = factory.get_logbook_api() types = logbook_api.get_logbook_types() - if not types: + items = [ + { + "id": t.id, + "name": t.name or "", + "display_name": t.display_name or "", + "description": t.description or "", + } + for t in types + ] + if not items and fmt == "text": print("No logbook types found.") return - - print(f"{'ID':<6} {'Name':<20} {'Display Name':<30} {'Description'}") - print(f"{'--':<6} {'----':<20} {'------------':<30} {'-----------'}") - for t in types: - desc = t.description or "" - print(f"{t.id:<6} {t.name:<20} {t.display_name:<30} {desc}") + columns = [("id", "ID", 6), ("name", "Name", 20), + ("display_name", "Display Name", 30), ("description", "Description", 0)] + print_items(items, columns, fmt) -def cmd_list_systems(): +def cmd_list_systems(fmt="text"): """List all logbook systems.""" factory = auth.get_factory() logbook_api = factory.get_logbook_api() systems = logbook_api.get_logbook_systems() - if not systems: + items = [ + {"id": s.id, "name": s.name or "", "description": s.description or ""} + for s in systems + ] + if not items and fmt == "text": print("No logbook systems found.") return + columns = [("id", "ID", 6), ("name", "Name", 30), ("description", "Description", 0)] + print_items(items, columns, fmt) - print(f"{'ID':<6} {'Name':<30} {'Description'}") - print(f"{'--':<6} {'----':<30} {'-----------'}") - for s in systems: - desc = s.description or "" - print(f"{s.id:<6} {s.name:<30} {desc}") - -def cmd_list_templates(): +def cmd_list_templates(fmt="text"): """List all logbook templates.""" factory = auth.get_factory() logbook_api = factory.get_logbook_api() templates = logbook_api.get_logbook_templates() - if not templates: + items = [ + {"id": t.id, "name": t.name or "", "description": t.description or ""} + for t in templates + ] + if not items and fmt == "text": print("No logbook templates found.") return - - print(f"{'ID':<6} {'Name':<30} {'Description'}") - print(f"{'--':<6} {'----':<30} {'-----------'}") - for t in templates: - desc = t.description or "" - print(f"{t.id:<6} {t.name:<30} {desc}") + columns = [("id", "ID", 6), ("name", "Name", 30), ("description", "Description", 0)] + print_items(items, columns, fmt) diff --git a/tools/developer_tools/bely-cli/bely-cli/common.py b/tools/developer_tools/bely-cli/bely-cli/common.py index fc0a472f7..ece4601e2 100644 --- a/tools/developer_tools/bely-cli/bely-cli/common.py +++ b/tools/developer_tools/bely-cli/bely-cli/common.py @@ -4,16 +4,26 @@ import sys import tempfile +import yaml + import belyApi -def print_items(items, columns, json_output=False): - """Print a list of dicts as a table or JSON array. +# Supported values for the global --format option (single source of truth). +FORMATS = ("text", "json", "yaml") + + +def print_items(items, columns, fmt="text"): + """Print a list of dicts as a table, JSON array, or YAML sequence. - columns: list of (key, header, width) tuples for table format. + columns: list of (key, header, width) tuples for the table format. + fmt: one of FORMATS. """ - if json_output: - print(json.dumps(items, indent=2)) + if fmt == "json": + print(json.dumps(items, indent=2, default=str)) + return + if fmt == "yaml": + print(yaml.safe_dump(items, sort_keys=False, default_flow_style=False), end="") return header = " ".join(f"{h:<{w}}" for _, h, w in columns) sep = " ".join(f"{'-' * len(h):<{w}}" for _, h, w in columns) @@ -23,10 +33,12 @@ def print_items(items, columns, json_output=False): print(" ".join(f"{str(item.get(k, '')):<{w}}" for k, _, w in columns)) -def print_result(data, message, json_output=False): - """Print a confirmation message or JSON object.""" - if json_output: - print(json.dumps(data)) +def print_result(data, message, fmt="text"): + """Print a confirmation message (text) or structured data (json/yaml).""" + if fmt == "json": + print(json.dumps(data, default=str)) + elif fmt == "yaml": + print(yaml.safe_dump(data, sort_keys=False, default_flow_style=False), end="") else: print(message) @@ -43,8 +55,11 @@ def _sanitize_for_filename(name): return "".join(c if c.isalnum() or c in "-_." else "_" for c in name) -def write_entry_to_file(entry, doc_name, output_dir=None): - """Write entry markdown to _entry_.md in output_dir (cwd if None).""" +def write_entry_to_file(entry, doc_name, output_dir=None, fmt="text"): + """Write entry markdown to _entry_.md in output_dir (cwd if None). + + Returns the path written. Prints the confirmation line only for text format. + """ directory = os.path.expanduser(output_dir) if output_dir else "." if not os.path.isdir(directory): print(f"Error: output directory not found: {directory}", file=sys.stderr) @@ -53,7 +68,8 @@ def write_entry_to_file(entry, doc_name, output_dir=None): out_path = os.path.join(directory, f"{safe_doc}_entry_{entry.log_id}.md") with open(out_path, "w") as f: f.write(entry.log_entry or "") - print(f'Wrote log entry id={entry.log_id} to {out_path}') + if fmt == "text": + print(f'Wrote log entry id={entry.log_id} to {out_path}') return out_path diff --git a/tools/developer_tools/bely-cli/bely-cli/entry.py b/tools/developer_tools/bely-cli/bely-cli/entry.py index a553e2f05..688c8bd8e 100644 --- a/tools/developer_tools/bely-cli/bely-cli/entry.py +++ b/tools/developer_tools/bely-cli/bely-cli/entry.py @@ -3,7 +3,7 @@ from types import SimpleNamespace import auth -from common import find_logdoc, write_entry_to_file, open_in_editor +from common import find_logdoc, write_entry_to_file, open_in_editor, print_items, print_result def resolve_doc(logbook_api, doc_name, doc_id): @@ -23,7 +23,11 @@ def resolve_doc(logbook_api, doc_name, doc_id): return doc -def upload_and_print_attachment(logbook_api, doc_id, log_id, path): +def upload_and_print_attachment(logbook_api, doc_id, log_id, path, fmt="text"): + """Upload an attachment and return its details as a dict. + + Prints the human-readable summary only for text format. + """ basename = os.path.basename(path) att = logbook_api.upload_attachment( log_document_id=doc_id, @@ -32,14 +36,22 @@ def upload_and_print_attachment(logbook_api, doc_id, log_id, path): append_reference=True, file_name=basename, ) - print(f'Attachment "{basename}" uploaded') - print(f" original_filename: {att.original_filename}") - print(f" stored_filename: {att.stored_filename}") - print(f" download_path: {att.download_path}") - print(f" markdown_reference: {att.markdown_reference}") + info = { + "original_filename": att.original_filename, + "stored_filename": att.stored_filename, + "download_path": att.download_path, + "markdown_reference": att.markdown_reference, + } + if fmt == "text": + print(f'Attachment "{basename}" uploaded') + print(f" original_filename: {att.original_filename}") + print(f" stored_filename: {att.stored_filename}") + print(f" download_path: {att.download_path}") + print(f" markdown_reference: {att.markdown_reference}") + return info -def cmd_update_entry(doc_name, doc_id, entry_id, file, text, add_attachment): +def cmd_update_entry(doc_name, doc_id, entry_id, file, text, add_attachment, fmt="text"): """Update an existing log entry.""" if file and text: print("Error: --file and --text are mutually exclusive.", file=sys.stderr) @@ -103,12 +115,19 @@ def cmd_update_entry(doc_name, doc_id, entry_id, file, text, add_attachment): entry = user_entries[-1] # Update entry content + result = {"doc": doc.name, "log_id": entry.log_id, "status": None, + "attachment": None} if content: entry.log_entry = content entry = logbook_api.add_update_log_entry(log_entry=entry) - print(f'Log entry updated in "{doc.name}", log_id={entry.log_id}') + result["log_id"] = entry.log_id + result["status"] = "updated" + if fmt == "text": + print(f'Log entry updated in "{doc.name}", log_id={entry.log_id}') elif add_attachment: - upload_and_print_attachment(logbook_api, doc.id, entry.log_id, add_attachment) + result["attachment"] = upload_and_print_attachment( + logbook_api, doc.id, entry.log_id, add_attachment, fmt) + result["status"] = "attachment_added" else: # Interactive edit: open entry in editor original = entry.log_entry or "" @@ -116,12 +135,20 @@ def cmd_update_entry(doc_name, doc_id, entry_id, file, text, add_attachment): if edited != original: entry.log_entry = edited entry = logbook_api.add_update_log_entry(log_entry=entry) - print(f'Log entry updated in "{doc.name}", log_id={entry.log_id}') + result["log_id"] = entry.log_id + result["status"] = "updated" + if fmt == "text": + print(f'Log entry updated in "{doc.name}", log_id={entry.log_id}') else: - print("No changes made.") + result["status"] = "no_change" + if fmt == "text": + print("No changes made.") + + if fmt != "text": + print_result(result, "", fmt) -def cmd_add_entry(doc_name, doc_id, file, text, add_attachment): +def cmd_add_entry(doc_name, doc_id, file, text, add_attachment, fmt="text"): """Add a new log entry to an existing document.""" if file and text: print("Error: --file and --text are mutually exclusive.", file=sys.stderr) @@ -159,24 +186,37 @@ def cmd_add_entry(doc_name, doc_id, file, text, add_attachment): entry = logbook_api.get_log_entry_template(log_document_id=doc.id) + result = {"doc": doc.name, "log_id": None, "status": None, "attachment": None} if use_editor: edited = open_in_editor(entry.log_entry or "") if edited.strip(): entry.log_entry = edited entry = logbook_api.add_update_log_entry(log_entry=entry) - print(f'Log entry added to "{doc.name}", log_id={entry.log_id}') + result["log_id"] = entry.log_id + result["status"] = "added" + if fmt == "text": + print(f'Log entry added to "{doc.name}", log_id={entry.log_id}') else: - print("Empty entry, skipped.") + result["status"] = "skipped" + if fmt == "text": + print("Empty entry, skipped.") else: entry.log_entry = content or "" entry = logbook_api.add_update_log_entry(log_entry=entry) - print(f'Log entry added to "{doc.name}", log_id={entry.log_id}') + result["log_id"] = entry.log_id + result["status"] = "added" + if fmt == "text": + print(f'Log entry added to "{doc.name}", log_id={entry.log_id}') if add_attachment: - upload_and_print_attachment(logbook_api, doc.id, entry.log_id, add_attachment) + result["attachment"] = upload_and_print_attachment( + logbook_api, doc.id, entry.log_id, add_attachment, fmt) + if fmt != "text": + print_result(result, "", fmt) -def cmd_list_entries(doc_name, doc_id): + +def cmd_list_entries(doc_name, doc_id, fmt="text"): """List entries in a log document.""" factory = auth.get_factory() logbook_api = factory.get_logbook_api() @@ -184,21 +224,30 @@ def cmd_list_entries(doc_name, doc_id): entries = logbook_api.get_log_entries(log_document_id=doc.id) if not entries: - print(f'No entries found in document {doc.name}.') + if fmt == "text": + print(f'No entries found in document {doc.name}.') + else: + print_items([], [], fmt) return - print(f"{'Log ID':<10} {'Date':<18} {'Author':<20} {'Snippet'}") - print(f"{'------':<10} {'----':<18} {'------':<20} {'-------'}") + items = [] for e in entries: date = e.entered_on_date_time.strftime("%Y-%m-%d %H:%M") if e.entered_on_date_time else "" - author = e.entered_by_username or "" snippet = (e.log_entry or "").strip().splitlines()[0] if e.log_entry else "" if len(snippet) > 60: snippet = snippet[:57] + "..." - print(f"{e.log_id:<10} {date:<18} {author:<20} {snippet}") + items.append({ + "log_id": e.log_id, + "date": date, + "author": e.entered_by_username or "", + "snippet": snippet, + }) + columns = [("log_id", "Log ID", 10), ("date", "Date", 18), + ("author", "Author", 20), ("snippet", "Snippet", 0)] + print_items(items, columns, fmt) -def cmd_get_entry(doc_name, doc_id, entry_id, output_dir): +def cmd_get_entry(doc_name, doc_id, entry_id, output_dir, fmt="text"): """Write the markdown of a log entry to a file (latest by default).""" factory = auth.get_factory() logbook_api = factory.get_logbook_api() @@ -219,4 +268,10 @@ def cmd_get_entry(doc_name, doc_id, entry_id, output_dir): entry = entries[-1] name_for_file = doc_name if doc_name else str(doc.id) - write_entry_to_file(entry, name_for_file, output_dir) + out_path = write_entry_to_file(entry, name_for_file, output_dir, fmt) + if fmt != "text": + print_result( + {"log_id": entry.log_id, "path": out_path, "doc": doc.name}, + "", + fmt, + ) From 8b5c7b2437779b5a6e3193ea8c193619b17b4039 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 19 Jun 2026 11:16:09 -0500 Subject: [PATCH 14/62] bely-cli: update tests for --format and add json-output case --- .../bely-cli/test/test_commands.py | 1 + .../bely-cli/test/test_entry.py | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/tools/developer_tools/bely-cli/test/test_commands.py b/tools/developer_tools/bely-cli/test/test_commands.py index 7ee5b3d3c..d4eb2be25 100644 --- a/tools/developer_tools/bely-cli/test/test_commands.py +++ b/tools/developer_tools/bely-cli/test/test_commands.py @@ -77,6 +77,7 @@ def test_creates_doc_and_first_entry_from_file(self): no_template=False, output_dir=None, list_options=None, + fmt="text", ) finally: os.unlink(tmp_path) diff --git a/tools/developer_tools/bely-cli/test/test_entry.py b/tools/developer_tools/bely-cli/test/test_entry.py index 18a9b9ca2..e9c6112fd 100644 --- a/tools/developer_tools/bely-cli/test/test_entry.py +++ b/tools/developer_tools/bely-cli/test/test_entry.py @@ -1,4 +1,5 @@ import io +import json import os import sys import tempfile @@ -77,6 +78,7 @@ def test_add_entry_with_file_and_name(self): file=tmp_path, text=None, add_attachment=None, + fmt="text", ) finally: for p in patches: @@ -89,6 +91,37 @@ def test_add_entry_with_file_and_name(self): self.assertEqual(api.entry_saved.log_id, 99) self.assertIn('Log entry added to "My Doc", log_id=99', buf.getvalue()) + def test_add_entry_json_format(self): + api = FakeApi() + tmp_path = _write_tmp("added content\n") + + try: + patches = _patch_auth(api) + for p in patches: + p.start() + try: + buf = io.StringIO() + with redirect_stdout(buf): + entry.cmd_add_entry( + doc_name="My Doc", + doc_id=None, + file=tmp_path, + text=None, + add_attachment=None, + fmt="json", + ) + finally: + for p in patches: + p.stop() + finally: + os.unlink(tmp_path) + + # In JSON mode the only stdout is the structured result. + payload = json.loads(buf.getvalue()) + self.assertEqual(payload["log_id"], 99) + self.assertEqual(payload["status"], "added") + self.assertEqual(payload["doc"], "My Doc") + class CmdUpdateEntryTests(unittest.TestCase): def test_update_entry_with_file_and_name(self): @@ -115,6 +148,7 @@ def test_update_entry_with_file_and_name(self): file=tmp_path, text=None, add_attachment=None, + fmt="text", ) finally: for p in patches: From 7890fb6d8a9e7862c7235f7653d271dfe2ef8fce Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 19 Jun 2026 11:16:25 -0500 Subject: [PATCH 15/62] bely-cli: fix run_test.sh python typo and unittest discovery Correct the ptyhon typo, run unittest from the project dir so tests are discovered, and fail on any error (set -e). --- tools/developer_tools/bely-cli/run_test.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/tools/developer_tools/bely-cli/run_test.sh b/tools/developer_tools/bely-cli/run_test.sh index ee6ef2838..457bc8b05 100644 --- a/tools/developer_tools/bely-cli/run_test.sh +++ b/tools/developer_tools/bely-cli/run_test.sh @@ -1,7 +1,15 @@ #!/bin/bash +set -e -PYTHON=/C2/conda/envs/bely/bin/ptyhon +PYTHON=/C2/conda/envs/bely/bin/python -$PYTHON -m unittest +# COMPONENT_DIR is set by the test harness; default to this script's dir so the +# test is also runnable standalone. +COMPONENT_DIR="${COMPONENT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" -bely.py -h +# Unit tests (run from the project dir so unittest discovers test/). +(cd "$COMPONENT_DIR" && "$PYTHON" -m unittest) + +# Smoke test: the published command loads and the global --format option is wired. +bely.py -h > /dev/null +bely.py --format json doc -h > /dev/null From 38f53c5d29060aa59aba1d4d7d5a07a75bd3b8f5 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 19 Jun 2026 11:37:30 -0500 Subject: [PATCH 16/62] Add readme with instructions on usage of the CLI --- tools/developer_tools/bely-cli/README.md | 225 +++++++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 tools/developer_tools/bely-cli/README.md diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md new file mode 100644 index 000000000..af196c3fd --- /dev/null +++ b/tools/developer_tools/bely-cli/README.md @@ -0,0 +1,225 @@ +# bely-cli + +A command-line client for the **BELY logbook**. Create log documents, add and update log +entries (from files, inline text, an editor, or with attachments), list and fetch entries, +and manage local configuration. + +The published command is `bely.py`. + +## Getting started + +After loading the `aux` module the command is on your `PATH`: + +```bash +module add aux +bely.py -h +``` + +Every command and group accepts `-h` / `--help`: + +```bash +bely.py doc -h +bely.py entry add -h +``` + +### Set the server host + +The CLI needs to know the BELY server URL. If it is not set, commands exit with an error. +The host is resolved in this order: + +1. `BELY_HOST` environment variable +2. `host` in `~/.config/bely/settings.yaml` +3. otherwise → error + +Set it once with `config set`: + +```bash +bely.py config set host https://tinkerbox.aps.anl.gov:8181/bely +``` + +> A convenience wrapper named `bely` ships in the source directory; it presets +> `BELY_HOST` to `https://tinkerbox.aps.anl.gov:8181/bely` before invoking `bely.py`. The +> examples in this README use `bely.py` directly, so set the host (or export `BELY_HOST`) +> as shown above. + +## Authentication + +Mutating operations (creating documents, adding/updating entries) require authentication; +lookups (listing types, systems, templates, finding documents) do not. + +- **Username** — from `BELY_USER`, else `user` in the settings file, else you are prompted. +- **Password** — from `BELY_PASSWORD` (useful for automation), else you are prompted. + +On success a token is cached at `~/.config/bely/token` (permissions `0600`) and reused on +later runs. Expired or invalid tokens are discarded and you re-authenticate automatically. + +## Output format + +A global `--format` option controls output for all commands: + +```bash +bely.py --format json doc list +``` + +| Value | Behavior | +|--------|-------------------------------------------------------| +| `text` | Human-readable tables and messages (default) | +| `json` | Structured JSON — for scripting | +| `yaml` | Structured YAML — for scripting | + +`--format` goes before the command group, e.g. `bely.py --format yaml entry list -n "..."`. + +## Commands + +### `doc` — log documents + +#### `bely.py doc new` + +Create a new log document (and optionally its first entry). + +| Option | Description | +|--------|-------------| +| `--type TEXT` | Logbook type (e.g. `ops`, `controls`). Prompted if omitted. | +| `-n, --name TEXT` | Name for the new document. Prompted if omitted. | +| `-f, --file TEXT` | Markdown file to use as the first log entry. | +| `--template TEXT` | Template name to use for the first entry. | +| `--systems TEXT` | Comma-separated system list, e.g. `SR,software`. | +| `--no-template` | Skip template selection. Mutually exclusive with `--template`. | +| `-o, --output TEXT` | Directory to write a template-generated entry into (default: cwd). | +| `--list-options {system,type,template}` | List the available values for that option and exit. | + +#### `bely.py doc list` + +List recent log documents you created, newest first. + +| Option | Description | +|--------|-------------| +| `--limit INTEGER` | Maximum documents to return (default: 20). | + +### `entry` — log entries + +All `entry` commands identify the target document with **either** `-n/--doc-name` **or** +`-d/--doc-id` (provide one). + +#### `bely.py entry add` + +Add a new entry to an existing document. If none of `--file`, `--text`, or +`--add-attachment` is given, your `$EDITOR` opens for the entry text. + +| Option | Description | +|--------|-------------| +| `-n, --doc-name TEXT` | Document name. | +| `-d, --doc-id INTEGER` | Document ID. | +| `-f, --file TEXT` | Markdown file with the entry content. | +| `-t, --text TEXT` | Inline text for the entry. | +| `--add-attachment TEXT` | File to attach to the entry. | + +#### `bely.py entry update` + +Update an existing entry. With no `--id`, your most recent entry in the document is +updated. If none of `--file`, `--text`, or `--add-attachment` is given, your `$EDITOR` +opens. `--file` and `--text` are mutually exclusive. + +| Option | Description | +|--------|-------------| +| `-n, --doc-name TEXT` | Document name. | +| `-d, --doc-id INTEGER` | Document ID. | +| `--id INTEGER` | Specific entry ID to update (default: your most recent entry). | +| `-f, --file TEXT` | Markdown file with the updated content. | +| `-t, --text TEXT` | Inline text for the entry. | +| `--add-attachment TEXT` | File to attach to the entry. | + +#### `bely.py entry list` + +List the entries in a document (Log ID, date, author, and a snippet of the first line). + +| Option | Description | +|--------|-------------| +| `-n, --doc-name TEXT` | Document name. | +| `-d, --doc-id INTEGER` | Document ID. | + +#### `bely.py entry get` + +Write the markdown of an entry to a file named `_entry_.md`. + +| Option | Description | +|--------|-------------| +| `-n, --doc-name TEXT` | Document name. | +| `-d, --doc-id INTEGER` | Document ID. | +| `--id INTEGER` | Specific entry ID (default: latest). | +| `-o, --output TEXT` | Directory to write the file into (default: cwd). | + +### `config` — local configuration + +#### `bely.py config show` + +Show the current configuration: values from the settings file and the relevant environment +variables (`BELY_PASSWORD` is masked). + +#### `bely.py config edit` + +Open `~/.config/bely/settings.yaml` in your `$EDITOR` (defaults to `vi`). The file and +directory are created if needed. + +#### `bely.py config set FIELD VALUE` + +Set a single configuration field. `FIELD` is one of `host` or `user`. + +```bash +bely.py config set user alice +bely.py config set host https://tinkerbox.aps.anl.gov:8181/bely +``` + +## Configuration & environment + +| Location / variable | Purpose | +|---------------------|---------| +| `~/.config/bely/settings.yaml` | Persistent settings (`host`, `user`); permissions `0600`. | +| `~/.config/bely/token` | Cached auth token; permissions `0600`. | +| `BELY_HOST` | Server URL (overrides the settings file). | +| `BELY_USER` | Username (overrides the settings file). | +| `BELY_PASSWORD` | Password for non-interactive authentication. | +| `EDITOR` | Editor used for interactive entries and `config edit` (default: `vi`). | + +## Examples + +```bash +# One-time setup +bely.py config set host https://tinkerbox.aps.anl.gov:8181/bely +bely.py config set user alice + +# Discover available values +bely.py doc new --list-options type +bely.py doc new --list-options system +bely.py doc new --list-options template + +# Create a document interactively (prompts for type and name) +bely.py doc new + +# Create a document with everything specified, plus a first entry from a file +bely.py doc new --type ops --name "Shift Report" --systems SR,software --file entry.md + +# List your recent documents +bely.py doc list --limit 50 + +# Add an entry — inline text, from a file, or via your editor +bely.py entry add -n "Shift Report" -t "Beam restored after RF trip." +bely.py entry add -n "Shift Report" -f entry.md +bely.py entry add -n "Shift Report" # opens $EDITOR + +# Attach a file to an entry +bely.py entry add -n "Shift Report" --add-attachment plot.png + +# Update your most recent entry, or a specific one +bely.py entry update -n "Shift Report" -t "Corrected: trip was on RF2." +bely.py entry update -n "Shift Report" --id 42 -f revised.md + +# List and fetch entries +bely.py entry list -n "Shift Report" +bely.py entry get -n "Shift Report" # latest, to cwd +bely.py entry get -d 99 --id 42 -o ~/logs/ + +# Structured output for scripting +bely.py --format json doc list +bely.py --format yaml entry list -n "Shift Report" +``` From 7039c45465c4b8168a68a4e265a0be687fac93af Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 19 Jun 2026 12:13:10 -0500 Subject: [PATCH 17/62] Extend the settings file path and token path(via settings file) to be configurable. Also allow editor to be set in settings file. --- tools/developer_tools/bely-cli/README.md | 22 ++++-- .../developer_tools/bely-cli/bely-cli/auth.py | 23 ++++-- .../bely-cli/bely-cli/commands.py | 4 +- .../bely-cli/bely-cli/common.py | 4 +- .../bely-cli/bely-cli/config.py | 25 ++++++- .../bely-cli/test/test_config.py | 71 +++++++++++++++++++ 6 files changed, 130 insertions(+), 19 deletions(-) create mode 100644 tools/developer_tools/bely-cli/test/test_config.py diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index af196c3fd..98499ad5c 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -53,6 +53,9 @@ lookups (listing types, systems, templates, finding documents) do not. On success a token is cached at `~/.config/bely/token` (permissions `0600`) and reused on later runs. Expired or invalid tokens are discarded and you re-authenticate automatically. +The token location can be changed with the `token_path` setting; by default it sits beside +the settings file (see [Configuration & environment](#configuration--environment)). + ## Output format A global `--format` option controls output for all commands: @@ -158,28 +161,35 @@ variables (`BELY_PASSWORD` is masked). #### `bely.py config edit` -Open `~/.config/bely/settings.yaml` in your `$EDITOR` (defaults to `vi`). The file and -directory are created if needed. +Open the settings file in your editor. The editor is resolved from `EDITOR`, then the +`editor` setting, then `vi`. The file and directory are created if needed. #### `bely.py config set FIELD VALUE` -Set a single configuration field. `FIELD` is one of `host` or `user`. +Set a single configuration field. `FIELD` is one of `host`, `user`, `editor`, or +`token_path`. ```bash bely.py config set user alice bely.py config set host https://tinkerbox.aps.anl.gov:8181/bely +bely.py config set editor nano +bely.py config set token_path ~/.secrets/bely-token ``` ## Configuration & environment | Location / variable | Purpose | |---------------------|---------| -| `~/.config/bely/settings.yaml` | Persistent settings (`host`, `user`); permissions `0600`. | -| `~/.config/bely/token` | Cached auth token; permissions `0600`. | +| `~/.config/bely/settings.yaml` | Persistent settings (`host`, `user`, `editor`, `token_path`); permissions `0600`. | +| `~/.config/bely/token` | Cached auth token; permissions `0600`. Override with the `token_path` setting. | +| `BELY_SETTINGS_FILE` | Path to the settings file (overrides the default location). The default token sits beside it. | | `BELY_HOST` | Server URL (overrides the settings file). | | `BELY_USER` | Username (overrides the settings file). | | `BELY_PASSWORD` | Password for non-interactive authentication. | -| `EDITOR` | Editor used for interactive entries and `config edit` (default: `vi`). | +| `EDITOR` | Editor for interactive entries and `config edit`. Falls back to the `editor` setting, then `vi`. | + +Settings that hold paths (`token_path`) and the `BELY_SETTINGS_FILE` env var expand `~` and +`$VARS`. ## Examples diff --git a/tools/developer_tools/bely-cli/bely-cli/auth.py b/tools/developer_tools/bely-cli/bely-cli/auth.py index 3534d83fa..46cbc0a60 100644 --- a/tools/developer_tools/bely-cli/bely-cli/auth.py +++ b/tools/developer_tools/bely-cli/bely-cli/auth.py @@ -6,9 +6,15 @@ import belyApi from BelyApiFactory import BelyApiFactory -from config import CONFIG_DIR, _ensure_config_dir, get_setting +from config import CONFIG_DIR, expand_path, get_setting -TOKEN_FILE = os.path.join(CONFIG_DIR, "token") + +def get_token_file(): + """Return the token file path: 'token_path' setting, else /token.""" + configured = get_setting("token_path") + if configured: + return expand_path(configured) + return os.path.join(CONFIG_DIR, "token") def get_host(): @@ -45,7 +51,7 @@ def get_password(username): def load_token(): """Return the cached auth token from disk, or None if not present.""" try: - with open(TOKEN_FILE, "r") as f: + with open(get_token_file(), "r") as f: return f.read().strip() or None except FileNotFoundError: return None @@ -53,16 +59,19 @@ def load_token(): def save_token(token): """Persist the auth token to disk with restrictive permissions.""" - _ensure_config_dir() - with open(TOKEN_FILE, "w") as f: + token_file = get_token_file() + parent = os.path.dirname(token_file) + if parent: + os.makedirs(parent, mode=0o700, exist_ok=True) + with open(token_file, "w") as f: f.write(token) - os.chmod(TOKEN_FILE, 0o600) + os.chmod(token_file, 0o600) def delete_token(): """Remove the cached token file.""" try: - os.remove(TOKEN_FILE) + os.remove(get_token_file()) except FileNotFoundError: pass diff --git a/tools/developer_tools/bely-cli/bely-cli/commands.py b/tools/developer_tools/bely-cli/bely-cli/commands.py index a31174699..2eb239ed5 100644 --- a/tools/developer_tools/bely-cli/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/bely-cli/commands.py @@ -8,7 +8,7 @@ from common import find_logdoc, write_entry_to_file, open_in_editor, print_items, print_result -ENV_VARS = ["BELY_HOST", "BELY_USER", "BELY_PASSWORD"] +ENV_VARS = ["BELY_HOST", "BELY_USER", "BELY_PASSWORD", "BELY_SETTINGS_FILE", "EDITOR"] def cmd_show_config(fmt="text"): @@ -86,7 +86,7 @@ def cmd_edit_config(): config._ensure_config_dir() if not os.path.exists(config.SETTINGS_FILE): config.save_settings({}) - editor = os.environ.get("EDITOR", "vi") + editor = config.get_editor() os.execvp(editor, [editor, config.SETTINGS_FILE]) diff --git a/tools/developer_tools/bely-cli/bely-cli/common.py b/tools/developer_tools/bely-cli/bely-cli/common.py index ece4601e2..cdf0f6986 100644 --- a/tools/developer_tools/bely-cli/bely-cli/common.py +++ b/tools/developer_tools/bely-cli/bely-cli/common.py @@ -8,6 +8,8 @@ import belyApi +import config + # Supported values for the global --format option (single source of truth). FORMATS = ("text", "json", "yaml") @@ -79,7 +81,7 @@ def open_in_editor(initial_content=""): tmp.write(initial_content) tmp_path = tmp.name try: - editor = os.environ.get("EDITOR", "vi") + editor = config.get_editor() subprocess.call([editor, tmp_path]) with open(tmp_path, "r") as f: return f.read() diff --git a/tools/developer_tools/bely-cli/bely-cli/config.py b/tools/developer_tools/bely-cli/bely-cli/config.py index 1ded10d67..e19a62d68 100644 --- a/tools/developer_tools/bely-cli/bely-cli/config.py +++ b/tools/developer_tools/bely-cli/bely-cli/config.py @@ -1,10 +1,24 @@ import os import yaml -CONFIG_DIR = os.path.expanduser("~/.config/bely") -SETTINGS_FILE = os.path.join(CONFIG_DIR, "settings.yaml") +DEFAULT_CONFIG_DIR = os.path.expanduser("~/.config/bely") -VALID_FIELDS = ("host", "user") +VALID_FIELDS = ("host", "user", "editor", "token_path") + + +def expand_path(path): + """Expand ~ and $VARS in a path string.""" + return os.path.expanduser(os.path.expandvars(path)) + + +# The settings file location can be overridden with BELY_SETTINGS_FILE; the +# config dir (where the default token lives) follows the settings file. +_settings_env = os.environ.get("BELY_SETTINGS_FILE") +if _settings_env: + SETTINGS_FILE = expand_path(_settings_env) +else: + SETTINGS_FILE = os.path.join(DEFAULT_CONFIG_DIR, "settings.yaml") +CONFIG_DIR = os.path.dirname(SETTINGS_FILE) def _ensure_config_dir(): @@ -39,3 +53,8 @@ def set_setting(key, value): data = load_settings() data[key] = value save_settings(data) + + +def get_editor(): + """Return the editor: EDITOR env var, then 'editor' setting, then 'vi'.""" + return os.environ.get("EDITOR") or get_setting("editor") or "vi" diff --git a/tools/developer_tools/bely-cli/test/test_config.py b/tools/developer_tools/bely-cli/test/test_config.py new file mode 100644 index 000000000..639f7dee3 --- /dev/null +++ b/tools/developer_tools/bely-cli/test/test_config.py @@ -0,0 +1,71 @@ +import importlib +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bely-cli")) + +import config +import auth + + +class GetEditorTests(unittest.TestCase): + def test_env_var_wins(self): + with patch.dict(os.environ, {"EDITOR": "emacs"}), \ + patch.object(config, "get_setting", return_value="nano"): + self.assertEqual(config.get_editor(), "emacs") + + def test_settings_used_when_no_env(self): + with patch.dict(os.environ, {}, clear=False), \ + patch.object(config, "get_setting", return_value="nano"): + os.environ.pop("EDITOR", None) + self.assertEqual(config.get_editor(), "nano") + + def test_default_vi(self): + with patch.dict(os.environ, {}, clear=False), \ + patch.object(config, "get_setting", return_value=None): + os.environ.pop("EDITOR", None) + self.assertEqual(config.get_editor(), "vi") + + +class GetTokenFileTests(unittest.TestCase): + def test_default_is_sibling_of_settings(self): + with patch.object(auth, "get_setting", return_value=None): + self.assertEqual( + auth.get_token_file(), + os.path.join(config.CONFIG_DIR, "token"), + ) + + def test_settings_override_with_expansion(self): + with patch.dict(os.environ, {"MYDIR": "/tmp/belytok"}), \ + patch.object(auth, "get_setting", return_value="$MYDIR/tok"): + self.assertEqual(auth.get_token_file(), "/tmp/belytok/tok") + + def test_settings_override_with_tilde(self): + with patch.object(auth, "get_setting", return_value="~/mytoken"): + self.assertEqual( + auth.get_token_file(), + os.path.join(os.path.expanduser("~"), "mytoken"), + ) + + +class SettingsFileEnvTests(unittest.TestCase): + def tearDown(self): + # Reload back to defaults so other test modules see a clean config. + os.environ.pop("BELY_SETTINGS_FILE", None) + importlib.reload(config) + importlib.reload(auth) + + def test_env_var_sets_settings_file_and_config_dir(self): + with patch.dict(os.environ, {"BELY_SETTINGS_FILE": "/tmp/bely/custom.yaml"}): + importlib.reload(config) + importlib.reload(auth) + self.assertEqual(config.SETTINGS_FILE, "/tmp/bely/custom.yaml") + self.assertEqual(config.CONFIG_DIR, "/tmp/bely") + with patch.object(auth, "get_setting", return_value=None): + self.assertEqual(auth.get_token_file(), "/tmp/bely/token") + + +if __name__ == "__main__": + unittest.main() From 14b1dc54285a760eb091b5b015c1b738d2d72095 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 19 Jun 2026 12:18:52 -0500 Subject: [PATCH 18/62] Add ability to have shared settings and user specific override. --- tools/developer_tools/bely-cli/README.md | 28 ++++++++- .../bely-cli/bely-cli/config.py | 32 ++++++++-- .../bely-cli/test/test_config.py | 60 +++++++++++++++++++ 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index 98499ad5c..90a2f5787 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -188,8 +188,32 @@ bely.py config set token_path ~/.secrets/bely-token | `BELY_PASSWORD` | Password for non-interactive authentication. | | `EDITOR` | Editor for interactive entries and `config edit`. Falls back to the `editor` setting, then `vi`. | -Settings that hold paths (`token_path`) and the `BELY_SETTINGS_FILE` env var expand `~` and -`$VARS`. +Settings that hold paths (`token_path`, `setting_override_path`) and the +`BELY_SETTINGS_FILE` env var expand `~` and `$VARS`. + +### Shared settings with per-user overrides + +When the settings file lives in a shared, read-only location, add a +`setting_override_path` key pointing to a file the user owns. Keys in that file are merged +on top of the shared settings, so each user can override individual values (e.g. `user` or +`token_path`) without write access to the shared file. The override file is plain YAML and +is hand-edited — `setting_override_path` is **not** set via the CLI, and a +`setting_override_path` key inside the override file itself is ignored (no chaining). + +Shared `settings.yaml` (read-only). It can still hold per-user paths like `token_path` +because `~` expands to each user's own home directory: + +```yaml +host: https://tinkerbox.aps.anl.gov:8181/bely +token_path: ~/.config/bely/token +setting_override_path: ~/.config/bely/overrides.yaml +``` + +User-owned `~/.config/bely/overrides.yaml` — only the keys that differ per user: + +```yaml +user: alice +``` ## Examples diff --git a/tools/developer_tools/bely-cli/bely-cli/config.py b/tools/developer_tools/bely-cli/bely-cli/config.py index e19a62d68..2d4613fd8 100644 --- a/tools/developer_tools/bely-cli/bely-cli/config.py +++ b/tools/developer_tools/bely-cli/bely-cli/config.py @@ -26,15 +26,33 @@ def _ensure_config_dir(): os.makedirs(CONFIG_DIR, mode=0o700) -def load_settings(): - """Read settings.yaml and return as a dict (empty dict if missing).""" +def _read_yaml(path): + """Read a YAML file into a dict (empty dict if missing).""" try: - with open(SETTINGS_FILE, "r") as f: + with open(path, "r") as f: return yaml.safe_load(f) or {} except FileNotFoundError: return {} +def load_settings(): + """Read the settings file and return it as a dict (empty if missing). + + If the settings file defines ``setting_override_path``, keys from that + file are merged on top of the base settings. This lets a user override + individual values (e.g. ``user``) in a file they own, even when the main + settings file lives in a shared, read-only location. + """ + settings = _read_yaml(SETTINGS_FILE) + override_path = settings.get("setting_override_path") + if override_path: + overrides = _read_yaml(expand_path(override_path)) + # The override file overrides values only; it cannot re-chain. + overrides.pop("setting_override_path", None) + settings.update(overrides) + return settings + + def save_settings(data): """Write a dict to settings.yaml, creating the config dir if needed.""" _ensure_config_dir() @@ -49,8 +67,12 @@ def get_setting(key): def set_setting(key, value): - """Update a single setting and save.""" - data = load_settings() + """Update a single setting and save. + + Operates on the base settings file only (not merged override values), so + overridden keys are never baked back into the base file. + """ + data = _read_yaml(SETTINGS_FILE) data[key] = value save_settings(data) diff --git a/tools/developer_tools/bely-cli/test/test_config.py b/tools/developer_tools/bely-cli/test/test_config.py index 639f7dee3..af7ff3e22 100644 --- a/tools/developer_tools/bely-cli/test/test_config.py +++ b/tools/developer_tools/bely-cli/test/test_config.py @@ -1,6 +1,7 @@ import importlib import os import sys +import tempfile import unittest from unittest.mock import patch @@ -50,6 +51,65 @@ def test_settings_override_with_tilde(self): ) +class SettingOverridePathTests(unittest.TestCase): + """Keys from setting_override_path are merged on top of base settings.""" + + def _write(self, dir_, name, text): + path = os.path.join(dir_, name) + with open(path, "w") as f: + f.write(text) + return path + + def test_override_merges_on_top_of_base(self): + with tempfile.TemporaryDirectory() as d: + override = self._write(d, "overrides.yaml", "user: alice\n") + base = self._write( + d, "settings.yaml", + f"host: shared-host\nuser: bob\nsetting_override_path: {override}\n", + ) + with patch.object(config, "SETTINGS_FILE", base): + settings = config.load_settings() + self.assertEqual(settings["host"], "shared-host") # base kept + self.assertEqual(settings["user"], "alice") # overridden + + def test_missing_override_file_is_ignored(self): + with tempfile.TemporaryDirectory() as d: + base = self._write( + d, "settings.yaml", + f"user: bob\nsetting_override_path: {d}/nope.yaml\n", + ) + with patch.object(config, "SETTINGS_FILE", base): + self.assertEqual(config.load_settings()["user"], "bob") + + def test_override_cannot_rechain(self): + with tempfile.TemporaryDirectory() as d: + override = self._write( + d, "overrides.yaml", + "user: alice\nsetting_override_path: /etc/evil.yaml\n", + ) + base = self._write( + d, "settings.yaml", f"setting_override_path: {override}\n", + ) + with patch.object(config, "SETTINGS_FILE", base): + settings = config.load_settings() + self.assertEqual(settings["user"], "alice") + # base's override path is preserved; the override file's is dropped. + self.assertEqual(settings["setting_override_path"], override) + + def test_set_setting_does_not_bake_in_overrides(self): + with tempfile.TemporaryDirectory() as d: + override = self._write(d, "overrides.yaml", "user: alice\n") + base = self._write( + d, "settings.yaml", + f"user: bob\nsetting_override_path: {override}\n", + ) + with patch.object(config, "SETTINGS_FILE", base): + config.set_setting("host", "new-host") + raw_base = config._read_yaml(base) + self.assertEqual(raw_base["host"], "new-host") + self.assertEqual(raw_base["user"], "bob") # not overwritten by override + + class SettingsFileEnvTests(unittest.TestCase): def tearDown(self): # Reload back to defaults so other test modules see a clean config. From 291c67d3c8d49cc4f8b29dbcc27182b4458e98ce Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 19 Jun 2026 12:35:52 -0500 Subject: [PATCH 19/62] BELY API import is quite large. Simple -h commands took 2+ seconds. Improve performance by selectively importing it. --- tools/developer_tools/bely-cli/bely-cli/auth.py | 12 +++++++++--- tools/developer_tools/bely-cli/bely-cli/commands.py | 3 +-- tools/developer_tools/bely-cli/bely-cli/common.py | 3 +-- tools/developer_tools/bely-cli/test/test_commands.py | 5 ++--- 4 files changed, 13 insertions(+), 10 deletions(-) diff --git a/tools/developer_tools/bely-cli/bely-cli/auth.py b/tools/developer_tools/bely-cli/bely-cli/auth.py index 46cbc0a60..054b65f5c 100644 --- a/tools/developer_tools/bely-cli/bely-cli/auth.py +++ b/tools/developer_tools/bely-cli/bely-cli/auth.py @@ -3,11 +3,12 @@ import sys from contextlib import contextmanager -import belyApi - -from BelyApiFactory import BelyApiFactory from config import CONFIG_DIR, expand_path, get_setting +# belyApi and BelyApiFactory are imported lazily inside the functions that need +# them: the generated client is ~1.8s to import, and paths like --help that +# never make a network call should not pay that cost. + def get_token_file(): """Return the token file path: 'token_path' setting, else /token.""" @@ -78,11 +79,13 @@ def delete_token(): def get_factory(): """Create and return an unauthenticated BelyApiFactory.""" + from BelyApiFactory import BelyApiFactory return BelyApiFactory(bely_url=get_host()) def _login_and_cache(factory): """Prompt for credentials, authenticate the factory, and persist the new token.""" + import belyApi username = get_username() password = get_password(username) try: @@ -109,6 +112,9 @@ def get_authenticated_factory(): 1. BELY_USER + BELY_PASSWORD env vars 2. Interactive prompt """ + import belyApi + from BelyApiFactory import BelyApiFactory + factory = BelyApiFactory(bely_url=get_host()) token = load_token() diff --git a/tools/developer_tools/bely-cli/bely-cli/commands.py b/tools/developer_tools/bely-cli/bely-cli/commands.py index 2eb239ed5..140143870 100644 --- a/tools/developer_tools/bely-cli/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/bely-cli/commands.py @@ -1,8 +1,6 @@ import os import sys -import belyApi - import auth import config from common import find_logdoc, write_entry_to_file, open_in_editor, print_items, print_result @@ -150,6 +148,7 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, # Build document options + import belyApi doc_opts = belyApi.LogDocumentOptions( name=name, logbook_type_id=logbook_type.id, diff --git a/tools/developer_tools/bely-cli/bely-cli/common.py b/tools/developer_tools/bely-cli/bely-cli/common.py index cdf0f6986..e88eb1a6a 100644 --- a/tools/developer_tools/bely-cli/bely-cli/common.py +++ b/tools/developer_tools/bely-cli/bely-cli/common.py @@ -6,8 +6,6 @@ import yaml -import belyApi - import config @@ -46,6 +44,7 @@ def print_result(data, message, fmt="text"): def find_logdoc(logbook_api, name): + import belyApi try: existing = logbook_api.get_log_document_by_name(name=name) return existing diff --git a/tools/developer_tools/bely-cli/test/test_commands.py b/tools/developer_tools/bely-cli/test/test_commands.py index d4eb2be25..45672ae57 100644 --- a/tools/developer_tools/bely-cli/test/test_commands.py +++ b/tools/developer_tools/bely-cli/test/test_commands.py @@ -10,7 +10,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bely-cli")) import commands -import common class _NF(Exception): @@ -64,8 +63,8 @@ def test_creates_doc_and_first_entry_from_file(self): try: with patch.object(commands.auth, "get_factory", return_value=factory), \ patch.object(commands.auth, "get_authenticated_factory", return_value=auth_ctx), \ - patch.object(commands.belyApi, "LogDocumentOptions") as opts_cls, \ - patch.object(common.belyApi.exceptions, "NotFoundException", _NF): + patch("belyApi.LogDocumentOptions") as opts_cls, \ + patch("belyApi.exceptions.NotFoundException", _NF): buf = io.StringIO() with redirect_stdout(buf): commands.cmd_new_doc( From a5de079ba3dc9198bb4128b7bf5c43d86c6a532f Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 19 Jun 2026 12:58:58 -0500 Subject: [PATCH 20/62] Format should show up and be applied to the commands that provide output. --- tools/developer_tools/bely-cli/README.md | 10 +-- .../developer_tools/bely-cli/bely-cli/bely.py | 64 ++++++++++--------- tools/developer_tools/bely-cli/run_test.sh | 5 +- 3 files changed, 42 insertions(+), 37 deletions(-) diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index 90a2f5787..f88679d9c 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -58,10 +58,10 @@ the settings file (see [Configuration & environment](#configuration--environment ## Output format -A global `--format` option controls output for all commands: +A `--format` option controls output, appended to the command: ```bash -bely.py --format json doc list +bely.py doc list --format json ``` | Value | Behavior | @@ -70,7 +70,7 @@ bely.py --format json doc list | `json` | Structured JSON — for scripting | | `yaml` | Structured YAML — for scripting | -`--format` goes before the command group, e.g. `bely.py --format yaml entry list -n "..."`. +`--format` is given at the end of a command, e.g. `bely.py entry list -n "..." --format yaml`. ## Commands @@ -254,6 +254,6 @@ bely.py entry get -n "Shift Report" # latest, to cwd bely.py entry get -d 99 --id 42 -o ~/logs/ # Structured output for scripting -bely.py --format json doc list -bely.py --format yaml entry list -n "Shift Report" +bely.py doc list --format json +bely.py entry list -n "Shift Report" --format yaml ``` diff --git a/tools/developer_tools/bely-cli/bely-cli/bely.py b/tools/developer_tools/bely-cli/bely-cli/bely.py index 987a6b2f5..56a0805ef 100755 --- a/tools/developer_tools/bely-cli/bely-cli/bely.py +++ b/tools/developer_tools/bely-cli/bely-cli/bely.py @@ -22,14 +22,18 @@ CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) +def format_option(f): + """Per-command --format option, appended to each leaf command.""" + return click.option( + "--format", "output_format", type=click.Choice(FORMATS), default="text", + help="Output format: text, json, yaml (default: text)", + )(f) + + @click.group(context_settings=CONTEXT_SETTINGS) -@click.option("--format", "output_format", type=click.Choice(FORMATS), - default="text", help="Output format (default: text)") -@click.pass_context -def cli(ctx, output_format): +def cli(): """BELY logbook CLI""" - ctx.ensure_object(dict) - ctx.obj["format"] = output_format + pass # -- doc -- @@ -53,18 +57,18 @@ def doc_group(): type=click.Choice(["system", "type", "template"]), default=None, help="List available values for the given option and exit") -@click.pass_context -def doc_new(ctx, **kwargs): +@format_option +def doc_new(output_format, **kwargs): """Create a new log document.""" - cmd_new_doc(fmt=ctx.obj["format"], **kwargs) + cmd_new_doc(fmt=output_format, **kwargs) @doc_group.command("list") @click.option("--limit", default=20, type=int, help="Max documents to return (default 20)") -@click.pass_context -def doc_list(ctx, **kwargs): +@format_option +def doc_list(output_format, **kwargs): """List recent log documents created by you.""" - cmd_list_docs(fmt=ctx.obj["format"], **kwargs) + cmd_list_docs(fmt=output_format, **kwargs) # -- entry -- @@ -81,10 +85,10 @@ def entry_group(): @click.option("--file", "-f", "file", default=None, help="Markdown file with entry content") @click.option("--text", "-t", default=None, help="Inline text for the entry") @click.option("--add-attachment", default=None, help="File to attach to the entry") -@click.pass_context -def entry_add(ctx, **kwargs): +@format_option +def entry_add(output_format, **kwargs): """Add a new log entry to an existing document.""" - cmd_add_entry(fmt=ctx.obj["format"], **kwargs) + cmd_add_entry(fmt=output_format, **kwargs) @entry_group.command("update") @@ -94,19 +98,19 @@ def entry_add(ctx, **kwargs): @click.option("--file", "-f", "file", default=None, help="Markdown file with updated content") @click.option("--text", "-t", default=None, help="Inline text for the entry") @click.option("--add-attachment", default=None, help="File to attach to the entry") -@click.pass_context -def entry_update(ctx, **kwargs): +@format_option +def entry_update(output_format, **kwargs): """Update an existing log entry.""" - cmd_update_entry(fmt=ctx.obj["format"], **kwargs) + cmd_update_entry(fmt=output_format, **kwargs) @entry_group.command("list") @click.option("--doc-name", "-n", default=None, help="Log document name") @click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") -@click.pass_context -def entry_list(ctx, **kwargs): +@format_option +def entry_list(output_format, **kwargs): """List entries in a log document.""" - cmd_list_entries(fmt=ctx.obj["format"], **kwargs) + cmd_list_entries(fmt=output_format, **kwargs) @entry_group.command("get") @@ -115,10 +119,10 @@ def entry_list(ctx, **kwargs): @click.option("--id", "entry_id", default=None, type=int, help="Specific log entry ID (default: latest)") @click.option("--output", "-o", "output_dir", default=None, help="Directory to write _entry_.md into (default: cwd)") -@click.pass_context -def entry_get(ctx, **kwargs): +@format_option +def entry_get(output_format, **kwargs): """Write the markdown of a log entry to a file (latest by default).""" - cmd_get_entry(fmt=ctx.obj["format"], **kwargs) + cmd_get_entry(fmt=output_format, **kwargs) # -- config -- @@ -130,10 +134,10 @@ def config_group(): @config_group.command("show") -@click.pass_context -def config_show(ctx): +@format_option +def config_show(output_format): """Show current configuration.""" - cmd_show_config(fmt=ctx.obj["format"]) + cmd_show_config(fmt=output_format) @config_group.command("edit") @@ -145,10 +149,10 @@ def config_edit(): @config_group.command("set") @click.argument("field", type=click.Choice(VALID_FIELDS)) @click.argument("value") -@click.pass_context -def config_set(ctx, field, value): +@format_option +def config_set(field, value, output_format): """Set a configuration field to a value.""" - cmd_set_config(field, value, fmt=ctx.obj["format"]) + cmd_set_config(field, value, fmt=output_format) diff --git a/tools/developer_tools/bely-cli/run_test.sh b/tools/developer_tools/bely-cli/run_test.sh index 457bc8b05..8f09f5d50 100644 --- a/tools/developer_tools/bely-cli/run_test.sh +++ b/tools/developer_tools/bely-cli/run_test.sh @@ -10,6 +10,7 @@ COMPONENT_DIR="${COMPONENT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" # Unit tests (run from the project dir so unittest discovers test/). (cd "$COMPONENT_DIR" && "$PYTHON" -m unittest) -# Smoke test: the published command loads and the global --format option is wired. +# Smoke test: the published command loads and --format is wired per-command +# (appended to a leaf command, not at the top level). bely.py -h > /dev/null -bely.py --format json doc -h > /dev/null +bely.py doc list -h | grep -q -- --format From 8bf53dcf936d7bb17e31aa2a837e840e12f3a357 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 19 Jun 2026 13:42:53 -0500 Subject: [PATCH 21/62] Add a simpl TUI tool for lookiing up a log entry. --- tools/developer_tools/bely-cli/README.md | 40 +++ .../developer_tools/bely-cli/bely-cli/bely.py | 18 ++ .../developer_tools/bely-cli/bely-cli/tui.py | 281 ++++++++++++++++++ tools/developer_tools/bely-cli/run_test.sh | 1 + .../developer_tools/bely-cli/test/test_tui.py | 70 +++++ 5 files changed, 410 insertions(+) create mode 100644 tools/developer_tools/bely-cli/bely-cli/tui.py create mode 100644 tools/developer_tools/bely-cli/test/test_tui.py diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index f88679d9c..17db68f44 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -99,6 +99,46 @@ List recent log documents you created, newest first. |--------|-------------| | `--limit INTEGER` | Maximum documents to return (default: 20). | +### `tui` — interactive terminal UIs + +#### `bely.py tui lookup` + +Interactively browse to find a log entry when you don't already know its document. The TUI +drills down through three levels — **logbook → recent documents → entries** — and then shows +the entry's markdown in a scrollable view. Browsing is read-only and needs no authentication. + +```bash +bely.py tui lookup +bely.py tui lookup --limit 50 +``` + +| Option | Description | +|--------|-------------| +| `--limit INTEGER` | Recent documents to load per logbook (default: 100). | + +Keys: + +| Key | Action | +|-----|--------| +| `Up` / `Down`, `PgUp` / `PgDn` | Move the highlight (or scroll, in the entry view). | +| *type any text* | Incrementally filter the current list (case-insensitive substring). | +| `Backspace` | Edit the filter; with an empty filter, go back one level. | +| `Enter` | Open the highlighted item / drill in. In the entry view, select the entry. | +| `q` | (Entry view only) select the entry. | +| `Esc` | Go back one level; quits from the logbook list. | + +On selecting an entry the TUI exits and prints its `doc-id` / `log-id`, plus a ready-to-run +`bely.py entry get` command so you can fetch it: + +``` +doc-id: 99 +log-id: 42 +# fetch with: bely.py entry get -d 99 --id 42 +``` + +With `--format json` / `--format yaml` the selected reference is printed as structured data +instead. + ### `entry` — log entries All `entry` commands identify the target document with **either** `-n/--doc-name` **or** diff --git a/tools/developer_tools/bely-cli/bely-cli/bely.py b/tools/developer_tools/bely-cli/bely-cli/bely.py index 56a0805ef..ea1147828 100755 --- a/tools/developer_tools/bely-cli/bely-cli/bely.py +++ b/tools/developer_tools/bely-cli/bely-cli/bely.py @@ -17,6 +17,7 @@ cmd_list_entries, cmd_update_entry, ) +from tui import cmd_tui CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) @@ -125,6 +126,23 @@ def entry_get(output_format, **kwargs): cmd_get_entry(fmt=output_format, **kwargs) +# -- tui -- + +@cli.group("tui") +def tui_group(): + """Interactive terminal UIs.""" + pass + + +@tui_group.command("lookup") +@click.option("--limit", default=100, type=int, + help="Recent documents to load per logbook (default 100)") +@format_option +def tui_lookup(output_format, **kwargs): + """Interactively browse logbooks -> documents -> entries to find a log entry.""" + cmd_tui(fmt=output_format, **kwargs) + + # -- config -- @cli.group("config") diff --git a/tools/developer_tools/bely-cli/bely-cli/tui.py b/tools/developer_tools/bely-cli/bely-cli/tui.py new file mode 100644 index 000000000..21231df2f --- /dev/null +++ b/tools/developer_tools/bely-cli/bely-cli/tui.py @@ -0,0 +1,281 @@ +import curses +import sys +import textwrap + +import auth +from common import print_result + +# belyApi is intentionally NOT imported at module scope: the heavy client is +# pulled in lazily by auth.get_factory() only when the TUI actually runs, so the +# --help path stays fast (see auth.py). + + +# -- pure helpers (no curses; unit-tested) -- + +def format_type(t): + """Display string for a logbook type (EntityType).""" + display = getattr(t, "display_name", None) or "" + name = getattr(t, "name", None) or "" + return f"{name} ({display})" if display else name + + +def format_doc(d): + """Display string for a log document (ItemDomainLogbook).""" + name = getattr(d, "name", None) or "(unnamed)" + desc = getattr(d, "description", None) + return f"{name} - {desc}" if desc else name + + +def format_entry(e): + """Display string for a log entry: date, author, first-line snippet. + + Mirrors the snippet logic used by cmd_list_entries (entry.py). + """ + dt = getattr(e, "entered_on_date_time", None) + date = dt.strftime("%Y-%m-%d %H:%M") if dt else "" + author = getattr(e, "entered_by_username", None) or "" + body = getattr(e, "log_entry", None) or "" + lines = [ln for ln in body.strip().splitlines() if ln.strip()] + snippet = lines[0] if lines else "" + if len(snippet) > 60: + snippet = snippet[:57] + "..." + return f"{date} {author:<16} {snippet}".rstrip() + + +def filter_items(items, query, render_fn): + """Return items whose rendered string contains query (case-insensitive).""" + if not query: + return list(items) + q = query.lower() + return [it for it in items if q in render_fn(it).lower()] + + +def entry_reference(doc, entry): + """Reference dict for the selected entry, for json/yaml output.""" + return { + "doc_id": getattr(doc, "id", None), + "doc_name": getattr(doc, "name", None), + "log_id": getattr(entry, "log_id", None), + } + + +# -- curses helpers -- + +_ENTER_KEYS = (curses.KEY_ENTER, 10, 13) +_BACKSPACE_KEYS = (curses.KEY_BACKSPACE, 127, 8) +_ESC = 27 + + +def _addstr(stdscr, y, x, text, width, attr=0): + """Write text truncated to width, swallowing curses edge errors.""" + try: + stdscr.addstr(y, x, text[:max(0, width)], attr) + except curses.error: + pass + + +def _select(stdscr, title, items, render_fn): + """Interactive, filterable list. Return the chosen item, or None to go back. + + Up/Down/PgUp/PgDn move; printable chars filter; Backspace edits the filter + (and goes back when the filter is empty); Enter selects; Esc goes back. + """ + query = "" + pos = 0 # highlighted index within the filtered list + top = 0 # first visible row (for scrolling) + + while True: + height, width = stdscr.getmaxyx() + body_h = max(1, height - 3) # rows available for list items + shown = filter_items(items, query, render_fn) + + if pos >= len(shown): + pos = max(0, len(shown) - 1) + if pos < top: + top = pos + elif pos >= top + body_h: + top = pos - body_h + 1 + + stdscr.erase() + _addstr(stdscr, 0, 0, title, width, curses.A_BOLD) + + if not shown: + _addstr(stdscr, 2, 2, "(no items)", width) + else: + for row, item in enumerate(shown[top:top + body_h]): + idx = top + row + attr = curses.A_REVERSE if idx == pos else 0 + _addstr(stdscr, 2 + row, 0, " " + render_fn(item), width, attr) + + footer = f"Filter: {query}_ [Up/Down PgUp/PgDn] move [Enter] open [Esc] back (type to filter)" + _addstr(stdscr, height - 1, 0, footer, width, curses.A_DIM) + stdscr.refresh() + + ch = stdscr.getch() + if ch == curses.KEY_UP: + pos = max(0, pos - 1) + elif ch == curses.KEY_DOWN: + pos = min(len(shown) - 1, pos + 1) if shown else 0 + elif ch == curses.KEY_PPAGE: + pos = max(0, pos - body_h) + elif ch == curses.KEY_NPAGE: + pos = min(len(shown) - 1, pos + body_h) if shown else 0 + elif ch in _ENTER_KEYS: + if shown: + return shown[pos] + elif ch == _ESC: + return None + elif ch in _BACKSPACE_KEYS: + if query: + query = query[:-1] + pos = 0 + else: + return None # empty filter + backspace = go back + elif 32 <= ch <= 126: + query += chr(ch) + pos = 0 + + +def _view_entry(stdscr, doc, entry): + """Scrollable view of an entry's markdown. Return 'select' or 'back'.""" + body = getattr(entry, "log_entry", None) or "(empty entry)" + top = 0 + header = f'{getattr(doc, "name", "")} / log_id={getattr(entry, "log_id", "")}' + + while True: + height, width = stdscr.getmaxyx() + body_h = max(1, height - 3) + + lines = [] + for raw in body.splitlines() or [""]: + wrapped = textwrap.wrap(raw, max(1, width - 1)) or [""] + lines.extend(wrapped) + + max_top = max(0, len(lines) - body_h) + top = min(top, max_top) + + stdscr.erase() + _addstr(stdscr, 0, 0, header, width, curses.A_BOLD) + for row, line in enumerate(lines[top:top + body_h]): + _addstr(stdscr, 2 + row, 0, line, width) + footer = "[Up/Down PgUp/PgDn] scroll [Enter/q] select this entry [Esc] back" + _addstr(stdscr, height - 1, 0, footer, width, curses.A_DIM) + stdscr.refresh() + + ch = stdscr.getch() + if ch == curses.KEY_UP: + top = max(0, top - 1) + elif ch == curses.KEY_DOWN: + top = min(max_top, top + 1) + elif ch == curses.KEY_PPAGE: + top = max(0, top - body_h) + elif ch == curses.KEY_NPAGE: + top = min(max_top, top + body_h) + elif ch in _ENTER_KEYS or ch in (ord("q"), ord("Q")): + return "select" + elif ch == _ESC or ch in _BACKSPACE_KEYS: + return "back" + + +def _loading(stdscr, message): + """Show a transient status line while a network call runs.""" + _, width = stdscr.getmaxyx() + stdscr.erase() + _addstr(stdscr, 0, 0, message, width, curses.A_DIM) + stdscr.refresh() + + +def _show_error(stdscr, message): + """Show an error and wait for a keypress.""" + height, width = stdscr.getmaxyx() + stdscr.erase() + _addstr(stdscr, 0, 0, "Error", width, curses.A_BOLD) + for row, line in enumerate(textwrap.wrap(message, max(1, width - 1))): + _addstr(stdscr, 2 + row, 0, line, width) + _addstr(stdscr, height - 1, 0, "Press any key to go back", width, curses.A_DIM) + stdscr.refresh() + stdscr.getch() + + +def _run(stdscr, api, limit): + """Drill-down loop. Return (doc, entry) if confirmed, else None.""" + curses.curs_set(0) + + level = 0 + sel_type = sel_doc = sel_entry = None + docs = entries = [] + + while True: + if level == 0: + _loading(stdscr, "Loading logbooks...") + try: + types = api.get_logbook_types() + except Exception as e: # broad: avoid importing belyApi just for its exceptions + _show_error(stdscr, f"Could not load logbooks: {e}") + return None + chosen = _select(stdscr, "Select a logbook", types, format_type) + if chosen is None: + return None + sel_type = chosen + level = 1 + + elif level == 1: + _loading(stdscr, f"Loading recent documents in '{format_type(sel_type)}'...") + try: + docs = api.get_log_documents(logbook_type_id=sel_type.id, limit=limit) + except Exception as e: + _show_error(stdscr, f"Could not load documents: {e}") + level = 0 + continue + chosen = _select( + stdscr, f"{format_type(sel_type)} - recent documents", docs, format_doc) + if chosen is None: + level = 0 + continue + sel_doc = chosen + level = 2 + + elif level == 2: + _loading(stdscr, f"Loading entries in '{format_doc(sel_doc)}'...") + try: + entries = api.get_log_entries(log_document_id=sel_doc.id) + except Exception as e: + _show_error(stdscr, f"Could not load entries: {e}") + level = 1 + continue + chosen = _select( + stdscr, f"{format_doc(sel_doc)} - entries", entries, format_entry) + if chosen is None: + level = 1 + continue + sel_entry = chosen + level = 3 + + elif level == 3: + action = _view_entry(stdscr, sel_doc, sel_entry) + if action == "back": + level = 2 + continue + return (sel_doc, sel_entry) + + +def cmd_tui(limit=100, fmt="text"): + """Interactively browse logbooks -> documents -> entries to find an entry.""" + if not sys.stdout.isatty() or not sys.stdin.isatty(): + print("Error: the tui requires an interactive terminal.", file=sys.stderr) + sys.exit(1) + + factory = auth.get_factory() + api = factory.get_logbook_api() + + result = curses.wrapper(_run, api, limit) + if not result: + return + doc, entry = result + + if fmt == "text": + print(f"doc-id: {doc.id}") + print(f"log-id: {entry.log_id}") + print(f"# fetch with: bely.py entry get -d {doc.id} --id {entry.log_id}") + else: + print_result(entry_reference(doc, entry), "", fmt) diff --git a/tools/developer_tools/bely-cli/run_test.sh b/tools/developer_tools/bely-cli/run_test.sh index 8f09f5d50..5c088a833 100644 --- a/tools/developer_tools/bely-cli/run_test.sh +++ b/tools/developer_tools/bely-cli/run_test.sh @@ -14,3 +14,4 @@ COMPONENT_DIR="${COMPONENT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" # (appended to a leaf command, not at the top level). bely.py -h > /dev/null bely.py doc list -h | grep -q -- --format +bely.py tui lookup -h | grep -q -- --format diff --git a/tools/developer_tools/bely-cli/test/test_tui.py b/tools/developer_tools/bely-cli/test/test_tui.py new file mode 100644 index 000000000..5862ad98b --- /dev/null +++ b/tools/developer_tools/bely-cli/test/test_tui.py @@ -0,0 +1,70 @@ +import datetime +import os +import sys +import unittest +from types import SimpleNamespace + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bely-cli")) + +import tui + + +class FilterItemsTests(unittest.TestCase): + def test_empty_query_returns_all(self): + items = ["alpha", "beta", "gamma"] + self.assertEqual(tui.filter_items(items, "", lambda s: s), items) + + def test_case_insensitive_substring(self): + items = ["Shift Report", "Beam Study", "RF trip"] + result = tui.filter_items(items, "beam", lambda s: s) + self.assertEqual(result, ["Beam Study"]) + + def test_substring_anywhere(self): + items = ["abc", "xbcx", "zzz"] + result = tui.filter_items(items, "bc", lambda s: s) + self.assertEqual(result, ["abc", "xbcx"]) + + +class FormatEntryTests(unittest.TestCase): + def test_date_author_snippet(self): + e = SimpleNamespace( + entered_on_date_time=datetime.datetime(2026, 6, 19, 14, 30), + entered_by_username="alice", + log_entry="First line\nSecond line", + ) + out = tui.format_entry(e) + self.assertIn("2026-06-19 14:30", out) + self.assertIn("alice", out) + self.assertIn("First line", out) + self.assertNotIn("Second line", out) + + def test_truncates_long_first_line(self): + e = SimpleNamespace( + entered_on_date_time=None, + entered_by_username="bob", + log_entry="x" * 100, + ) + out = tui.format_entry(e) + self.assertIn("...", out) + + def test_skips_blank_leading_lines(self): + e = SimpleNamespace( + entered_on_date_time=None, + entered_by_username="bob", + log_entry="\n\n \nReal content", + ) + self.assertIn("Real content", tui.format_entry(e)) + + +class EntryReferenceTests(unittest.TestCase): + def test_reference_fields(self): + doc = SimpleNamespace(id=42, name="My Doc") + entry = SimpleNamespace(log_id=99) + self.assertEqual( + tui.entry_reference(doc, entry), + {"doc_id": 42, "doc_name": "My Doc", "log_id": 99}, + ) + + +if __name__ == "__main__": + unittest.main() From f512a05b26540866021df3c0e248b71bec93a6ce Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 19 Jun 2026 13:48:04 -0500 Subject: [PATCH 22/62] Use the terminal color pallete to ensure easy viability of the tui. --- .../developer_tools/bely-cli/bely-cli/tui.py | 63 ++++++++++++++++--- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/tools/developer_tools/bely-cli/bely-cli/tui.py b/tools/developer_tools/bely-cli/bely-cli/tui.py index 21231df2f..54dcfcdf0 100644 --- a/tools/developer_tools/bely-cli/bely-cli/tui.py +++ b/tools/developer_tools/bely-cli/bely-cli/tui.py @@ -66,6 +66,45 @@ def entry_reference(doc, entry): _ESC = 27 +# Display attributes. These start as monochrome fallbacks (used when the +# terminal has no color support) and are upgraded to theme-aware colors by +# _init_colors() once curses is running. +_A_TITLE = curses.A_BOLD +_A_SELECTED = curses.A_REVERSE | curses.A_BOLD +_A_FOOTER = curses.A_DIM +_A_ERROR = curses.A_BOLD + +_PAIR_TITLE = 1 +_PAIR_FOOTER = 2 +_PAIR_ERROR = 3 + + +def _init_colors(): + """Derive display attributes from the terminal's own palette. + + use_default_colors() lets us pass -1 for the background so the terminal's + own background shows through, and the named ANSI colors resolve to whatever + the user's theme defines for them. That keeps the UI legible on both light + and dark terminals without hardcoding a background. The selected-row + highlight uses reverse video, which simply swaps the terminal's current + foreground/background and so adapts to any theme. + """ + global _A_TITLE, _A_FOOTER, _A_ERROR + if not curses.has_colors(): + return + try: + curses.start_color() + curses.use_default_colors() + except curses.error: + return + curses.init_pair(_PAIR_TITLE, curses.COLOR_CYAN, -1) + curses.init_pair(_PAIR_FOOTER, curses.COLOR_BLUE, -1) + curses.init_pair(_PAIR_ERROR, curses.COLOR_RED, -1) + _A_TITLE = curses.color_pair(_PAIR_TITLE) | curses.A_BOLD + _A_FOOTER = curses.color_pair(_PAIR_FOOTER) + _A_ERROR = curses.color_pair(_PAIR_ERROR) | curses.A_BOLD + + def _addstr(stdscr, y, x, text, width, attr=0): """Write text truncated to width, swallowing curses edge errors.""" try: @@ -97,18 +136,23 @@ def _select(stdscr, title, items, render_fn): top = pos - body_h + 1 stdscr.erase() - _addstr(stdscr, 0, 0, title, width, curses.A_BOLD) + _addstr(stdscr, 0, 0, title, width, _A_TITLE) if not shown: _addstr(stdscr, 2, 2, "(no items)", width) else: for row, item in enumerate(shown[top:top + body_h]): idx = top + row - attr = curses.A_REVERSE if idx == pos else 0 - _addstr(stdscr, 2 + row, 0, " " + render_fn(item), width, attr) + selected = idx == pos + text = " " + render_fn(item) + if selected: + # Pad to full width so the highlight reads as a solid bar. + text = text.ljust(width) + _addstr(stdscr, 2 + row, 0, text, width, + _A_SELECTED if selected else 0) footer = f"Filter: {query}_ [Up/Down PgUp/PgDn] move [Enter] open [Esc] back (type to filter)" - _addstr(stdscr, height - 1, 0, footer, width, curses.A_DIM) + _addstr(stdscr, height - 1, 0, footer, width, _A_FOOTER) stdscr.refresh() ch = stdscr.getch() @@ -155,11 +199,11 @@ def _view_entry(stdscr, doc, entry): top = min(top, max_top) stdscr.erase() - _addstr(stdscr, 0, 0, header, width, curses.A_BOLD) + _addstr(stdscr, 0, 0, header, width, _A_TITLE) for row, line in enumerate(lines[top:top + body_h]): _addstr(stdscr, 2 + row, 0, line, width) footer = "[Up/Down PgUp/PgDn] scroll [Enter/q] select this entry [Esc] back" - _addstr(stdscr, height - 1, 0, footer, width, curses.A_DIM) + _addstr(stdscr, height - 1, 0, footer, width, _A_FOOTER) stdscr.refresh() ch = stdscr.getch() @@ -181,7 +225,7 @@ def _loading(stdscr, message): """Show a transient status line while a network call runs.""" _, width = stdscr.getmaxyx() stdscr.erase() - _addstr(stdscr, 0, 0, message, width, curses.A_DIM) + _addstr(stdscr, 0, 0, message, width, _A_FOOTER) stdscr.refresh() @@ -189,10 +233,10 @@ def _show_error(stdscr, message): """Show an error and wait for a keypress.""" height, width = stdscr.getmaxyx() stdscr.erase() - _addstr(stdscr, 0, 0, "Error", width, curses.A_BOLD) + _addstr(stdscr, 0, 0, "Error", width, _A_ERROR) for row, line in enumerate(textwrap.wrap(message, max(1, width - 1))): _addstr(stdscr, 2 + row, 0, line, width) - _addstr(stdscr, height - 1, 0, "Press any key to go back", width, curses.A_DIM) + _addstr(stdscr, height - 1, 0, "Press any key to go back", width, _A_FOOTER) stdscr.refresh() stdscr.getch() @@ -200,6 +244,7 @@ def _show_error(stdscr, message): def _run(stdscr, api, limit): """Drill-down loop. Return (doc, entry) if confirmed, else None.""" curses.curs_set(0) + _init_colors() level = 0 sel_type = sel_doc = sel_entry = None From 152c243ad6f6e71eb39bfbd6e875831c4d604ecb Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 19 Jun 2026 13:57:51 -0500 Subject: [PATCH 23/62] Add tui performance improvements with caching and show esc delay. --- .../developer_tools/bely-cli/bely-cli/tui.py | 61 ++++++++++++------- 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/tools/developer_tools/bely-cli/bely-cli/tui.py b/tools/developer_tools/bely-cli/bely-cli/tui.py index 54dcfcdf0..e72fb3243 100644 --- a/tools/developer_tools/bely-cli/bely-cli/tui.py +++ b/tools/developer_tools/bely-cli/bely-cli/tui.py @@ -244,20 +244,33 @@ def _show_error(stdscr, message): def _run(stdscr, api, limit): """Drill-down loop. Return (doc, entry) if confirmed, else None.""" curses.curs_set(0) + # ncurses waits ESCDELAY ms after an ESC byte to see if it begins an escape + # sequence (arrows, PgUp, ...). The default is 1000ms, which makes "Esc to + # go back" feel frozen. 25ms is plenty to disambiguate real key sequences. + if hasattr(curses, "set_escdelay"): + curses.set_escdelay(25) _init_colors() level = 0 sel_type = sel_doc = sel_entry = None - docs = entries = [] + + # Per-session caches so back-navigation redraws from memory instead of + # re-hitting the network. Keyed by parent id; errors are left uncached so a + # later visit retries. `is None` checks distinguish "not fetched" from a + # legitimately empty result list (which we do cache). + types = None + docs_cache = {} # type_id -> list of documents + entries_cache = {} # doc_id -> list of entries while True: if level == 0: - _loading(stdscr, "Loading logbooks...") - try: - types = api.get_logbook_types() - except Exception as e: # broad: avoid importing belyApi just for its exceptions - _show_error(stdscr, f"Could not load logbooks: {e}") - return None + if types is None: + _loading(stdscr, "Loading logbooks...") + try: + types = api.get_logbook_types() + except Exception as e: # broad: avoid importing belyApi just for its exceptions + _show_error(stdscr, f"Could not load logbooks: {e}") + return None chosen = _select(stdscr, "Select a logbook", types, format_type) if chosen is None: return None @@ -265,13 +278,16 @@ def _run(stdscr, api, limit): level = 1 elif level == 1: - _loading(stdscr, f"Loading recent documents in '{format_type(sel_type)}'...") - try: - docs = api.get_log_documents(logbook_type_id=sel_type.id, limit=limit) - except Exception as e: - _show_error(stdscr, f"Could not load documents: {e}") - level = 0 - continue + docs = docs_cache.get(sel_type.id) + if docs is None: + _loading(stdscr, f"Loading recent documents in '{format_type(sel_type)}'...") + try: + docs = api.get_log_documents(logbook_type_id=sel_type.id, limit=limit) + except Exception as e: + _show_error(stdscr, f"Could not load documents: {e}") + level = 0 + continue + docs_cache[sel_type.id] = docs chosen = _select( stdscr, f"{format_type(sel_type)} - recent documents", docs, format_doc) if chosen is None: @@ -281,13 +297,16 @@ def _run(stdscr, api, limit): level = 2 elif level == 2: - _loading(stdscr, f"Loading entries in '{format_doc(sel_doc)}'...") - try: - entries = api.get_log_entries(log_document_id=sel_doc.id) - except Exception as e: - _show_error(stdscr, f"Could not load entries: {e}") - level = 1 - continue + entries = entries_cache.get(sel_doc.id) + if entries is None: + _loading(stdscr, f"Loading entries in '{format_doc(sel_doc)}'...") + try: + entries = api.get_log_entries(log_document_id=sel_doc.id) + except Exception as e: + _show_error(stdscr, f"Could not load entries: {e}") + level = 1 + continue + entries_cache[sel_doc.id] = entries chosen = _select( stdscr, f"{format_doc(sel_doc)} - entries", entries, format_entry) if chosen is None: From 3d7e797f527df6c6c275c264b795ecfe1bf6102d Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 19 Jun 2026 14:16:18 -0500 Subject: [PATCH 24/62] Allow navigation previous and next log entry when looking at the entries. --- .../developer_tools/bely-cli/bely-cli/tui.py | 41 +++++++++++++++---- .../developer_tools/bely-cli/test/test_tui.py | 15 +++++++ 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/tools/developer_tools/bely-cli/bely-cli/tui.py b/tools/developer_tools/bely-cli/bely-cli/tui.py index e72fb3243..e9e86f387 100644 --- a/tools/developer_tools/bely-cli/bely-cli/tui.py +++ b/tools/developer_tools/bely-cli/bely-cli/tui.py @@ -50,6 +50,13 @@ def filter_items(items, query, render_fn): return [it for it in items if q in render_fn(it).lower()] +def step_index(index, delta, length): + """Clamp index+delta to [0, length-1]. length<=0 returns 0.""" + if length <= 0: + return 0 + return max(0, min(index + delta, length - 1)) + + def entry_reference(doc, entry): """Reference dict for the selected entry, for json/yaml output.""" return { @@ -180,13 +187,20 @@ def _select(stdscr, title, items, render_fn): pos = 0 -def _view_entry(stdscr, doc, entry): - """Scrollable view of an entry's markdown. Return 'select' or 'back'.""" - body = getattr(entry, "log_entry", None) or "(empty entry)" +def _view_entry(stdscr, doc, entries, index): + """Scrollable view of an entry's markdown, with Left/Right to move between + entries in the document. Return (action, index) where action is 'select' or + 'back' and index is the (possibly changed) entry the user ended on.""" top = 0 - header = f'{getattr(doc, "name", "")} / log_id={getattr(entry, "log_id", "")}' while True: + entry = entries[index] + body = getattr(entry, "log_entry", None) or "(empty entry)" + header = ( + f'{getattr(doc, "name", "")} / log_id={getattr(entry, "log_id", "")}' + f' ({index + 1}/{len(entries)})' + ) + height, width = stdscr.getmaxyx() body_h = max(1, height - 3) @@ -202,7 +216,7 @@ def _view_entry(stdscr, doc, entry): _addstr(stdscr, 0, 0, header, width, _A_TITLE) for row, line in enumerate(lines[top:top + body_h]): _addstr(stdscr, 2 + row, 0, line, width) - footer = "[Up/Down PgUp/PgDn] scroll [Enter/q] select this entry [Esc] back" + footer = "[Up/Down PgUp/PgDn] scroll [Left/Right] prev/next entry [Enter/q] select [Esc] back" _addstr(stdscr, height - 1, 0, footer, width, _A_FOOTER) stdscr.refresh() @@ -215,10 +229,18 @@ def _view_entry(stdscr, doc, entry): top = max(0, top - body_h) elif ch == curses.KEY_NPAGE: top = min(max_top, top + body_h) + elif ch == curses.KEY_LEFT: + new = step_index(index, -1, len(entries)) + if new != index: + index, top = new, 0 # reset scroll on entry change + elif ch == curses.KEY_RIGHT: + new = step_index(index, +1, len(entries)) + if new != index: + index, top = new, 0 elif ch in _ENTER_KEYS or ch in (ord("q"), ord("Q")): - return "select" + return "select", index elif ch == _ESC or ch in _BACKSPACE_KEYS: - return "back" + return "back", index def _loading(stdscr, message): @@ -316,7 +338,10 @@ def _run(stdscr, api, limit): level = 3 elif level == 3: - action = _view_entry(stdscr, sel_doc, sel_entry) + entries = entries_cache[sel_doc.id] # already populated at level 2 + idx = next(i for i, e in enumerate(entries) if e is sel_entry) + action, idx = _view_entry(stdscr, sel_doc, entries, idx) + sel_entry = entries[idx] if action == "back": level = 2 continue diff --git a/tools/developer_tools/bely-cli/test/test_tui.py b/tools/developer_tools/bely-cli/test/test_tui.py index 5862ad98b..19d230e2b 100644 --- a/tools/developer_tools/bely-cli/test/test_tui.py +++ b/tools/developer_tools/bely-cli/test/test_tui.py @@ -56,6 +56,21 @@ def test_skips_blank_leading_lines(self): self.assertIn("Real content", tui.format_entry(e)) +class StepIndexTests(unittest.TestCase): + def test_middle_moves(self): + self.assertEqual(tui.step_index(3, +1, 10), 4) + self.assertEqual(tui.step_index(3, -1, 10), 2) + + def test_clamp_low(self): + self.assertEqual(tui.step_index(0, -1, 10), 0) + + def test_clamp_high(self): + self.assertEqual(tui.step_index(9, +1, 10), 9) + + def test_empty_list(self): + self.assertEqual(tui.step_index(0, +1, 0), 0) + + class EntryReferenceTests(unittest.TestCase): def test_reference_fields(self): doc = SimpleNamespace(id=42, name="My Doc") From 63c4cff3766927db63f250e34d2e0237bacd15aa Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Mon, 22 Jun 2026 09:45:21 -0500 Subject: [PATCH 25/62] add general exception handling in bely cli --- tools/developer_tools/bely-cli/bely-cli/bely.py | 8 +++++++- tools/developer_tools/bely-cli/bely-cli/commands.py | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tools/developer_tools/bely-cli/bely-cli/bely.py b/tools/developer_tools/bely-cli/bely-cli/bely.py index ea1147828..22f30f918 100755 --- a/tools/developer_tools/bely-cli/bely-cli/bely.py +++ b/tools/developer_tools/bely-cli/bely-cli/bely.py @@ -1,5 +1,7 @@ #!/C2/conda/envs/bely/bin/python +import sys + import click from common import FORMATS @@ -175,4 +177,8 @@ def config_set(field, value, output_format): if __name__ == "__main__": - cli() + try: + cli() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/tools/developer_tools/bely-cli/bely-cli/commands.py b/tools/developer_tools/bely-cli/bely-cli/commands.py index 140143870..5e231541d 100644 --- a/tools/developer_tools/bely-cli/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/bely-cli/commands.py @@ -174,6 +174,9 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, content = None if file: file = os.path.expanduser(file) + if not os.path.isfile(file): + print(f"Error: file not found: {file}", file=sys.stderr) + sys.exit(1) with open(file, "r") as f: content = f.read() From b3168728f0fc5625a91a697ebdf47fd545e7dbed Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Fri, 26 Jun 2026 11:36:10 -0500 Subject: [PATCH 26/62] rename bely to bely-cli --- tools/developer_tools/bely-cli/README.md | 92 +++++++++---------- .../bely-cli/{bely => bely-cli-test} | 2 +- .../bely-cli/{bely.py => bely-cli.py} | 0 .../developer_tools/bely-cli/bely-cli/tui.py | 2 +- tools/developer_tools/bely-cli/install.txt | 2 +- tools/developer_tools/bely-cli/run_test.sh | 6 +- 6 files changed, 52 insertions(+), 52 deletions(-) rename tools/developer_tools/bely-cli/{bely => bely-cli-test} (73%) rename tools/developer_tools/bely-cli/bely-cli/{bely.py => bely-cli.py} (100%) diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index 17db68f44..5d4e77dd0 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -4,7 +4,7 @@ A command-line client for the **BELY logbook**. Create log documents, add and up entries (from files, inline text, an editor, or with attachments), list and fetch entries, and manage local configuration. -The published command is `bely.py`. +The published command is `bely-cli.py`. ## Getting started @@ -12,14 +12,14 @@ After loading the `aux` module the command is on your `PATH`: ```bash module add aux -bely.py -h +bely-cli.py -h ``` Every command and group accepts `-h` / `--help`: ```bash -bely.py doc -h -bely.py entry add -h +bely-cli.py doc -h +bely-cli.py entry add -h ``` ### Set the server host @@ -34,12 +34,12 @@ The host is resolved in this order: Set it once with `config set`: ```bash -bely.py config set host https://tinkerbox.aps.anl.gov:8181/bely +bely-cli.py config set host https://tinkerbox.aps.anl.gov:8181/bely ``` > A convenience wrapper named `bely` ships in the source directory; it presets -> `BELY_HOST` to `https://tinkerbox.aps.anl.gov:8181/bely` before invoking `bely.py`. The -> examples in this README use `bely.py` directly, so set the host (or export `BELY_HOST`) +> `BELY_HOST` to `https://tinkerbox.aps.anl.gov:8181/bely` before invoking `bely-cli.py`. The +> examples in this README use `bely-cli.py` directly, so set the host (or export `BELY_HOST`) > as shown above. ## Authentication @@ -61,7 +61,7 @@ the settings file (see [Configuration & environment](#configuration--environment A `--format` option controls output, appended to the command: ```bash -bely.py doc list --format json +bely-cli.py doc list --format json ``` | Value | Behavior | @@ -70,13 +70,13 @@ bely.py doc list --format json | `json` | Structured JSON — for scripting | | `yaml` | Structured YAML — for scripting | -`--format` is given at the end of a command, e.g. `bely.py entry list -n "..." --format yaml`. +`--format` is given at the end of a command, e.g. `bely-cli.py entry list -n "..." --format yaml`. ## Commands ### `doc` — log documents -#### `bely.py doc new` +#### `bely-cli.py doc new` Create a new log document (and optionally its first entry). @@ -91,7 +91,7 @@ Create a new log document (and optionally its first entry). | `-o, --output TEXT` | Directory to write a template-generated entry into (default: cwd). | | `--list-options {system,type,template}` | List the available values for that option and exit. | -#### `bely.py doc list` +#### `bely-cli.py doc list` List recent log documents you created, newest first. @@ -101,15 +101,15 @@ List recent log documents you created, newest first. ### `tui` — interactive terminal UIs -#### `bely.py tui lookup` +#### `bely-cli.py tui lookup` Interactively browse to find a log entry when you don't already know its document. The TUI drills down through three levels — **logbook → recent documents → entries** — and then shows the entry's markdown in a scrollable view. Browsing is read-only and needs no authentication. ```bash -bely.py tui lookup -bely.py tui lookup --limit 50 +bely-cli.py tui lookup +bely-cli.py tui lookup --limit 50 ``` | Option | Description | @@ -128,12 +128,12 @@ Keys: | `Esc` | Go back one level; quits from the logbook list. | On selecting an entry the TUI exits and prints its `doc-id` / `log-id`, plus a ready-to-run -`bely.py entry get` command so you can fetch it: +`bely-cli.py entry get` command so you can fetch it: ``` doc-id: 99 log-id: 42 -# fetch with: bely.py entry get -d 99 --id 42 +# fetch with: bely-cli.py entry get -d 99 --id 42 ``` With `--format json` / `--format yaml` the selected reference is printed as structured data @@ -144,7 +144,7 @@ instead. All `entry` commands identify the target document with **either** `-n/--doc-name` **or** `-d/--doc-id` (provide one). -#### `bely.py entry add` +#### `bely-cli.py entry add` Add a new entry to an existing document. If none of `--file`, `--text`, or `--add-attachment` is given, your `$EDITOR` opens for the entry text. @@ -157,7 +157,7 @@ Add a new entry to an existing document. If none of `--file`, `--text`, or | `-t, --text TEXT` | Inline text for the entry. | | `--add-attachment TEXT` | File to attach to the entry. | -#### `bely.py entry update` +#### `bely-cli.py entry update` Update an existing entry. With no `--id`, your most recent entry in the document is updated. If none of `--file`, `--text`, or `--add-attachment` is given, your `$EDITOR` @@ -172,7 +172,7 @@ opens. `--file` and `--text` are mutually exclusive. | `-t, --text TEXT` | Inline text for the entry. | | `--add-attachment TEXT` | File to attach to the entry. | -#### `bely.py entry list` +#### `bely-cli.py entry list` List the entries in a document (Log ID, date, author, and a snippet of the first line). @@ -181,7 +181,7 @@ List the entries in a document (Log ID, date, author, and a snippet of the first | `-n, --doc-name TEXT` | Document name. | | `-d, --doc-id INTEGER` | Document ID. | -#### `bely.py entry get` +#### `bely-cli.py entry get` Write the markdown of an entry to a file named `_entry_.md`. @@ -194,26 +194,26 @@ Write the markdown of an entry to a file named `_entry_.md`. ### `config` — local configuration -#### `bely.py config show` +#### `bely-cli.py config show` Show the current configuration: values from the settings file and the relevant environment variables (`BELY_PASSWORD` is masked). -#### `bely.py config edit` +#### `bely-cli.py config edit` Open the settings file in your editor. The editor is resolved from `EDITOR`, then the `editor` setting, then `vi`. The file and directory are created if needed. -#### `bely.py config set FIELD VALUE` +#### `bely-cli.py config set FIELD VALUE` Set a single configuration field. `FIELD` is one of `host`, `user`, `editor`, or `token_path`. ```bash -bely.py config set user alice -bely.py config set host https://tinkerbox.aps.anl.gov:8181/bely -bely.py config set editor nano -bely.py config set token_path ~/.secrets/bely-token +bely-cli.py config set user alice +bely-cli.py config set host https://tinkerbox.aps.anl.gov:8181/bely +bely-cli.py config set editor nano +bely-cli.py config set token_path ~/.secrets/bely-token ``` ## Configuration & environment @@ -259,41 +259,41 @@ user: alice ```bash # One-time setup -bely.py config set host https://tinkerbox.aps.anl.gov:8181/bely -bely.py config set user alice +bely-cli.py config set host https://tinkerbox.aps.anl.gov:8181/bely +bely-cli.py config set user alice # Discover available values -bely.py doc new --list-options type -bely.py doc new --list-options system -bely.py doc new --list-options template +bely-cli.py doc new --list-options type +bely-cli.py doc new --list-options system +bely-cli.py doc new --list-options template # Create a document interactively (prompts for type and name) -bely.py doc new +bely-cli.py doc new # Create a document with everything specified, plus a first entry from a file -bely.py doc new --type ops --name "Shift Report" --systems SR,software --file entry.md +bely-cli.py doc new --type ops --name "Shift Report" --systems SR,software --file entry.md # List your recent documents -bely.py doc list --limit 50 +bely-cli.py doc list --limit 50 # Add an entry — inline text, from a file, or via your editor -bely.py entry add -n "Shift Report" -t "Beam restored after RF trip." -bely.py entry add -n "Shift Report" -f entry.md -bely.py entry add -n "Shift Report" # opens $EDITOR +bely-cli.py entry add -n "Shift Report" -t "Beam restored after RF trip." +bely-cli.py entry add -n "Shift Report" -f entry.md +bely-cli.py entry add -n "Shift Report" # opens $EDITOR # Attach a file to an entry -bely.py entry add -n "Shift Report" --add-attachment plot.png +bely-cli.py entry add -n "Shift Report" --add-attachment plot.png # Update your most recent entry, or a specific one -bely.py entry update -n "Shift Report" -t "Corrected: trip was on RF2." -bely.py entry update -n "Shift Report" --id 42 -f revised.md +bely-cli.py entry update -n "Shift Report" -t "Corrected: trip was on RF2." +bely-cli.py entry update -n "Shift Report" --id 42 -f revised.md # List and fetch entries -bely.py entry list -n "Shift Report" -bely.py entry get -n "Shift Report" # latest, to cwd -bely.py entry get -d 99 --id 42 -o ~/logs/ +bely-cli.py entry list -n "Shift Report" +bely-cli.py entry get -n "Shift Report" # latest, to cwd +bely-cli.py entry get -d 99 --id 42 -o ~/logs/ # Structured output for scripting -bely.py doc list --format json -bely.py entry list -n "Shift Report" --format yaml +bely-cli.py doc list --format json +bely-cli.py entry list -n "Shift Report" --format yaml ``` diff --git a/tools/developer_tools/bely-cli/bely b/tools/developer_tools/bely-cli/bely-cli-test similarity index 73% rename from tools/developer_tools/bely-cli/bely rename to tools/developer_tools/bely-cli/bely-cli-test index 624b09847..d5452c293 100755 --- a/tools/developer_tools/bely-cli/bely +++ b/tools/developer_tools/bely-cli/bely-cli-test @@ -2,4 +2,4 @@ SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}") export BELY_HOST=https://tinkerbox.aps.anl.gov:8181/bely -${SCRIPT_DIR}/bely-cli/bely.py "$@" +${SCRIPT_DIR}/bely-cli/bely-cli.py "$@" diff --git a/tools/developer_tools/bely-cli/bely-cli/bely.py b/tools/developer_tools/bely-cli/bely-cli/bely-cli.py similarity index 100% rename from tools/developer_tools/bely-cli/bely-cli/bely.py rename to tools/developer_tools/bely-cli/bely-cli/bely-cli.py diff --git a/tools/developer_tools/bely-cli/bely-cli/tui.py b/tools/developer_tools/bely-cli/bely-cli/tui.py index e9e86f387..ff4100c3b 100644 --- a/tools/developer_tools/bely-cli/bely-cli/tui.py +++ b/tools/developer_tools/bely-cli/bely-cli/tui.py @@ -365,6 +365,6 @@ def cmd_tui(limit=100, fmt="text"): if fmt == "text": print(f"doc-id: {doc.id}") print(f"log-id: {entry.log_id}") - print(f"# fetch with: bely.py entry get -d {doc.id} --id {entry.log_id}") + print(f"# fetch with: bely-cli.py entry get -d {doc.id} --id {entry.log_id}") else: print_result(entry_reference(doc, entry), "", fmt) diff --git a/tools/developer_tools/bely-cli/install.txt b/tools/developer_tools/bely-cli/install.txt index 04a3f8cf1..d0587c658 100644 --- a/tools/developer_tools/bely-cli/install.txt +++ b/tools/developer_tools/bely-cli/install.txt @@ -1 +1 @@ -bely-cli/bely.py +bely-cli/bely-cli.py diff --git a/tools/developer_tools/bely-cli/run_test.sh b/tools/developer_tools/bely-cli/run_test.sh index 5c088a833..8beeb1656 100644 --- a/tools/developer_tools/bely-cli/run_test.sh +++ b/tools/developer_tools/bely-cli/run_test.sh @@ -12,6 +12,6 @@ COMPONENT_DIR="${COMPONENT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" # Smoke test: the published command loads and --format is wired per-command # (appended to a leaf command, not at the top level). -bely.py -h > /dev/null -bely.py doc list -h | grep -q -- --format -bely.py tui lookup -h | grep -q -- --format +bely-cli.py -h > /dev/null +bely-cli.py doc list -h | grep -q -- --format +bely-cli.py tui lookup -h | grep -q -- --format From 7d4068d7bbd6e2c29dcaaff04644d59d79a0573e Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Fri, 26 Jun 2026 12:05:28 -0500 Subject: [PATCH 27/62] switch to raising exception for propagating errors --- .../developer_tools/bely-cli/bely-cli/auth.py | 11 ++--- .../bely-cli/bely-cli/commands.py | 37 +++++--------- .../bely-cli/bely-cli/common.py | 4 +- .../bely-cli/bely-cli/entry.py | 49 ++++++------------- .../developer_tools/bely-cli/bely-cli/tui.py | 3 +- 5 files changed, 32 insertions(+), 72 deletions(-) diff --git a/tools/developer_tools/bely-cli/bely-cli/auth.py b/tools/developer_tools/bely-cli/bely-cli/auth.py index 054b65f5c..54639899a 100644 --- a/tools/developer_tools/bely-cli/bely-cli/auth.py +++ b/tools/developer_tools/bely-cli/bely-cli/auth.py @@ -22,9 +22,7 @@ def get_host(): """Return the BELY server URL from env var or settings.""" host = os.environ.get("BELY_HOST") or get_setting("host") if not host: - print("Error: no host configured. Set BELY_HOST or add 'host' to settings.yaml.", - file=sys.stderr) - sys.exit(1) + raise ValueError("no host configured. Set BELY_HOST or add 'host' to settings.yaml.") return host @@ -91,12 +89,9 @@ def _login_and_cache(factory): try: factory.authenticate_user(username, password) except belyApi.exceptions.UnauthorizedException: - print(f"Authentication failed: invalid credentials for user '{username}'", - file=sys.stderr) - sys.exit(1) + raise ValueError(f"Authentication failed: invalid credentials for user '{username}'") except Exception as e: - print(f"Authentication failed: {e}", file=sys.stderr) - sys.exit(1) + raise RuntimeError(f"Authentication failed: {e}") from e save_token(factory.get_authenticate_token()) diff --git a/tools/developer_tools/bely-cli/bely-cli/commands.py b/tools/developer_tools/bely-cli/bely-cli/commands.py index 5e231541d..244fceeb5 100644 --- a/tools/developer_tools/bely-cli/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/bely-cli/commands.py @@ -1,5 +1,4 @@ import os -import sys import auth import config @@ -92,8 +91,7 @@ def cmd_set_config(field, value, fmt="text"): """Set a single configuration field in settings.yaml.""" if field not in config.VALID_FIELDS: valid = ", ".join(config.VALID_FIELDS) - print(f"Error: unknown field '{field}'. Valid fields: {valid}", file=sys.stderr) - sys.exit(1) + raise ValueError(f"unknown field '{field}'. Valid fields: {valid}") config.set_setting(field, value) print_result({field: value}, f"Set {field} = {value}", fmt) @@ -107,8 +105,7 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, return if template and no_template: - print("Error: --template and --no-template are mutually exclusive.", file=sys.stderr) - sys.exit(1) + raise ValueError("--template and --no-template are mutually exclusive.") # Resolve names to IDs using unauthenticated API factory = auth.get_factory() @@ -124,8 +121,7 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, if choice.isdigit(): idx = int(choice) - 1 if not (0 <= idx < len(types)): - print("Error: invalid selection.", file=sys.stderr) - sys.exit(1) + raise ValueError("invalid selection.") type_ = types[idx].name else: type_ = choice @@ -133,18 +129,13 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, if not name: name = input("Document name: ").strip() if not name: - print("Error: name cannot be empty.", file=sys.stderr) - sys.exit(1) + raise ValueError("name cannot be empty.") - try: - logbook_type = find_logbook_type(logbook_api, type_) - system_id_list = find_systems(logbook_api, systems) if systems else None - template_id = find_template(logbook_api, template).id if template else None - if find_logdoc(logbook_api, name): - raise ValueError(f"A log document named '{name}' already exists") - except ValueError as e: - print(f"Error: {e}", file=sys.stderr) - sys.exit(1) + logbook_type = find_logbook_type(logbook_api, type_) + system_id_list = find_systems(logbook_api, systems) if systems else None + template_id = find_template(logbook_api, template).id if template else None + if find_logdoc(logbook_api, name): + raise ValueError(f"A log document named '{name}' already exists") # Build document options @@ -175,8 +166,7 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, if file: file = os.path.expanduser(file) if not os.path.isfile(file): - print(f"Error: file not found: {file}", file=sys.stderr) - sys.exit(1) + raise ValueError(f"file not found: {file}") with open(file, "r") as f: content = f.read() @@ -232,17 +222,14 @@ def cmd_list_docs(limit, fmt="text"): """List recent log documents created by the current user.""" username = auth.get_username() if not username: - print("Error: cannot determine username. Set BELY_USER or 'user' in settings.", - file=sys.stderr) - sys.exit(1) + raise ValueError("cannot determine username. Set BELY_USER or 'user' in settings.") factory = auth.get_factory() users_api = factory.get_users_api() try: user_info = users_api.get_user_by_username(username=username) except Exception as e: - print(f"Error: could not look up user '{username}': {e}", file=sys.stderr) - sys.exit(1) + raise RuntimeError(f"could not look up user '{username}': {e}") from e search_api = factory.get_search_api() results = search_api.search_logbook(search_text="*", user_id=[user_info.id]) diff --git a/tools/developer_tools/bely-cli/bely-cli/common.py b/tools/developer_tools/bely-cli/bely-cli/common.py index e88eb1a6a..34ff8f24f 100644 --- a/tools/developer_tools/bely-cli/bely-cli/common.py +++ b/tools/developer_tools/bely-cli/bely-cli/common.py @@ -1,7 +1,6 @@ import json import os import subprocess -import sys import tempfile import yaml @@ -63,8 +62,7 @@ def write_entry_to_file(entry, doc_name, output_dir=None, fmt="text"): """ directory = os.path.expanduser(output_dir) if output_dir else "." if not os.path.isdir(directory): - print(f"Error: output directory not found: {directory}", file=sys.stderr) - sys.exit(1) + raise ValueError(f"output directory not found: {directory}") safe_doc = _sanitize_for_filename(doc_name) out_path = os.path.join(directory, f"{safe_doc}_entry_{entry.log_id}.md") with open(out_path, "w") as f: diff --git a/tools/developer_tools/bely-cli/bely-cli/entry.py b/tools/developer_tools/bely-cli/bely-cli/entry.py index 688c8bd8e..d471a1ba7 100644 --- a/tools/developer_tools/bely-cli/bely-cli/entry.py +++ b/tools/developer_tools/bely-cli/bely-cli/entry.py @@ -1,5 +1,4 @@ import os -import sys from types import SimpleNamespace import auth @@ -7,19 +6,16 @@ def resolve_doc(logbook_api, doc_name, doc_id): - """Resolve a document by name or ID. Exits on error.""" + """Resolve a document by name or ID. Raises ValueError on error.""" if doc_name and doc_id: - print("Error: --doc-name and --doc-id are mutually exclusive.", file=sys.stderr) - sys.exit(1) + raise ValueError("--doc-name and --doc-id are mutually exclusive.") if not doc_name and not doc_id: - print("Error: --doc-name or --doc-id is required.", file=sys.stderr) - sys.exit(1) + raise ValueError("--doc-name or --doc-id is required.") if doc_id: return SimpleNamespace(id=doc_id, name=f"id={doc_id}") doc = find_logdoc(logbook_api, doc_name) if not doc: - print(f'Error: log document "{doc_name}" not found.', file=sys.stderr) - sys.exit(1) + raise ValueError(f'log document "{doc_name}" not found.') return doc @@ -54,20 +50,17 @@ def upload_and_print_attachment(logbook_api, doc_id, log_id, path, fmt="text"): def cmd_update_entry(doc_name, doc_id, entry_id, file, text, add_attachment, fmt="text"): """Update an existing log entry.""" if file and text: - print("Error: --file and --text are mutually exclusive.", file=sys.stderr) - sys.exit(1) + raise ValueError("--file and --text are mutually exclusive.") # Validate files exist before any network calls if file: file = os.path.expanduser(file) if not os.path.isfile(file): - print(f"Error: file not found: {file}", file=sys.stderr) - sys.exit(1) + raise ValueError(f"file not found: {file}") if add_attachment: add_attachment = os.path.expanduser(add_attachment) if not os.path.isfile(add_attachment): - print(f"Error: attachment file not found: {add_attachment}", file=sys.stderr) - sys.exit(1) + raise ValueError(f"attachment file not found: {add_attachment}") # Determine content content = None @@ -95,23 +88,17 @@ def cmd_update_entry(doc_name, doc_id, entry_id, file, text, add_attachment, fmt entry = e break if not entry: - print(f'Error: entry with log_id={entry_id} not found in document "{doc.name}".', - file=sys.stderr) - sys.exit(1) + raise ValueError(f'entry with log_id={entry_id} not found in document "{doc.name}".') else: # Find last entry by current user username = auth.get_username() if not username: - print("Error: cannot determine username. Set BELY_USER or 'user' in settings.", - file=sys.stderr) - sys.exit(1) + raise ValueError("cannot determine username. Set BELY_USER or 'user' in settings.") user_entries = [e for e in entries if e.entered_by_username and e.entered_by_username.lower() == username.lower()] if not user_entries: - print(f'Error: no entries by user "{username}" found in document "{doc.name}".', - file=sys.stderr) - sys.exit(1) + raise ValueError(f'no entries by user "{username}" found in document "{doc.name}".') entry = user_entries[-1] # Update entry content @@ -151,21 +138,18 @@ def cmd_update_entry(doc_name, doc_id, entry_id, file, text, add_attachment, fmt def cmd_add_entry(doc_name, doc_id, file, text, add_attachment, fmt="text"): """Add a new log entry to an existing document.""" if file and text: - print("Error: --file and --text are mutually exclusive.", file=sys.stderr) - sys.exit(1) + raise ValueError("--file and --text are mutually exclusive.") use_editor = not file and not text and not add_attachment # Validate files exist before any network calls if file: file = os.path.expanduser(file) if not os.path.isfile(file): - print(f"Error: file not found: {file}", file=sys.stderr) - sys.exit(1) + raise ValueError(f"file not found: {file}") if add_attachment: add_attachment = os.path.expanduser(add_attachment) if not os.path.isfile(add_attachment): - print(f"Error: attachment file not found: {add_attachment}", file=sys.stderr) - sys.exit(1) + raise ValueError(f"attachment file not found: {add_attachment}") # Determine content content = None @@ -255,15 +239,12 @@ def cmd_get_entry(doc_name, doc_id, entry_id, output_dir, fmt="text"): entries = logbook_api.get_log_entries(log_document_id=doc.id) if not entries: - print(f'No entries found in document {doc.name}.', file=sys.stderr) - sys.exit(1) + raise ValueError(f'No entries found in document {doc.name}.') if entry_id: entry = next((e for e in entries if e.log_id == entry_id), None) if not entry: - print(f'Error: entry with log_id={entry_id} not found in document {doc.name}.', - file=sys.stderr) - sys.exit(1) + raise ValueError(f'entry with log_id={entry_id} not found in document {doc.name}.') else: entry = entries[-1] diff --git a/tools/developer_tools/bely-cli/bely-cli/tui.py b/tools/developer_tools/bely-cli/bely-cli/tui.py index ff4100c3b..10dd9dc98 100644 --- a/tools/developer_tools/bely-cli/bely-cli/tui.py +++ b/tools/developer_tools/bely-cli/bely-cli/tui.py @@ -351,8 +351,7 @@ def _run(stdscr, api, limit): def cmd_tui(limit=100, fmt="text"): """Interactively browse logbooks -> documents -> entries to find an entry.""" if not sys.stdout.isatty() or not sys.stdin.isatty(): - print("Error: the tui requires an interactive terminal.", file=sys.stderr) - sys.exit(1) + raise RuntimeError("the tui requires an interactive terminal.") factory = auth.get_factory() api = factory.get_logbook_api() From aae524923eff44f396ae036831fe9270a7d80c20 Mon Sep 17 00:00:00 2001 From: Elaine Chandler Date: Fri, 26 Jun 2026 12:43:32 -0500 Subject: [PATCH 28/62] add option to read entry data from stdin --- .../developer_tools/bely-cli/bely-cli/auth.py | 13 +++++++- .../bely-cli/bely-cli/bely-cli.py | 15 +++++++-- .../bely-cli/bely-cli/commands.py | 15 ++++----- .../bely-cli/bely-cli/common.py | 23 +++++++++++++ .../bely-cli/bely-cli/entry.py | 33 +++++-------------- 5 files changed, 62 insertions(+), 37 deletions(-) diff --git a/tools/developer_tools/bely-cli/bely-cli/auth.py b/tools/developer_tools/bely-cli/bely-cli/auth.py index 54639899a..8b305340b 100644 --- a/tools/developer_tools/bely-cli/bely-cli/auth.py +++ b/tools/developer_tools/bely-cli/bely-cli/auth.py @@ -3,6 +3,7 @@ import sys from contextlib import contextmanager +from common import is_no_prompt from config import CONFIG_DIR, expand_path, get_setting # belyApi and BelyApiFactory are imported lazily inside the functions that need @@ -10,6 +11,7 @@ # never make a network call should not pay that cost. + def get_token_file(): """Return the token file path: 'token_path' setting, else /token.""" configured = get_setting("token_path") @@ -26,10 +28,17 @@ def get_host(): return host +def get_configured_username(): + """Return the BELY username from env var or settings, without prompting.""" + return os.environ.get("BELY_USER") or get_setting("user") + + def get_username(): """Return the BELY username from env var, settings, or interactive prompt.""" - username = os.environ.get("BELY_USER") or get_setting("user") + username = get_configured_username() if not username: + if is_no_prompt(): + raise ValueError("username required: set BELY_USER or 'user' in settings") username = input("Username: ").strip() return username @@ -38,6 +47,8 @@ def get_password(username): """Return the BELY password from env var or interactive prompt.""" password = os.environ.get("BELY_PASSWORD") if not password: + if is_no_prompt(): + raise ValueError("password required: set BELY_PASSWORD") print(f"Logging in as '{username}'") try: password = getpass.getpass("Password: ") diff --git a/tools/developer_tools/bely-cli/bely-cli/bely-cli.py b/tools/developer_tools/bely-cli/bely-cli/bely-cli.py index 22f30f918..b71f491c2 100755 --- a/tools/developer_tools/bely-cli/bely-cli/bely-cli.py +++ b/tools/developer_tools/bely-cli/bely-cli/bely-cli.py @@ -4,7 +4,7 @@ import click -from common import FORMATS +from common import FORMATS, set_no_prompt from config import VALID_FIELDS from commands import ( cmd_new_doc, @@ -34,9 +34,12 @@ def format_option(f): @click.group(context_settings=CONTEXT_SETTINGS) -def cli(): +@click.option("--no-prompt", is_flag=True, default=False, + help="Non-interactive mode: fail if any prompt would be needed. Enabled automatically when --file=-.") +def cli(no_prompt): """BELY logbook CLI""" - pass + if no_prompt: + set_no_prompt() # -- doc -- @@ -63,6 +66,8 @@ def doc_group(): @format_option def doc_new(output_format, **kwargs): """Create a new log document.""" + if kwargs.get('file') == '-': + set_no_prompt() cmd_new_doc(fmt=output_format, **kwargs) @@ -91,6 +96,8 @@ def entry_group(): @format_option def entry_add(output_format, **kwargs): """Add a new log entry to an existing document.""" + if kwargs.get('file') == '-': + set_no_prompt() cmd_add_entry(fmt=output_format, **kwargs) @@ -104,6 +111,8 @@ def entry_add(output_format, **kwargs): @format_option def entry_update(output_format, **kwargs): """Update an existing log entry.""" + if kwargs.get('file') == '-': + set_no_prompt() cmd_update_entry(fmt=output_format, **kwargs) diff --git a/tools/developer_tools/bely-cli/bely-cli/commands.py b/tools/developer_tools/bely-cli/bely-cli/commands.py index 244fceeb5..5bbd5f469 100644 --- a/tools/developer_tools/bely-cli/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/bely-cli/commands.py @@ -2,7 +2,7 @@ import auth import config -from common import find_logdoc, write_entry_to_file, open_in_editor, print_items, print_result +from common import find_logdoc, is_no_prompt, read_file_or_stdin, write_entry_to_file, open_in_editor, print_items, print_result ENV_VARS = ["BELY_HOST", "BELY_USER", "BELY_PASSWORD", "BELY_SETTINGS_FILE", "EDITOR"] @@ -107,6 +107,11 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, if template and no_template: raise ValueError("--template and --no-template are mutually exclusive.") + if is_no_prompt(): + missing = [opt for opt, val in [("--type", type_), ("--name", name)] if not val] + if missing: + raise ValueError(f"{', '.join(missing)} required in non-interactive mode") + # Resolve names to IDs using unauthenticated API factory = auth.get_factory() logbook_api = factory.get_logbook_api() @@ -162,13 +167,7 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, print(f'New document "{doc.name}" created, id={doc.id}') # Determine entry content from --file or --text - content = None - if file: - file = os.path.expanduser(file) - if not os.path.isfile(file): - raise ValueError(f"file not found: {file}") - with open(file, "r") as f: - content = f.read() + content = read_file_or_stdin(file) if file else None # Check if creating the doc already produced a default entry entries = logbook_api.get_log_entries(log_document_id=doc.id) diff --git a/tools/developer_tools/bely-cli/bely-cli/common.py b/tools/developer_tools/bely-cli/bely-cli/common.py index 34ff8f24f..1404624a3 100644 --- a/tools/developer_tools/bely-cli/bely-cli/common.py +++ b/tools/developer_tools/bely-cli/bely-cli/common.py @@ -1,6 +1,7 @@ import json import os import subprocess +import sys import tempfile import yaml @@ -11,6 +12,17 @@ # Supported values for the global --format option (single source of truth). FORMATS = ("text", "json", "yaml") +_no_prompt = False + + +def set_no_prompt(value=True): + global _no_prompt + _no_prompt = value + + +def is_no_prompt(): + return _no_prompt + def print_items(items, columns, fmt="text"): """Print a list of dicts as a table, JSON array, or YAML sequence. @@ -42,6 +54,17 @@ def print_result(data, message, fmt="text"): print(message) +def read_file_or_stdin(path): + """Read content from path; '-' reads from stdin.""" + if path == "-": + return sys.stdin.read() + path = os.path.expanduser(path) + if not os.path.isfile(path): + raise ValueError(f"file not found: {path}") + with open(path, "r") as f: + return f.read() + + def find_logdoc(logbook_api, name): import belyApi try: diff --git a/tools/developer_tools/bely-cli/bely-cli/entry.py b/tools/developer_tools/bely-cli/bely-cli/entry.py index d471a1ba7..b83c511f9 100644 --- a/tools/developer_tools/bely-cli/bely-cli/entry.py +++ b/tools/developer_tools/bely-cli/bely-cli/entry.py @@ -2,7 +2,7 @@ from types import SimpleNamespace import auth -from common import find_logdoc, write_entry_to_file, open_in_editor, print_items, print_result +from common import find_logdoc, is_no_prompt, read_file_or_stdin, write_entry_to_file, open_in_editor, print_items, print_result def resolve_doc(logbook_api, doc_name, doc_id): @@ -52,23 +52,16 @@ def cmd_update_entry(doc_name, doc_id, entry_id, file, text, add_attachment, fmt if file and text: raise ValueError("--file and --text are mutually exclusive.") - # Validate files exist before any network calls - if file: - file = os.path.expanduser(file) - if not os.path.isfile(file): - raise ValueError(f"file not found: {file}") + if is_no_prompt() and not entry_id and not auth.get_configured_username(): + raise ValueError("--id or a configured username (BELY_USER / 'user' setting) required in non-interactive mode") + + # Validate attachments and read content before any network calls if add_attachment: add_attachment = os.path.expanduser(add_attachment) if not os.path.isfile(add_attachment): raise ValueError(f"attachment file not found: {add_attachment}") - # Determine content - content = None - if file: - with open(file, "r") as f: - content = f.read() - elif text: - content = text + content = read_file_or_stdin(file) if file else text # Resolve document (unauthenticated) factory = auth.get_factory() @@ -141,23 +134,13 @@ def cmd_add_entry(doc_name, doc_id, file, text, add_attachment, fmt="text"): raise ValueError("--file and --text are mutually exclusive.") use_editor = not file and not text and not add_attachment - # Validate files exist before any network calls - if file: - file = os.path.expanduser(file) - if not os.path.isfile(file): - raise ValueError(f"file not found: {file}") + # Validate attachments and read content before any network calls if add_attachment: add_attachment = os.path.expanduser(add_attachment) if not os.path.isfile(add_attachment): raise ValueError(f"attachment file not found: {add_attachment}") - # Determine content - content = None - if file: - with open(file, "r") as f: - content = f.read() - elif text: - content = text + content = read_file_or_stdin(file) if file else text # Resolve document (unauthenticated) factory = auth.get_factory() From dc9d54a1e656378a0c33cc3b6337111ae93d36a9 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 9 Jul 2026 09:34:16 -0500 Subject: [PATCH 29/62] the no prompot should appear on the leaf node. --- .../bely-cli/bely-cli/bely-cli.py | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/tools/developer_tools/bely-cli/bely-cli/bely-cli.py b/tools/developer_tools/bely-cli/bely-cli/bely-cli.py index b71f491c2..d1b50f54c 100755 --- a/tools/developer_tools/bely-cli/bely-cli/bely-cli.py +++ b/tools/developer_tools/bely-cli/bely-cli/bely-cli.py @@ -33,13 +33,29 @@ def format_option(f): )(f) +def no_prompt_option(f): + """Per-command --no-prompt flag: enable non-interactive mode.""" + def _set(ctx, param, value): + if value: + set_no_prompt() + return value + return click.option( + "--no-prompt", is_flag=True, default=False, + expose_value=False, callback=_set, + help="Non-interactive mode: fail if any prompt would be needed. " + "Enabled automatically when --file=-.", + )(f) + + +def common_options(f): + """Options shared by all leaf commands (--format and --no-prompt).""" + return format_option(no_prompt_option(f)) + + @click.group(context_settings=CONTEXT_SETTINGS) -@click.option("--no-prompt", is_flag=True, default=False, - help="Non-interactive mode: fail if any prompt would be needed. Enabled automatically when --file=-.") -def cli(no_prompt): +def cli(): """BELY logbook CLI""" - if no_prompt: - set_no_prompt() + pass # -- doc -- @@ -63,7 +79,7 @@ def doc_group(): type=click.Choice(["system", "type", "template"]), default=None, help="List available values for the given option and exit") -@format_option +@common_options def doc_new(output_format, **kwargs): """Create a new log document.""" if kwargs.get('file') == '-': @@ -73,7 +89,7 @@ def doc_new(output_format, **kwargs): @doc_group.command("list") @click.option("--limit", default=20, type=int, help="Max documents to return (default 20)") -@format_option +@common_options def doc_list(output_format, **kwargs): """List recent log documents created by you.""" cmd_list_docs(fmt=output_format, **kwargs) @@ -93,7 +109,7 @@ def entry_group(): @click.option("--file", "-f", "file", default=None, help="Markdown file with entry content") @click.option("--text", "-t", default=None, help="Inline text for the entry") @click.option("--add-attachment", default=None, help="File to attach to the entry") -@format_option +@common_options def entry_add(output_format, **kwargs): """Add a new log entry to an existing document.""" if kwargs.get('file') == '-': @@ -108,7 +124,7 @@ def entry_add(output_format, **kwargs): @click.option("--file", "-f", "file", default=None, help="Markdown file with updated content") @click.option("--text", "-t", default=None, help="Inline text for the entry") @click.option("--add-attachment", default=None, help="File to attach to the entry") -@format_option +@common_options def entry_update(output_format, **kwargs): """Update an existing log entry.""" if kwargs.get('file') == '-': @@ -119,7 +135,7 @@ def entry_update(output_format, **kwargs): @entry_group.command("list") @click.option("--doc-name", "-n", default=None, help="Log document name") @click.option("--doc-id", "-d", default=None, type=int, help="Log document ID") -@format_option +@common_options def entry_list(output_format, **kwargs): """List entries in a log document.""" cmd_list_entries(fmt=output_format, **kwargs) @@ -131,7 +147,7 @@ def entry_list(output_format, **kwargs): @click.option("--id", "entry_id", default=None, type=int, help="Specific log entry ID (default: latest)") @click.option("--output", "-o", "output_dir", default=None, help="Directory to write _entry_.md into (default: cwd)") -@format_option +@common_options def entry_get(output_format, **kwargs): """Write the markdown of a log entry to a file (latest by default).""" cmd_get_entry(fmt=output_format, **kwargs) @@ -148,7 +164,7 @@ def tui_group(): @tui_group.command("lookup") @click.option("--limit", default=100, type=int, help="Recent documents to load per logbook (default 100)") -@format_option +@common_options def tui_lookup(output_format, **kwargs): """Interactively browse logbooks -> documents -> entries to find a log entry.""" cmd_tui(fmt=output_format, **kwargs) @@ -163,7 +179,7 @@ def config_group(): @config_group.command("show") -@format_option +@common_options def config_show(output_format): """Show current configuration.""" cmd_show_config(fmt=output_format) @@ -178,7 +194,7 @@ def config_edit(): @config_group.command("set") @click.argument("field", type=click.Choice(VALID_FIELDS)) @click.argument("value") -@format_option +@common_options def config_set(field, value, output_format): """Set a configuration field to a value.""" cmd_set_config(field, value, fmt=output_format) From 43d008fe4619bb9ae0a1cbab0d1d64e0a3048636 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Tue, 18 Aug 2026 15:00:54 -0500 Subject: [PATCH 30/62] Add uv packagaing for publishing bely-cli to pypi and conda repo --- tools/developer_tools/bely-cli/.gitignore | 9 ++ tools/developer_tools/bely-cli/README.md | 113 ++++++++++-------- tools/developer_tools/bely-cli/bely-cli-test | 3 +- .../bely-cli/conda-recipe/build.sh | 3 + .../bely-cli/conda-recipe/meta.yaml | 42 +++++++ tools/developer_tools/bely-cli/install.txt | 1 - tools/developer_tools/bely-cli/pyproject.toml | 32 +++++ tools/developer_tools/bely-cli/run_test.sh | 8 +- .../bely-cli/src/bely_cli/__init__.py | 1 + .../{bely-cli => src/bely_cli}/auth.py | 4 +- .../bely-cli.py => src/bely_cli/cli.py} | 18 +-- .../{bely-cli => src/bely_cli}/commands.py | 6 +- .../{bely-cli => src/bely_cli}/common.py | 2 +- .../{bely-cli => src/bely_cli}/config.py | 0 .../{bely-cli => src/bely_cli}/entry.py | 4 +- .../{bely-cli => src/bely_cli}/tui.py | 4 +- .../bely-cli/test/test_commands.py | 5 +- .../bely-cli/test/test_config.py | 6 +- .../bely-cli/test/test_entry.py | 5 +- .../developer_tools/bely-cli/test/test_tui.py | 6 +- 20 files changed, 180 insertions(+), 92 deletions(-) create mode 100644 tools/developer_tools/bely-cli/.gitignore create mode 100755 tools/developer_tools/bely-cli/conda-recipe/build.sh create mode 100644 tools/developer_tools/bely-cli/conda-recipe/meta.yaml delete mode 100644 tools/developer_tools/bely-cli/install.txt create mode 100644 tools/developer_tools/bely-cli/pyproject.toml mode change 100644 => 100755 tools/developer_tools/bely-cli/run_test.sh create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/__init__.py rename tools/developer_tools/bely-cli/{bely-cli => src/bely_cli}/auth.py (97%) rename tools/developer_tools/bely-cli/{bely-cli/bely-cli.py => src/bely_cli/cli.py} (97%) mode change 100755 => 100644 rename tools/developer_tools/bely-cli/{bely-cli => src/bely_cli}/commands.py (98%) rename tools/developer_tools/bely-cli/{bely-cli => src/bely_cli}/common.py (99%) rename tools/developer_tools/bely-cli/{bely-cli => src/bely_cli}/config.py (100%) rename tools/developer_tools/bely-cli/{bely-cli => src/bely_cli}/entry.py (98%) rename tools/developer_tools/bely-cli/{bely-cli => src/bely_cli}/tui.py (99%) diff --git a/tools/developer_tools/bely-cli/.gitignore b/tools/developer_tools/bely-cli/.gitignore new file mode 100644 index 000000000..e8e57a909 --- /dev/null +++ b/tools/developer_tools/bely-cli/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.venv/ +build/ +dist/ +*.egg-info/ +conda-recipe/src/ +conda-recipe/build/ +conda-recipe/*.txt diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index 5d4e77dd0..5a47e3769 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -4,22 +4,37 @@ A command-line client for the **BELY logbook**. Create log documents, add and up entries (from files, inline text, an editor, or with attachments), list and fetch entries, and manage local configuration. -The published command is `bely-cli.py`. +The published command is `bely-cli`. + +## Installation + +For development, from this directory: + +```bash +uv sync +uv run bely-cli -h +``` + +For deployment, install the conda package built from `conda-recipe/` (see +`conda-recipe/conda-build.sh`): + +```bash +conda install bely-cli -c +``` ## Getting started -After loading the `aux` module the command is on your `PATH`: +Once installed, the command is on your `PATH`: ```bash -module add aux -bely-cli.py -h +bely-cli -h ``` Every command and group accepts `-h` / `--help`: ```bash -bely-cli.py doc -h -bely-cli.py entry add -h +bely-cli doc -h +bely-cli entry add -h ``` ### Set the server host @@ -34,12 +49,12 @@ The host is resolved in this order: Set it once with `config set`: ```bash -bely-cli.py config set host https://tinkerbox.aps.anl.gov:8181/bely +bely-cli config set host https://tinkerbox.aps.anl.gov:8181/bely ``` -> A convenience wrapper named `bely` ships in the source directory; it presets -> `BELY_HOST` to `https://tinkerbox.aps.anl.gov:8181/bely` before invoking `bely-cli.py`. The -> examples in this README use `bely-cli.py` directly, so set the host (or export `BELY_HOST`) +> A convenience wrapper named `bely-cli-test` ships alongside the source; it presets +> `BELY_HOST` to `https://tinkerbox.aps.anl.gov:8181/bely` before invoking `bely-cli`. The +> examples in this README use `bely-cli` directly, so set the host (or export `BELY_HOST`) > as shown above. ## Authentication @@ -61,7 +76,7 @@ the settings file (see [Configuration & environment](#configuration--environment A `--format` option controls output, appended to the command: ```bash -bely-cli.py doc list --format json +bely-cli doc list --format json ``` | Value | Behavior | @@ -70,13 +85,13 @@ bely-cli.py doc list --format json | `json` | Structured JSON — for scripting | | `yaml` | Structured YAML — for scripting | -`--format` is given at the end of a command, e.g. `bely-cli.py entry list -n "..." --format yaml`. +`--format` is given at the end of a command, e.g. `bely-cli entry list -n "..." --format yaml`. ## Commands ### `doc` — log documents -#### `bely-cli.py doc new` +#### `bely-cli doc new` Create a new log document (and optionally its first entry). @@ -91,7 +106,7 @@ Create a new log document (and optionally its first entry). | `-o, --output TEXT` | Directory to write a template-generated entry into (default: cwd). | | `--list-options {system,type,template}` | List the available values for that option and exit. | -#### `bely-cli.py doc list` +#### `bely-cli doc list` List recent log documents you created, newest first. @@ -101,15 +116,15 @@ List recent log documents you created, newest first. ### `tui` — interactive terminal UIs -#### `bely-cli.py tui lookup` +#### `bely-cli tui lookup` Interactively browse to find a log entry when you don't already know its document. The TUI drills down through three levels — **logbook → recent documents → entries** — and then shows the entry's markdown in a scrollable view. Browsing is read-only and needs no authentication. ```bash -bely-cli.py tui lookup -bely-cli.py tui lookup --limit 50 +bely-cli tui lookup +bely-cli tui lookup --limit 50 ``` | Option | Description | @@ -128,12 +143,12 @@ Keys: | `Esc` | Go back one level; quits from the logbook list. | On selecting an entry the TUI exits and prints its `doc-id` / `log-id`, plus a ready-to-run -`bely-cli.py entry get` command so you can fetch it: +`bely-cli entry get` command so you can fetch it: ``` doc-id: 99 log-id: 42 -# fetch with: bely-cli.py entry get -d 99 --id 42 +# fetch with: bely-cli entry get -d 99 --id 42 ``` With `--format json` / `--format yaml` the selected reference is printed as structured data @@ -144,7 +159,7 @@ instead. All `entry` commands identify the target document with **either** `-n/--doc-name` **or** `-d/--doc-id` (provide one). -#### `bely-cli.py entry add` +#### `bely-cli entry add` Add a new entry to an existing document. If none of `--file`, `--text`, or `--add-attachment` is given, your `$EDITOR` opens for the entry text. @@ -157,7 +172,7 @@ Add a new entry to an existing document. If none of `--file`, `--text`, or | `-t, --text TEXT` | Inline text for the entry. | | `--add-attachment TEXT` | File to attach to the entry. | -#### `bely-cli.py entry update` +#### `bely-cli entry update` Update an existing entry. With no `--id`, your most recent entry in the document is updated. If none of `--file`, `--text`, or `--add-attachment` is given, your `$EDITOR` @@ -172,7 +187,7 @@ opens. `--file` and `--text` are mutually exclusive. | `-t, --text TEXT` | Inline text for the entry. | | `--add-attachment TEXT` | File to attach to the entry. | -#### `bely-cli.py entry list` +#### `bely-cli entry list` List the entries in a document (Log ID, date, author, and a snippet of the first line). @@ -181,7 +196,7 @@ List the entries in a document (Log ID, date, author, and a snippet of the first | `-n, --doc-name TEXT` | Document name. | | `-d, --doc-id INTEGER` | Document ID. | -#### `bely-cli.py entry get` +#### `bely-cli entry get` Write the markdown of an entry to a file named `_entry_.md`. @@ -194,26 +209,26 @@ Write the markdown of an entry to a file named `_entry_.md`. ### `config` — local configuration -#### `bely-cli.py config show` +#### `bely-cli config show` Show the current configuration: values from the settings file and the relevant environment variables (`BELY_PASSWORD` is masked). -#### `bely-cli.py config edit` +#### `bely-cli config edit` Open the settings file in your editor. The editor is resolved from `EDITOR`, then the `editor` setting, then `vi`. The file and directory are created if needed. -#### `bely-cli.py config set FIELD VALUE` +#### `bely-cli config set FIELD VALUE` Set a single configuration field. `FIELD` is one of `host`, `user`, `editor`, or `token_path`. ```bash -bely-cli.py config set user alice -bely-cli.py config set host https://tinkerbox.aps.anl.gov:8181/bely -bely-cli.py config set editor nano -bely-cli.py config set token_path ~/.secrets/bely-token +bely-cli config set user alice +bely-cli config set host https://tinkerbox.aps.anl.gov:8181/bely +bely-cli config set editor nano +bely-cli config set token_path ~/.secrets/bely-token ``` ## Configuration & environment @@ -259,41 +274,41 @@ user: alice ```bash # One-time setup -bely-cli.py config set host https://tinkerbox.aps.anl.gov:8181/bely -bely-cli.py config set user alice +bely-cli config set host https://tinkerbox.aps.anl.gov:8181/bely +bely-cli config set user alice # Discover available values -bely-cli.py doc new --list-options type -bely-cli.py doc new --list-options system -bely-cli.py doc new --list-options template +bely-cli doc new --list-options type +bely-cli doc new --list-options system +bely-cli doc new --list-options template # Create a document interactively (prompts for type and name) -bely-cli.py doc new +bely-cli doc new # Create a document with everything specified, plus a first entry from a file -bely-cli.py doc new --type ops --name "Shift Report" --systems SR,software --file entry.md +bely-cli doc new --type ops --name "Shift Report" --systems SR,software --file entry.md # List your recent documents -bely-cli.py doc list --limit 50 +bely-cli doc list --limit 50 # Add an entry — inline text, from a file, or via your editor -bely-cli.py entry add -n "Shift Report" -t "Beam restored after RF trip." -bely-cli.py entry add -n "Shift Report" -f entry.md -bely-cli.py entry add -n "Shift Report" # opens $EDITOR +bely-cli entry add -n "Shift Report" -t "Beam restored after RF trip." +bely-cli entry add -n "Shift Report" -f entry.md +bely-cli entry add -n "Shift Report" # opens $EDITOR # Attach a file to an entry -bely-cli.py entry add -n "Shift Report" --add-attachment plot.png +bely-cli entry add -n "Shift Report" --add-attachment plot.png # Update your most recent entry, or a specific one -bely-cli.py entry update -n "Shift Report" -t "Corrected: trip was on RF2." -bely-cli.py entry update -n "Shift Report" --id 42 -f revised.md +bely-cli entry update -n "Shift Report" -t "Corrected: trip was on RF2." +bely-cli entry update -n "Shift Report" --id 42 -f revised.md # List and fetch entries -bely-cli.py entry list -n "Shift Report" -bely-cli.py entry get -n "Shift Report" # latest, to cwd -bely-cli.py entry get -d 99 --id 42 -o ~/logs/ +bely-cli entry list -n "Shift Report" +bely-cli entry get -n "Shift Report" # latest, to cwd +bely-cli entry get -d 99 --id 42 -o ~/logs/ # Structured output for scripting -bely-cli.py doc list --format json -bely-cli.py entry list -n "Shift Report" --format yaml +bely-cli doc list --format json +bely-cli entry list -n "Shift Report" --format yaml ``` diff --git a/tools/developer_tools/bely-cli/bely-cli-test b/tools/developer_tools/bely-cli/bely-cli-test index d5452c293..5818e34a4 100755 --- a/tools/developer_tools/bely-cli/bely-cli-test +++ b/tools/developer_tools/bely-cli/bely-cli-test @@ -1,5 +1,4 @@ #!/bin/bash -SCRIPT_DIR=$(dirname "${BASH_SOURCE[0]}") export BELY_HOST=https://tinkerbox.aps.anl.gov:8181/bely -${SCRIPT_DIR}/bely-cli/bely-cli.py "$@" +bely-cli "$@" diff --git a/tools/developer_tools/bely-cli/conda-recipe/build.sh b/tools/developer_tools/bely-cli/conda-recipe/build.sh new file mode 100755 index 000000000..b7583ebaf --- /dev/null +++ b/tools/developer_tools/bely-cli/conda-recipe/build.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash + +$PYTHON -m pip install . --no-deps --no-build-isolation diff --git a/tools/developer_tools/bely-cli/conda-recipe/meta.yaml b/tools/developer_tools/bely-cli/conda-recipe/meta.yaml new file mode 100644 index 000000000..5ce8cdbb5 --- /dev/null +++ b/tools/developer_tools/bely-cli/conda-recipe/meta.yaml @@ -0,0 +1,42 @@ +{% set name = "bely-cli" %} +{% set version = "2026.3.0" %} + +package: + name: "{{ name|lower }}" + version: "{{ version }}" + +source: + path: ./src + +build: + number: 0 + noarch: python + entry_points: + - bely-cli = bely_cli.cli:main + +requirements: + build: + - pip + - python>3.10 + - setuptools + run: + - python>3.10 + - click + - pyyaml + - bely-api + +test: + imports: + - bely_cli + source_files: + - test + requires: + - python + commands: + - python -m unittest discover -s test -v + - bely-cli -h + +about: + home: "https://github.com/AdvancedPhotonSource/BELY" + license: "Copyright (c) UChicago Argonne, LLC. All rights reserved." + summary: "Command-line interface for the BELY" diff --git a/tools/developer_tools/bely-cli/install.txt b/tools/developer_tools/bely-cli/install.txt deleted file mode 100644 index d0587c658..000000000 --- a/tools/developer_tools/bely-cli/install.txt +++ /dev/null @@ -1 +0,0 @@ -bely-cli/bely-cli.py diff --git a/tools/developer_tools/bely-cli/pyproject.toml b/tools/developer_tools/bely-cli/pyproject.toml new file mode 100644 index 000000000..8918c53f8 --- /dev/null +++ b/tools/developer_tools/bely-cli/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=65.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "bely-cli" +version = "2026.3.0" +description = "Command-line interface for the BELY electronic logbook" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "Copyright (c) UChicago Argonne, LLC. All rights reserved." } +maintainers = [{ name = "Dariusz Jarosz", email = "djarosz@aps.anl.gov" }] +dependencies = [ + "click>=8.1.0", + "PyYAML>=6.0.0", + "bely-api>=2026.3.0", +] + +[project.urls] +Homepage = "https://github.com/AdvancedPhotonSource/BELY" + +[project.scripts] +bely-cli = "bely_cli.cli:main" + +[dependency-groups] +dev = ["pytest>=7.0.0"] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.uv.sources] +bely-api = { path = "../python-client/dist/bely_api-2026.3.0.tar.gz" } diff --git a/tools/developer_tools/bely-cli/run_test.sh b/tools/developer_tools/bely-cli/run_test.sh old mode 100644 new mode 100755 index 8beeb1656..ce6fdc9cd --- a/tools/developer_tools/bely-cli/run_test.sh +++ b/tools/developer_tools/bely-cli/run_test.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -PYTHON=/C2/conda/envs/bely/bin/python +PYTHON="${PYTHON:-python}" # COMPONENT_DIR is set by the test harness; default to this script's dir so the # test is also runnable standalone. @@ -12,6 +12,6 @@ COMPONENT_DIR="${COMPONENT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" # Smoke test: the published command loads and --format is wired per-command # (appended to a leaf command, not at the top level). -bely-cli.py -h > /dev/null -bely-cli.py doc list -h | grep -q -- --format -bely-cli.py tui lookup -h | grep -q -- --format +bely-cli -h > /dev/null +bely-cli doc list -h | grep -q -- --format +bely-cli tui lookup -h | grep -q -- --format diff --git a/tools/developer_tools/bely-cli/src/bely_cli/__init__.py b/tools/developer_tools/bely-cli/src/bely_cli/__init__.py new file mode 100644 index 000000000..cd3cdac42 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/__init__.py @@ -0,0 +1 @@ +__version__ = "2026.3.0" diff --git a/tools/developer_tools/bely-cli/bely-cli/auth.py b/tools/developer_tools/bely-cli/src/bely_cli/auth.py similarity index 97% rename from tools/developer_tools/bely-cli/bely-cli/auth.py rename to tools/developer_tools/bely-cli/src/bely_cli/auth.py index 8b305340b..8c8eed81a 100644 --- a/tools/developer_tools/bely-cli/bely-cli/auth.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/auth.py @@ -3,8 +3,8 @@ import sys from contextlib import contextmanager -from common import is_no_prompt -from config import CONFIG_DIR, expand_path, get_setting +from .common import is_no_prompt +from .config import CONFIG_DIR, expand_path, get_setting # belyApi and BelyApiFactory are imported lazily inside the functions that need # them: the generated client is ~1.8s to import, and paths like --help that diff --git a/tools/developer_tools/bely-cli/bely-cli/bely-cli.py b/tools/developer_tools/bely-cli/src/bely_cli/cli.py old mode 100755 new mode 100644 similarity index 97% rename from tools/developer_tools/bely-cli/bely-cli/bely-cli.py rename to tools/developer_tools/bely-cli/src/bely_cli/cli.py index d1b50f54c..55ac066a6 --- a/tools/developer_tools/bely-cli/bely-cli/bely-cli.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/cli.py @@ -1,25 +1,23 @@ -#!/C2/conda/envs/bely/bin/python - import sys import click -from common import FORMATS, set_no_prompt -from config import VALID_FIELDS -from commands import ( +from .common import FORMATS, set_no_prompt +from .config import VALID_FIELDS +from .commands import ( cmd_new_doc, cmd_list_docs, cmd_show_config, cmd_edit_config, cmd_set_config, ) -from entry import ( +from .entry import ( cmd_add_entry, cmd_get_entry, cmd_list_entries, cmd_update_entry, ) -from tui import cmd_tui +from .tui import cmd_tui CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) @@ -201,9 +199,13 @@ def config_set(field, value, output_format): -if __name__ == "__main__": +def main(): try: cli() except Exception as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tools/developer_tools/bely-cli/bely-cli/commands.py b/tools/developer_tools/bely-cli/src/bely_cli/commands.py similarity index 98% rename from tools/developer_tools/bely-cli/bely-cli/commands.py rename to tools/developer_tools/bely-cli/src/bely_cli/commands.py index 5bbd5f469..28fbffa48 100644 --- a/tools/developer_tools/bely-cli/bely-cli/commands.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/commands.py @@ -1,8 +1,8 @@ import os -import auth -import config -from common import find_logdoc, is_no_prompt, read_file_or_stdin, write_entry_to_file, open_in_editor, print_items, print_result +from . import auth +from . import config +from .common import find_logdoc, is_no_prompt, read_file_or_stdin, write_entry_to_file, open_in_editor, print_items, print_result ENV_VARS = ["BELY_HOST", "BELY_USER", "BELY_PASSWORD", "BELY_SETTINGS_FILE", "EDITOR"] diff --git a/tools/developer_tools/bely-cli/bely-cli/common.py b/tools/developer_tools/bely-cli/src/bely_cli/common.py similarity index 99% rename from tools/developer_tools/bely-cli/bely-cli/common.py rename to tools/developer_tools/bely-cli/src/bely_cli/common.py index 1404624a3..bb0f09f5f 100644 --- a/tools/developer_tools/bely-cli/bely-cli/common.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/common.py @@ -6,7 +6,7 @@ import yaml -import config +from . import config # Supported values for the global --format option (single source of truth). diff --git a/tools/developer_tools/bely-cli/bely-cli/config.py b/tools/developer_tools/bely-cli/src/bely_cli/config.py similarity index 100% rename from tools/developer_tools/bely-cli/bely-cli/config.py rename to tools/developer_tools/bely-cli/src/bely_cli/config.py diff --git a/tools/developer_tools/bely-cli/bely-cli/entry.py b/tools/developer_tools/bely-cli/src/bely_cli/entry.py similarity index 98% rename from tools/developer_tools/bely-cli/bely-cli/entry.py rename to tools/developer_tools/bely-cli/src/bely_cli/entry.py index b83c511f9..5bae99640 100644 --- a/tools/developer_tools/bely-cli/bely-cli/entry.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/entry.py @@ -1,8 +1,8 @@ import os from types import SimpleNamespace -import auth -from common import find_logdoc, is_no_prompt, read_file_or_stdin, write_entry_to_file, open_in_editor, print_items, print_result +from . import auth +from .common import find_logdoc, is_no_prompt, read_file_or_stdin, write_entry_to_file, open_in_editor, print_items, print_result def resolve_doc(logbook_api, doc_name, doc_id): diff --git a/tools/developer_tools/bely-cli/bely-cli/tui.py b/tools/developer_tools/bely-cli/src/bely_cli/tui.py similarity index 99% rename from tools/developer_tools/bely-cli/bely-cli/tui.py rename to tools/developer_tools/bely-cli/src/bely_cli/tui.py index 10dd9dc98..978ac184c 100644 --- a/tools/developer_tools/bely-cli/bely-cli/tui.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui.py @@ -2,8 +2,8 @@ import sys import textwrap -import auth -from common import print_result +from . import auth +from .common import print_result # belyApi is intentionally NOT imported at module scope: the heavy client is # pulled in lazily by auth.get_factory() only when the TUI actually runs, so the diff --git a/tools/developer_tools/bely-cli/test/test_commands.py b/tools/developer_tools/bely-cli/test/test_commands.py index 45672ae57..f86fe9a9e 100644 --- a/tools/developer_tools/bely-cli/test/test_commands.py +++ b/tools/developer_tools/bely-cli/test/test_commands.py @@ -1,15 +1,12 @@ import io import os -import sys import tempfile import unittest from contextlib import redirect_stdout from types import SimpleNamespace from unittest.mock import MagicMock, patch -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bely-cli")) - -import commands +from bely_cli import commands class _NF(Exception): diff --git a/tools/developer_tools/bely-cli/test/test_config.py b/tools/developer_tools/bely-cli/test/test_config.py index af7ff3e22..1ba79292a 100644 --- a/tools/developer_tools/bely-cli/test/test_config.py +++ b/tools/developer_tools/bely-cli/test/test_config.py @@ -1,14 +1,10 @@ import importlib import os -import sys import tempfile import unittest from unittest.mock import patch -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bely-cli")) - -import config -import auth +from bely_cli import auth, config class GetEditorTests(unittest.TestCase): diff --git a/tools/developer_tools/bely-cli/test/test_entry.py b/tools/developer_tools/bely-cli/test/test_entry.py index e9c6112fd..77e2de69b 100644 --- a/tools/developer_tools/bely-cli/test/test_entry.py +++ b/tools/developer_tools/bely-cli/test/test_entry.py @@ -1,16 +1,13 @@ import io import json import os -import sys import tempfile import unittest from contextlib import redirect_stdout from types import SimpleNamespace from unittest.mock import MagicMock, patch -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bely-cli")) - -import entry +from bely_cli import entry class FakeApi: diff --git a/tools/developer_tools/bely-cli/test/test_tui.py b/tools/developer_tools/bely-cli/test/test_tui.py index 19d230e2b..4a646aca3 100644 --- a/tools/developer_tools/bely-cli/test/test_tui.py +++ b/tools/developer_tools/bely-cli/test/test_tui.py @@ -1,12 +1,8 @@ import datetime -import os -import sys import unittest from types import SimpleNamespace -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bely-cli")) - -import tui +from bely_cli import tui class FilterItemsTests(unittest.TestCase): From 096f74094925dd2cdbba7b8eb5c3504dce622dd9 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Tue, 18 Aug 2026 15:00:59 -0500 Subject: [PATCH 31/62] run `uv sync` --- tools/developer_tools/bely-cli/uv.lock | 453 +++++++++++++++++++++++++ 1 file changed, 453 insertions(+) create mode 100644 tools/developer_tools/bely-cli/uv.lock diff --git a/tools/developer_tools/bely-cli/uv.lock b/tools/developer_tools/bely-cli/uv.lock new file mode 100644 index 000000000..5f1873928 --- /dev/null +++ b/tools/developer_tools/bely-cli/uv.lock @@ -0,0 +1,453 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "bely-api" +version = "2026.3.0" +source = { path = "../python-client/dist/bely_api-2026.3.0.tar.gz" } +dependencies = [ + { name = "certifi" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { hash = "sha256:ee9327fc02ddf11e3c0d70ba2e0a7babb3ccb6872096cd413a174bdcdc563f1b" } + +[package.metadata] +requires-dist = [ + { name = "certifi" }, + { name = "pydantic", specifier = ">=1.10" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] + +[[package]] +name = "bely-cli" +version = "2026.3.0" +source = { editable = "." } +dependencies = [ + { name = "bely-api" }, + { name = "click" }, + { name = "pyyaml" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "bely-api", path = "../python-client/dist/bely_api-2026.3.0.tar.gz" }, + { name = "click", specifier = ">=8.1.0" }, + { name = "pyyaml", specifier = ">=6.0.0" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=7.0.0" }] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] From 7f1f210b799db9631bb78e0cd8a0ded28bda133e Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Wed, 19 Aug 2026 10:53:56 -0500 Subject: [PATCH 32/62] Migrate to textual/rich for the TUI --- tools/developer_tools/bely-cli/README.md | 48 +- .../bely-cli/conda-recipe/meta.yaml | 2 + tools/developer_tools/bely-cli/pyproject.toml | 2 + .../bely-cli/src/bely_cli/common.py | 8 +- .../bely-cli/src/bely_cli/tui.py | 369 ------------- .../bely-cli/src/bely_cli/tui/__init__.py | 37 ++ .../bely-cli/src/bely_cli/tui/app.py | 497 ++++++++++++++++++ .../bely-cli/src/bely_cli/tui/data.py | 77 +++ .../bely-cli/src/bely_cli/tui/format.py | 234 +++++++++ .../developer_tools/bely-cli/test/test_tui.py | 191 ++++++- .../bely-cli/test/test_tui_app.py | 155 ++++++ .../bely-cli/test/test_tui_data.py | 110 ++++ tools/developer_tools/bely-cli/uv.lock | 102 ++++ 13 files changed, 1429 insertions(+), 403 deletions(-) delete mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui.py create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/app.py create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/data.py create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/format.py create mode 100644 tools/developer_tools/bely-cli/test/test_tui_app.py create mode 100644 tools/developer_tools/bely-cli/test/test_tui_data.py diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index 5a47e3769..b897f115e 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -119,8 +119,23 @@ List recent log documents you created, newest first. #### `bely-cli tui lookup` Interactively browse to find a log entry when you don't already know its document. The TUI -drills down through three levels — **logbook → recent documents → entries** — and then shows -the entry's markdown in a scrollable view. Browsing is read-only and needs no authentication. +(built on [Textual](https://textual.textualize.io/)) drills down through three levels — +**logbook → recent documents → entries**. Browsing is read-only and needs no authentication. + +The logbook and document levels render as full-width, aligned tables (rows stay in API order, +not sorted): + +| Level | Columns | +|-------|---------| +| Logbook | Name, Display, Description | +| Document | Name, Description, Systems, Owner, Modified | + +Press `i` at either of these levels to open a side info panel with a few extra fields for the +highlighted row (it splits the table's width; `i` again closes it). + +Entries stay a single-column list (date, author, and a snippet of the first line) with a preview +pane that's always shown alongside it — the entry body rendered as markdown (headings, lists, +tables, and syntax-highlighted code), since the list row itself is just a one-line snippet. ```bash bely-cli tui lookup @@ -131,16 +146,30 @@ bely-cli tui lookup --limit 50 |--------|-------------| | `--limit INTEGER` | Recent documents to load per logbook (default: 100). | +The info panel (`i`, logbook/document levels only) shows, depending on the level: for a logbook, +its name, display name(s), and description; for a document, its description, logbook types, +systems, owner, and creation/modification info. The entry preview (always shown at that level) +has author and modification info, reply/reaction counts, and attachments (fetched lazily as you +highlight each entry). + Keys: +The footer at the bottom of the screen only ever shows the keys that apply to the level you're +on — `i` disappears once you drill into entries, and `s` / `y` / `e` / `f` only appear there. + | Key | Action | |-----|--------| -| `Up` / `Down`, `PgUp` / `PgDn` | Move the highlight (or scroll, in the entry view). | -| *type any text* | Incrementally filter the current list (case-insensitive substring). | -| `Backspace` | Edit the filter; with an empty filter, go back one level. | -| `Enter` | Open the highlighted item / drill in. In the entry view, select the entry. | -| `q` | (Entry view only) select the entry. | -| `Esc` | Go back one level; quits from the logbook list. | +| `Up` / `Down`, `PgUp` / `PgDn` | Move the highlight; the preview/info panel follows. | +| `/` | Focus the filter box and incrementally filter the current list (case-insensitive substring). | +| `Enter` | In the filter box, return focus to the list. Elsewhere, drill into the highlighted item, or select the entry at the entries level. | +| `Esc` / `Backspace` | Go back one level (from the list); quits from the logbook list. In the filter box, `Esc` returns focus to the list. | +| `s` | Entries level only: save the highlighted entry's markdown to a file in the current directory. | +| `y` | Entries level only: copy a `bely-cli entry get` reference for the highlighted entry to the clipboard. | +| `e` | Entries level only: open the highlighted entry in `$EDITOR` (view-only — nothing is sent back to the server). | +| `i` | Logbook/document levels only: toggle the side info panel. | +| `f` | Entries level only: toggle the list to widen the preview pane. | +| `r` | Refresh the current level, bypassing the in-session cache. | +| `q` | Quit without selecting. | On selecting an entry the TUI exits and prints its `doc-id` / `log-id`, plus a ready-to-run `bely-cli entry get` command so you can fetch it: @@ -154,6 +183,9 @@ log-id: 42 With `--format json` / `--format yaml` the selected reference is printed as structured data instead. +Note: attachment images referenced from entry markdown render as links in the preview, not +inline images — use `s` or `e` to view the full entry, or fetch the attachment directly. + ### `entry` — log entries All `entry` commands identify the target document with **either** `-n/--doc-name` **or** diff --git a/tools/developer_tools/bely-cli/conda-recipe/meta.yaml b/tools/developer_tools/bely-cli/conda-recipe/meta.yaml index 5ce8cdbb5..25c3be914 100644 --- a/tools/developer_tools/bely-cli/conda-recipe/meta.yaml +++ b/tools/developer_tools/bely-cli/conda-recipe/meta.yaml @@ -24,6 +24,8 @@ requirements: - click - pyyaml - bely-api + - textual + - rich test: imports: diff --git a/tools/developer_tools/bely-cli/pyproject.toml b/tools/developer_tools/bely-cli/pyproject.toml index 8918c53f8..e3395322b 100644 --- a/tools/developer_tools/bely-cli/pyproject.toml +++ b/tools/developer_tools/bely-cli/pyproject.toml @@ -14,6 +14,8 @@ dependencies = [ "click>=8.1.0", "PyYAML>=6.0.0", "bely-api>=2026.3.0", + "textual>=0.86.0", + "rich>=13.7.0", ] [project.urls] diff --git a/tools/developer_tools/bely-cli/src/bely_cli/common.py b/tools/developer_tools/bely-cli/src/bely_cli/common.py index bb0f09f5f..37376e8a9 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/common.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/common.py @@ -78,10 +78,12 @@ def _sanitize_for_filename(name): return "".join(c if c.isalnum() or c in "-_." else "_" for c in name) -def write_entry_to_file(entry, doc_name, output_dir=None, fmt="text"): +def write_entry_to_file(entry, doc_name, output_dir=None, fmt="text", quiet=False): """Write entry markdown to _entry_.md in output_dir (cwd if None). - Returns the path written. Prints the confirmation line only for text format. + Returns the path written. Prints the confirmation line only for text + format; pass quiet=True to suppress it entirely (e.g. from the TUI, where + printing to stdout would corrupt the screen). """ directory = os.path.expanduser(output_dir) if output_dir else "." if not os.path.isdir(directory): @@ -90,7 +92,7 @@ def write_entry_to_file(entry, doc_name, output_dir=None, fmt="text"): out_path = os.path.join(directory, f"{safe_doc}_entry_{entry.log_id}.md") with open(out_path, "w") as f: f.write(entry.log_entry or "") - if fmt == "text": + if fmt == "text" and not quiet: print(f'Wrote log entry id={entry.log_id} to {out_path}') return out_path diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui.py b/tools/developer_tools/bely-cli/src/bely_cli/tui.py deleted file mode 100644 index 978ac184c..000000000 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui.py +++ /dev/null @@ -1,369 +0,0 @@ -import curses -import sys -import textwrap - -from . import auth -from .common import print_result - -# belyApi is intentionally NOT imported at module scope: the heavy client is -# pulled in lazily by auth.get_factory() only when the TUI actually runs, so the -# --help path stays fast (see auth.py). - - -# -- pure helpers (no curses; unit-tested) -- - -def format_type(t): - """Display string for a logbook type (EntityType).""" - display = getattr(t, "display_name", None) or "" - name = getattr(t, "name", None) or "" - return f"{name} ({display})" if display else name - - -def format_doc(d): - """Display string for a log document (ItemDomainLogbook).""" - name = getattr(d, "name", None) or "(unnamed)" - desc = getattr(d, "description", None) - return f"{name} - {desc}" if desc else name - - -def format_entry(e): - """Display string for a log entry: date, author, first-line snippet. - - Mirrors the snippet logic used by cmd_list_entries (entry.py). - """ - dt = getattr(e, "entered_on_date_time", None) - date = dt.strftime("%Y-%m-%d %H:%M") if dt else "" - author = getattr(e, "entered_by_username", None) or "" - body = getattr(e, "log_entry", None) or "" - lines = [ln for ln in body.strip().splitlines() if ln.strip()] - snippet = lines[0] if lines else "" - if len(snippet) > 60: - snippet = snippet[:57] + "..." - return f"{date} {author:<16} {snippet}".rstrip() - - -def filter_items(items, query, render_fn): - """Return items whose rendered string contains query (case-insensitive).""" - if not query: - return list(items) - q = query.lower() - return [it for it in items if q in render_fn(it).lower()] - - -def step_index(index, delta, length): - """Clamp index+delta to [0, length-1]. length<=0 returns 0.""" - if length <= 0: - return 0 - return max(0, min(index + delta, length - 1)) - - -def entry_reference(doc, entry): - """Reference dict for the selected entry, for json/yaml output.""" - return { - "doc_id": getattr(doc, "id", None), - "doc_name": getattr(doc, "name", None), - "log_id": getattr(entry, "log_id", None), - } - - -# -- curses helpers -- - -_ENTER_KEYS = (curses.KEY_ENTER, 10, 13) -_BACKSPACE_KEYS = (curses.KEY_BACKSPACE, 127, 8) -_ESC = 27 - - -# Display attributes. These start as monochrome fallbacks (used when the -# terminal has no color support) and are upgraded to theme-aware colors by -# _init_colors() once curses is running. -_A_TITLE = curses.A_BOLD -_A_SELECTED = curses.A_REVERSE | curses.A_BOLD -_A_FOOTER = curses.A_DIM -_A_ERROR = curses.A_BOLD - -_PAIR_TITLE = 1 -_PAIR_FOOTER = 2 -_PAIR_ERROR = 3 - - -def _init_colors(): - """Derive display attributes from the terminal's own palette. - - use_default_colors() lets us pass -1 for the background so the terminal's - own background shows through, and the named ANSI colors resolve to whatever - the user's theme defines for them. That keeps the UI legible on both light - and dark terminals without hardcoding a background. The selected-row - highlight uses reverse video, which simply swaps the terminal's current - foreground/background and so adapts to any theme. - """ - global _A_TITLE, _A_FOOTER, _A_ERROR - if not curses.has_colors(): - return - try: - curses.start_color() - curses.use_default_colors() - except curses.error: - return - curses.init_pair(_PAIR_TITLE, curses.COLOR_CYAN, -1) - curses.init_pair(_PAIR_FOOTER, curses.COLOR_BLUE, -1) - curses.init_pair(_PAIR_ERROR, curses.COLOR_RED, -1) - _A_TITLE = curses.color_pair(_PAIR_TITLE) | curses.A_BOLD - _A_FOOTER = curses.color_pair(_PAIR_FOOTER) - _A_ERROR = curses.color_pair(_PAIR_ERROR) | curses.A_BOLD - - -def _addstr(stdscr, y, x, text, width, attr=0): - """Write text truncated to width, swallowing curses edge errors.""" - try: - stdscr.addstr(y, x, text[:max(0, width)], attr) - except curses.error: - pass - - -def _select(stdscr, title, items, render_fn): - """Interactive, filterable list. Return the chosen item, or None to go back. - - Up/Down/PgUp/PgDn move; printable chars filter; Backspace edits the filter - (and goes back when the filter is empty); Enter selects; Esc goes back. - """ - query = "" - pos = 0 # highlighted index within the filtered list - top = 0 # first visible row (for scrolling) - - while True: - height, width = stdscr.getmaxyx() - body_h = max(1, height - 3) # rows available for list items - shown = filter_items(items, query, render_fn) - - if pos >= len(shown): - pos = max(0, len(shown) - 1) - if pos < top: - top = pos - elif pos >= top + body_h: - top = pos - body_h + 1 - - stdscr.erase() - _addstr(stdscr, 0, 0, title, width, _A_TITLE) - - if not shown: - _addstr(stdscr, 2, 2, "(no items)", width) - else: - for row, item in enumerate(shown[top:top + body_h]): - idx = top + row - selected = idx == pos - text = " " + render_fn(item) - if selected: - # Pad to full width so the highlight reads as a solid bar. - text = text.ljust(width) - _addstr(stdscr, 2 + row, 0, text, width, - _A_SELECTED if selected else 0) - - footer = f"Filter: {query}_ [Up/Down PgUp/PgDn] move [Enter] open [Esc] back (type to filter)" - _addstr(stdscr, height - 1, 0, footer, width, _A_FOOTER) - stdscr.refresh() - - ch = stdscr.getch() - if ch == curses.KEY_UP: - pos = max(0, pos - 1) - elif ch == curses.KEY_DOWN: - pos = min(len(shown) - 1, pos + 1) if shown else 0 - elif ch == curses.KEY_PPAGE: - pos = max(0, pos - body_h) - elif ch == curses.KEY_NPAGE: - pos = min(len(shown) - 1, pos + body_h) if shown else 0 - elif ch in _ENTER_KEYS: - if shown: - return shown[pos] - elif ch == _ESC: - return None - elif ch in _BACKSPACE_KEYS: - if query: - query = query[:-1] - pos = 0 - else: - return None # empty filter + backspace = go back - elif 32 <= ch <= 126: - query += chr(ch) - pos = 0 - - -def _view_entry(stdscr, doc, entries, index): - """Scrollable view of an entry's markdown, with Left/Right to move between - entries in the document. Return (action, index) where action is 'select' or - 'back' and index is the (possibly changed) entry the user ended on.""" - top = 0 - - while True: - entry = entries[index] - body = getattr(entry, "log_entry", None) or "(empty entry)" - header = ( - f'{getattr(doc, "name", "")} / log_id={getattr(entry, "log_id", "")}' - f' ({index + 1}/{len(entries)})' - ) - - height, width = stdscr.getmaxyx() - body_h = max(1, height - 3) - - lines = [] - for raw in body.splitlines() or [""]: - wrapped = textwrap.wrap(raw, max(1, width - 1)) or [""] - lines.extend(wrapped) - - max_top = max(0, len(lines) - body_h) - top = min(top, max_top) - - stdscr.erase() - _addstr(stdscr, 0, 0, header, width, _A_TITLE) - for row, line in enumerate(lines[top:top + body_h]): - _addstr(stdscr, 2 + row, 0, line, width) - footer = "[Up/Down PgUp/PgDn] scroll [Left/Right] prev/next entry [Enter/q] select [Esc] back" - _addstr(stdscr, height - 1, 0, footer, width, _A_FOOTER) - stdscr.refresh() - - ch = stdscr.getch() - if ch == curses.KEY_UP: - top = max(0, top - 1) - elif ch == curses.KEY_DOWN: - top = min(max_top, top + 1) - elif ch == curses.KEY_PPAGE: - top = max(0, top - body_h) - elif ch == curses.KEY_NPAGE: - top = min(max_top, top + body_h) - elif ch == curses.KEY_LEFT: - new = step_index(index, -1, len(entries)) - if new != index: - index, top = new, 0 # reset scroll on entry change - elif ch == curses.KEY_RIGHT: - new = step_index(index, +1, len(entries)) - if new != index: - index, top = new, 0 - elif ch in _ENTER_KEYS or ch in (ord("q"), ord("Q")): - return "select", index - elif ch == _ESC or ch in _BACKSPACE_KEYS: - return "back", index - - -def _loading(stdscr, message): - """Show a transient status line while a network call runs.""" - _, width = stdscr.getmaxyx() - stdscr.erase() - _addstr(stdscr, 0, 0, message, width, _A_FOOTER) - stdscr.refresh() - - -def _show_error(stdscr, message): - """Show an error and wait for a keypress.""" - height, width = stdscr.getmaxyx() - stdscr.erase() - _addstr(stdscr, 0, 0, "Error", width, _A_ERROR) - for row, line in enumerate(textwrap.wrap(message, max(1, width - 1))): - _addstr(stdscr, 2 + row, 0, line, width) - _addstr(stdscr, height - 1, 0, "Press any key to go back", width, _A_FOOTER) - stdscr.refresh() - stdscr.getch() - - -def _run(stdscr, api, limit): - """Drill-down loop. Return (doc, entry) if confirmed, else None.""" - curses.curs_set(0) - # ncurses waits ESCDELAY ms after an ESC byte to see if it begins an escape - # sequence (arrows, PgUp, ...). The default is 1000ms, which makes "Esc to - # go back" feel frozen. 25ms is plenty to disambiguate real key sequences. - if hasattr(curses, "set_escdelay"): - curses.set_escdelay(25) - _init_colors() - - level = 0 - sel_type = sel_doc = sel_entry = None - - # Per-session caches so back-navigation redraws from memory instead of - # re-hitting the network. Keyed by parent id; errors are left uncached so a - # later visit retries. `is None` checks distinguish "not fetched" from a - # legitimately empty result list (which we do cache). - types = None - docs_cache = {} # type_id -> list of documents - entries_cache = {} # doc_id -> list of entries - - while True: - if level == 0: - if types is None: - _loading(stdscr, "Loading logbooks...") - try: - types = api.get_logbook_types() - except Exception as e: # broad: avoid importing belyApi just for its exceptions - _show_error(stdscr, f"Could not load logbooks: {e}") - return None - chosen = _select(stdscr, "Select a logbook", types, format_type) - if chosen is None: - return None - sel_type = chosen - level = 1 - - elif level == 1: - docs = docs_cache.get(sel_type.id) - if docs is None: - _loading(stdscr, f"Loading recent documents in '{format_type(sel_type)}'...") - try: - docs = api.get_log_documents(logbook_type_id=sel_type.id, limit=limit) - except Exception as e: - _show_error(stdscr, f"Could not load documents: {e}") - level = 0 - continue - docs_cache[sel_type.id] = docs - chosen = _select( - stdscr, f"{format_type(sel_type)} - recent documents", docs, format_doc) - if chosen is None: - level = 0 - continue - sel_doc = chosen - level = 2 - - elif level == 2: - entries = entries_cache.get(sel_doc.id) - if entries is None: - _loading(stdscr, f"Loading entries in '{format_doc(sel_doc)}'...") - try: - entries = api.get_log_entries(log_document_id=sel_doc.id) - except Exception as e: - _show_error(stdscr, f"Could not load entries: {e}") - level = 1 - continue - entries_cache[sel_doc.id] = entries - chosen = _select( - stdscr, f"{format_doc(sel_doc)} - entries", entries, format_entry) - if chosen is None: - level = 1 - continue - sel_entry = chosen - level = 3 - - elif level == 3: - entries = entries_cache[sel_doc.id] # already populated at level 2 - idx = next(i for i, e in enumerate(entries) if e is sel_entry) - action, idx = _view_entry(stdscr, sel_doc, entries, idx) - sel_entry = entries[idx] - if action == "back": - level = 2 - continue - return (sel_doc, sel_entry) - - -def cmd_tui(limit=100, fmt="text"): - """Interactively browse logbooks -> documents -> entries to find an entry.""" - if not sys.stdout.isatty() or not sys.stdin.isatty(): - raise RuntimeError("the tui requires an interactive terminal.") - - factory = auth.get_factory() - api = factory.get_logbook_api() - - result = curses.wrapper(_run, api, limit) - if not result: - return - doc, entry = result - - if fmt == "text": - print(f"doc-id: {doc.id}") - print(f"log-id: {entry.log_id}") - print(f"# fetch with: bely-cli.py entry get -d {doc.id} --id {entry.log_id}") - else: - print_result(entry_reference(doc, entry), "", fmt) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py new file mode 100644 index 000000000..c836fbc57 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py @@ -0,0 +1,37 @@ +"""`bely-cli tui lookup`: interactive Textual browser for logbooks/entries. + +`.app` (Textual/rich) is imported lazily inside cmd_tui, not at module scope, +so `bely-cli --help` (which imports this package via cli.py) stays fast. +""" + +from .. import auth +from ..common import print_result +from .data import LogbookData +from .format import entry_reference + +__all__ = ["cmd_tui", "LogbookData"] + + +def cmd_tui(limit=100, fmt="text"): + """Interactively browse logbooks -> documents -> entries to find an entry.""" + import sys + + if not sys.stdout.isatty() or not sys.stdin.isatty(): + raise RuntimeError("the tui requires an interactive terminal.") + + from .app import BelyTuiApp # lazy: keeps --help fast + + factory = auth.get_factory() + data = LogbookData(factory.get_logbook_api()) + + result = BelyTuiApp(data, limit=limit).run() + if not result: + return + doc, entry = result + + if fmt == "text": + print(f"doc-id: {doc.id}") + print(f"log-id: {entry.log_id}") + print(f"# fetch with: bely-cli entry get -d {doc.id} --id {entry.log_id}") + else: + print_result(entry_reference(doc, entry), "", fmt) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py new file mode 100644 index 000000000..34e008ad2 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py @@ -0,0 +1,497 @@ +"""Textual application for `bely-cli tui lookup`. + +Master/detail browser: a nav widget on the left drills through logbook type -> +document -> entry. The logbook/document levels are full-width tables with an +optional side info panel ('i'); the entry level always shows a preview pane +(Rich metadata table + rendered markdown body) since the row itself is just a +one-line snippet. All network calls go through LogbookData inside +@work(thread=True) methods so the UI never blocks on belyApi's synchronous +HTTP calls. +""" + +from rich.table import Table +from textual import work +from textual.app import App +from textual.binding import Binding +from textual.containers import Horizontal, VerticalScroll +from textual.screen import Screen +from textual.widgets import DataTable, Footer, Input, Markdown, OptionList, Static + +from ..common import open_in_editor, write_entry_to_file +from .format import ( + DOC_COLUMNS, + TYPE_COLUMNS, + doc_metadata_rows, + doc_row, + entry_metadata_rows, + entry_row, + filter_items, + format_attachment, + format_doc, + format_type, + reference_command, + type_metadata_rows, + type_row, +) + + +def _rows_table(rows): + table = Table.grid(padding=(0, 1)) + table.add_column(style="bold cyan", no_wrap=True) + table.add_column(ratio=1) + for label, value in rows: + table.add_row(label, value) + return table + + +class BrowseScreen(Screen): + """Logbook type -> document -> entry drill-down with a live preview.""" + + LEVEL_TYPES, LEVEL_DOCS, LEVEL_ENTRIES = range(3) + + # Levels rendered as a DataTable; LEVEL_ENTRIES stays an OptionList. + TABLE_LEVELS = (LEVEL_TYPES, LEVEL_DOCS) + LEVEL_COLUMNS = {LEVEL_TYPES: TYPE_COLUMNS, LEVEL_DOCS: DOC_COLUMNS} + LEVEL_ROW_FN = {LEVEL_TYPES: type_row, LEVEL_DOCS: doc_row, LEVEL_ENTRIES: entry_row} + + # Per-level nav pane width (%), used whenever a preview/info panel is visible. + LEVEL_WIDTH = {LEVEL_TYPES: 42, LEVEL_DOCS: 60, LEVEL_ENTRIES: 42} + + # Actions that only make sense at one kind of level. check_action() below + # returns None for the rest, which hides the binding from the Footer + # entirely (rather than showing it disabled) so only relevant keys appear. + ENTRY_ONLY_ACTIONS = frozenset( + {"toggle_full", "save_entry", "copy_reference", "open_editor"} + ) + TABLE_ONLY_ACTIONS = frozenset({"toggle_info"}) + + BINDINGS = [ + Binding("escape", "back", "Back"), + Binding("backspace", "back", "Back", show=False), + Binding("q", "quit_app", "Quit"), + Binding("slash", "focus_filter", "Filter"), + Binding("f", "toggle_full", "Full"), + Binding("s", "save_entry", "Save"), + Binding("y", "copy_reference", "Copy ref"), + Binding("e", "open_editor", "Editor"), + Binding("r", "refresh_level", "Refresh"), + Binding("i", "toggle_info", "Info"), + ] + + def __init__(self, data, limit): + super().__init__() + self.data = data + self.limit = limit + self.level = self.LEVEL_TYPES + self.sel_type = None + self.sel_doc = None + self.all_items = [] + self.shown_items = [] + self._entry_key = None + self._nav_hidden = False + self._info_open = False + self._table_columns_for = None + + def compose(self): + yield Static(id="breadcrumb") + with Horizontal(id="body"): + yield DataTable(id="nav-table", cursor_type="row", zebra_stripes=True) + yield OptionList(id="nav-list") + with VerticalScroll(id="preview"): + yield Static(id="meta") + yield Markdown(id="body-md") + with Horizontal(id="filter-bar"): + yield Static("Filter:", id="filter-label") + yield Input(id="filter", placeholder="type to filter, / to focus") + yield Footer() + + def on_mount(self): + self.query_one("#body-md", Markdown).display = False + self.show_level(self.LEVEL_TYPES) + + # -- nav widget (table for types/docs, list for entries) -- + + def _nav(self): + """The nav widget backing the current level.""" + if self.level in self.TABLE_LEVELS: + return self.query_one("#nav-table", DataTable) + return self.query_one("#nav-list", OptionList) + + def _preview_visible(self): + """Entries always show the preview; table levels only with the 'i' toggle on.""" + return self.level == self.LEVEL_ENTRIES or self._info_open + + def _sync_panes(self): + """Show the right widget(s) for the current level/toggles and size the nav pane. + + Table levels default to a full-width table with no preview; the entry level + always shows the preview (it's the reading pane, not a duplicate of the row). + """ + use_table = self.level in self.TABLE_LEVELS + table = self.query_one("#nav-table", DataTable) + lst = self.query_one("#nav-list", OptionList) + table.display = use_table and not self._nav_hidden + lst.display = (not use_table) and not self._nav_hidden + preview_on = self._preview_visible() + self.query_one("#preview", VerticalScroll).display = preview_on + nav = table if use_table else lst + nav.set_class(not preview_on, "-full-width") + nav.styles.width = f"{self.LEVEL_WIDTH[self.level]}%" if preview_on else "100%" + + def _ensure_columns(self): + """(Re)build #nav-table's columns when the level's column set changes.""" + if self.level not in self.TABLE_LEVELS: + return + if self._table_columns_for == self.level: + return + table = self.query_one("#nav-table", DataTable) + table.clear(columns=True) + for label, width in self.LEVEL_COLUMNS[self.level]: + table.add_column(label, width=width) + self._table_columns_for = self.level + + # -- level loading -- + + def show_level(self, level, *, preserve_filter=False): + self.level = level + self._sync_panes() + self.refresh_bindings() + nav = self._nav() + if not preserve_filter: + self.query_one("#filter", Input).value = "" + nav.set_loading(True) + self.query_one("#body-md", Markdown).display = False + self.query_one("#meta", Static).update("") + if level == self.LEVEL_TYPES: + self._load_types() + elif level == self.LEVEL_DOCS: + self._load_docs(self.sel_type.id) + else: + self._load_entries(self.sel_doc.id) + + @work(thread=True, exclusive=True, group="fetch") + def _load_types(self): + try: + items = self.data.logbook_types() + except Exception as e: + self.app.call_from_thread(self._fetch_failed, str(e)) + return + self.app.call_from_thread(self._populate, items) + + @work(thread=True, exclusive=True, group="fetch") + def _load_docs(self, type_id): + try: + items = self.data.documents(type_id, self.limit) + except Exception as e: + self.app.call_from_thread(self._fetch_failed, str(e)) + return + self.app.call_from_thread(self._populate, items) + + @work(thread=True, exclusive=True, group="fetch") + def _load_entries(self, doc_id): + try: + items = self.data.entries(doc_id) + except Exception as e: + self.app.call_from_thread(self._fetch_failed, str(e)) + return + self.app.call_from_thread(self._populate, items) + + def _fetch_failed(self, message): + self._nav().set_loading(False) + self.notify(f"Fetch failed: {message}", severity="error", timeout=6) + if self.level == self.LEVEL_DOCS: + self.level = self.LEVEL_TYPES + elif self.level == self.LEVEL_ENTRIES: + self.level = self.LEVEL_DOCS + self._sync_panes() + self.refresh_bindings() + self._update_breadcrumb() + + def _populate(self, items): + nav = self._nav() + nav.set_loading(False) + self.all_items = items + self._apply_filter("") + self._update_breadcrumb() + nav.focus() + + def _apply_filter(self, query): + row_fn = self.LEVEL_ROW_FN[self.level] + self.shown_items = filter_items( + self.all_items, query, + lambda it: " ".join(str(c) for c in row_fn(it)), + ) + if self.level in self.TABLE_LEVELS: + self._ensure_columns() + table = self.query_one("#nav-table", DataTable) + table.clear() + if self.shown_items: + for it in self.shown_items: + table.add_row(*row_fn(it)) + # DataTable.clear() leaves the cursor at (0, 0); if it was + # already there, RowHighlighted won't fire, so drive the + # initial preview explicitly instead of relying on it. + self._render_meta(self.shown_items[0]) + self.query_one("#body-md", Markdown).display = False + else: + self.query_one("#meta", Static).update("(no matches)") + self.query_one("#body-md", Markdown).display = False + else: + nav = self.query_one("#nav-list", OptionList) + nav.clear_options() + if self.shown_items: + nav.add_options([row_fn(it)[0] for it in self.shown_items]) + nav.highlighted = 0 + else: + self.query_one("#meta", Static).update("(no matches)") + self.query_one("#body-md", Markdown).display = False + + def _update_breadcrumb(self): + parts = ["BELY"] + if self.sel_type is not None: + parts.append(format_type(self.sel_type)) + if self.sel_doc is not None: + parts.append(format_doc(self.sel_doc)) + if self.level == self.LEVEL_ENTRIES: + parts.append("entries") + self.query_one("#breadcrumb", Static).update(" › ".join(parts)) + + # -- preview -- + + async def on_option_list_option_highlighted(self, event): + if event.option_list.id != "nav-list": + return + item = self.shown_items[event.option_index] + await self._show_preview(item) + + async def on_data_table_row_highlighted(self, event): + if event.data_table.id != "nav-table": + return + if event.cursor_row >= len(self.shown_items): + return + item = self.shown_items[event.cursor_row] + await self._show_preview(item) + + def _render_meta(self, item): + """Sync metadata render for the current level (types/docs have no async work).""" + meta = self.query_one("#meta", Static) + if self.level == self.LEVEL_TYPES: + meta.update(_rows_table(type_metadata_rows(item))) + elif self.level == self.LEVEL_DOCS: + meta.update(_rows_table(doc_metadata_rows(item))) + else: + meta.update(_rows_table(entry_metadata_rows(item, self.sel_doc))) + + async def _show_preview(self, item): + self._render_meta(item) + body_md = self.query_one("#body-md", Markdown) + if self.level == self.LEVEL_ENTRIES: + body_md.display = True + await body_md.update(item.log_entry or "") + self._load_attachments(item) + else: + body_md.display = False + + def _load_attachments(self, entry): + key = (self.sel_doc.id, entry.log_id) + self._entry_key = key + self._fetch_attachments(self.sel_doc.id, entry.log_id, entry, key) + + @work(thread=True, exclusive=True, group="attachments") + def _fetch_attachments(self, doc_id, log_id, entry, key): + try: + attachments = self.data.attachments(doc_id, log_id) + except Exception: + attachments = [] + self.app.call_from_thread(self._apply_attachments, key, entry, attachments) + + def _apply_attachments(self, key, entry, attachments): + if key != self._entry_key or not attachments: + return + meta = self.query_one("#meta", Static) + rows = entry_metadata_rows(entry, self.sel_doc) + rows.append(("attachments", "; ".join(format_attachment(a) for a in attachments))) + meta.update(_rows_table(rows)) + + # -- filter input -- + + def on_input_changed(self, event): + if event.input.id == "filter": + self._apply_filter(event.value) + + def on_input_submitted(self, event): + if event.input.id == "filter": + self._nav().focus() + + # -- selection / navigation -- + + def on_option_list_option_selected(self, event): + if event.option_list.id != "nav-list": + return + item = self.shown_items[event.option_index] + if self.level == self.LEVEL_TYPES: + self.sel_type = item + self.show_level(self.LEVEL_DOCS) + elif self.level == self.LEVEL_DOCS: + self.sel_doc = item + self.show_level(self.LEVEL_ENTRIES) + else: + self.app.exit((self.sel_doc, item)) + + def on_data_table_row_selected(self, event): + if event.data_table.id != "nav-table": + return + if event.cursor_row >= len(self.shown_items): + return + item = self.shown_items[event.cursor_row] + if self.level == self.LEVEL_TYPES: + self.sel_type = item + self.show_level(self.LEVEL_DOCS) + else: + self.sel_doc = item + self.show_level(self.LEVEL_ENTRIES) + + def action_back(self): + filter_input = self.query_one("#filter", Input) + if filter_input.has_focus: + self._nav().focus() + return + if self.level == self.LEVEL_ENTRIES: + self.sel_doc = None + self.show_level(self.LEVEL_DOCS) + elif self.level == self.LEVEL_DOCS: + self.sel_type = None + self.show_level(self.LEVEL_TYPES) + else: + self.app.exit(None) + + def action_quit_app(self): + self.app.exit(None) + + def action_focus_filter(self): + self.query_one("#filter", Input).focus() + + def action_toggle_full(self): + self._nav_hidden = not self._nav_hidden + self._sync_panes() + + def action_toggle_info(self): + self._info_open = not self._info_open + self._sync_panes() + + def check_action(self, action, parameters): + if action in self.ENTRY_ONLY_ACTIONS: + return self.level == self.LEVEL_ENTRIES or None + if action in self.TABLE_ONLY_ACTIONS: + return self.level in self.TABLE_LEVELS or None + return True + + def action_refresh_level(self): + if self.level == self.LEVEL_TYPES: + self.data.invalidate("types") + elif self.level == self.LEVEL_DOCS: + self.data.invalidate("docs", type_id=self.sel_type.id) + else: + self.data.invalidate("entries", doc_id=self.sel_doc.id) + self.show_level(self.level, preserve_filter=True) + + # -- entry actions -- + + def _current_entry(self): + if self.level != self.LEVEL_ENTRIES: + return None + nav = self.query_one("#nav-list", OptionList) + if nav.highlighted is None or not self.shown_items: + return None + return self.shown_items[nav.highlighted] + + def action_save_entry(self): + entry = self._current_entry() + if entry is None: + self.notify("Select an entry first.", severity="warning") + return + doc_name = getattr(self.sel_doc, "name", None) or str(self.sel_doc.id) + try: + path = write_entry_to_file(entry, doc_name, output_dir=None, fmt="text", quiet=True) + except Exception as e: + self.notify(f"Save failed: {e}", severity="error") + return + self.notify(f"Saved to {path}") + + def action_copy_reference(self): + entry = self._current_entry() + if entry is None: + self.notify("Select an entry first.", severity="warning") + return + ref = reference_command(self.sel_doc.id, entry.log_id) + self.app.copy_to_clipboard(ref) + self.notify(f"Copied: {ref}") + + def action_open_editor(self): + entry = self._current_entry() + if entry is None: + self.notify("Select an entry first.", severity="warning") + return + with self.app.suspend(): + open_in_editor(entry.log_entry or "") + self.notify("Back from editor (view-only; nothing was saved).") + + +class BelyTuiApp(App): + """Top-level app: pushes BrowseScreen and returns its exit result.""" + + TITLE = "BELY" + + CSS = """ + #breadcrumb { + height: 1; + background: $primary-darken-2; + color: $text; + padding: 0 1; + } + + #body { + height: 1fr; + } + + #nav-list, #nav-table { + border-right: solid $primary; + } + + #nav-list.-full-width, #nav-table.-full-width { + border-right: none; + } + + #preview { + width: 1fr; + padding: 0 1; + } + + #meta { + height: auto; + border-bottom: solid $primary-darken-1; + margin-bottom: 1; + } + + #filter-bar { + height: 1; + padding: 0 1; + } + + #filter-label { + width: auto; + padding-right: 1; + } + + #filter { + width: 1fr; + } + """ + + def __init__(self, data, limit=100): + super().__init__() + self.data = data + self.limit = limit + + def on_mount(self): + self.push_screen(BrowseScreen(self.data, self.limit)) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py new file mode 100644 index 000000000..d87e14d97 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py @@ -0,0 +1,77 @@ +"""LogbookData: API access + per-session caching for the TUI. + +No textual import here either — this is the data layer, independently +testable with a hand-rolled FakeApi (see test/test_tui_data.py), the same +style used by test/test_commands.py and test/test_entry.py. + +Caching rules (carried over from the original curses implementation): + - Results are cached per parent id/key, including empty lists. + - Failures are NOT cached, so a later visit retries the network call. + - `is None` (not falsiness) distinguishes "not fetched yet" from a + legitimately empty result. +""" + + +class LogbookData: + """Wraps logbook_api with the caching the TUI needs.""" + + def __init__(self, logbook_api): + self._logbook_api = logbook_api + + self._types = None + self._docs = {} # type_id -> list of ItemDomainLogbook + self._entries = {} # doc_id -> list of LogEntry + self._attachments = {} # (doc_id, log_id) -> list of LogEntryAttachment + + def logbook_types(self): + if self._types is None: + self._types = self._logbook_api.get_logbook_types() + return self._types + + def documents(self, type_id, limit): + docs = self._docs.get(type_id) + if docs is None: + docs = self._logbook_api.get_log_documents(logbook_type_id=type_id, limit=limit) + self._docs[type_id] = docs + return docs + + def entries(self, doc_id): + entries = self._entries.get(doc_id) + if entries is None: + entries = self._logbook_api.get_log_entries( + log_document_id=doc_id, load_replies=True, load_reactions=True) + self._entries[doc_id] = entries + return entries + + def attachments(self, doc_id, log_id): + key = (doc_id, log_id) + attachments = self._attachments.get(key) + if attachments is None: + attachments = self._logbook_api.get_log_entry_attachments( + log_document_id=doc_id, log_id=log_id) + self._attachments[key] = attachments + return attachments + + def invalidate(self, level, type_id=None, doc_id=None): + """Drop the cache for one level so the next fetch hits the network. + + level: "types", "docs", or "entries". type_id/doc_id narrow the + invalidation to a single key; omitted, the whole level is cleared. + """ + if level == "types": + self._types = None + elif level == "docs": + if type_id is None: + self._docs.clear() + else: + self._docs.pop(type_id, None) + elif level == "entries": + if doc_id is None: + self._entries.clear() + self._attachments.clear() + else: + self._entries.pop(doc_id, None) + for key in [k for k in self._attachments if k[0] == doc_id]: + del self._attachments[key] + else: + raise ValueError(f"unknown cache level: {level}") diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/format.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/format.py new file mode 100644 index 000000000..086a42886 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/format.py @@ -0,0 +1,234 @@ +"""Pure display helpers for the TUI: no textual/rich import here. + +Everything in this module is plain data-in, string/list-out logic so it can be +unit-tested with plain SimpleNamespace stand-ins for the API models (see +test/test_tui.py) without touching a terminal. +""" + + +# -- list-row formatting (used by both the old curses UI and the new +# Textual OptionList rows) -- + +def format_type(t): + """Display string for a logbook type (EntityType).""" + display = getattr(t, "display_name", None) or "" + name = getattr(t, "name", None) or "" + return f"{name} ({display})" if display else name + + +def format_doc(d): + """Display string for a log document (ItemDomainLogbook).""" + name = getattr(d, "name", None) or "(unnamed)" + desc = getattr(d, "description", None) + return f"{name} - {desc}" if desc else name + + +def format_entry(e): + """Display string for a log entry: date, author, first-line snippet. + + Mirrors the snippet logic used by cmd_list_entries (entry.py). + """ + dt = getattr(e, "entered_on_date_time", None) + date = dt.strftime("%Y-%m-%d %H:%M") if dt else "" + author = getattr(e, "entered_by_username", None) or "" + body = getattr(e, "log_entry", None) or "" + lines = [ln for ln in body.strip().splitlines() if ln.strip()] + snippet = lines[0] if lines else "" + if len(snippet) > 60: + snippet = snippet[:57] + "..." + return f"{date} {author:<16} {snippet}".rstrip() + + +def format_attachment(att): + """Display string for a LogEntryAttachment.""" + name = getattr(att, "original_filename", None) or "(unnamed)" + path = getattr(att, "download_path", None) + return f"{name} ({path})" if path else name + + +# -- table rows (DataTable columns for the types/docs nav levels) -- + +def _fmt_dt(dt): + return dt.strftime("%Y-%m-%d %H:%M") if dt else "" + + +def _named(items): + """['name', ...] for a list of objects exposing a .name attribute.""" + names = [getattr(it, "name", None) or "" for it in (items or [])] + return [n for n in names if n] + + +def _doc_systems(d): + """Comma-joined system names from an ItemDomainLogbook's item_type_list.""" + return ", ".join(_named(getattr(d, "item_type_list", None))) + + +def _doc_modified(d): + """Last-modified timestamp string from an ItemDomainLogbook's more_info.""" + more_info = getattr(d, "more_info", None) + if more_info is None: + return "" + return _fmt_dt(getattr(more_info, "last_modified_on_date_time", None)) + + +def _doc_owner(d): + """Owner username from an ItemDomainLogbook's more_info.""" + more_info = getattr(d, "more_info", None) + if more_info is None: + return "" + return getattr(more_info, "owner_username", None) or "" + + +TYPE_COLUMNS = [("Name", 24), ("Display", 24), ("Description", None)] + + +def type_row(t): + """DataTable row cells for a logbook type (EntityType).""" + name = getattr(t, "name", None) or "" + display = getattr(t, "display_name", None) or "" + description = getattr(t, "description", None) or "" + return (name, display, description) + + +DOC_COLUMNS = [ + ("Name", 32), ("Description", None), ("Systems", 20), ("Owner", 14), ("Modified", 16), +] + + +def doc_row(d): + """DataTable row cells for a log document (ItemDomainLogbook).""" + name = getattr(d, "name", None) or "(unnamed)" + description = getattr(d, "description", None) or "" + return (name, description, _doc_systems(d), _doc_owner(d), _doc_modified(d)) + + +def entry_row(e): + """Row cells for a log entry: kept 1-tuple since entries stay list-rendered.""" + return (format_entry(e),) + + +# -- filtering / navigation -- + +def filter_items(items, query, render_fn): + """Return items whose rendered string contains query (case-insensitive).""" + if not query: + return list(items) + q = query.lower() + return [it for it in items if q in render_fn(it).lower()] + + +# -- selection reference / reproduction command -- + +def entry_reference(doc, entry): + """Reference dict for the selected entry, for json/yaml output.""" + return { + "doc_id": getattr(doc, "id", None), + "doc_name": getattr(doc, "name", None), + "log_id": getattr(entry, "log_id", None), + } + + +def reference_command(doc_id, log_id): + """The ready-to-run command line for fetching the selected entry.""" + return f"bely-cli entry get -d {doc_id} --id {log_id}" + + +# -- metadata blocks for the preview pane -- + +def summarize_reactions(reactions): + """Aggregate a list of LogReaction into a compact "emoji count" string.""" + if not reactions: + return "" + counts = {} + order = [] + for r in reactions: + reaction = getattr(r, "reaction", None) + label = getattr(reaction, "emoji", None) or getattr(reaction, "name", None) or "?" + if label not in counts: + order.append(label) + counts[label] = counts.get(label, 0) + 1 + return " ".join(f"{label} {counts[label]}" for label in order) + + +def entry_metadata_rows(entry, doc): + """[(label, value)] metadata rows for the entry preview header.""" + rows = [ + ("log_id", str(getattr(entry, "log_id", "") or "")), + ("doc", getattr(doc, "name", None) or ""), + ] + + entered_by = getattr(entry, "entered_by_username", None) or "" + entered_at = _fmt_dt(getattr(entry, "entered_on_date_time", None)) + if entered_by or entered_at: + rows.append(("by", " ".join(v for v in (entered_by, entered_at) if v))) + + modified_by = getattr(entry, "last_modified_by_username", None) or "" + modified_at = _fmt_dt(getattr(entry, "last_modified_on_date_time", None)) + if modified_by or modified_at: + rows.append(("modified", " ".join(v for v in (modified_by, modified_at) if v))) + + replies = getattr(entry, "log_replies", None) or [] + if replies: + rows.append(("replies", str(len(replies)))) + + reactions = summarize_reactions(getattr(entry, "log_reactions", None)) + if reactions: + rows.append(("reactions", reactions)) + + return rows + + +def doc_metadata_rows(doc): + """[(label, value)] metadata rows for the document preview header.""" + rows = [("name", getattr(doc, "name", None) or "")] + + description = getattr(doc, "description", None) + if description: + rows.append(("description", description)) + + type_names = _named(getattr(doc, "entity_type_list", None)) + if type_names: + rows.append(("logbook types", ", ".join(type_names))) + + systems = _doc_systems(doc) + if systems: + rows.append(("systems", systems)) + + more_info = getattr(doc, "more_info", None) + if more_info is not None: + owner = getattr(more_info, "owner_username", None) + if owner: + rows.append(("owner", owner)) + created_by = getattr(more_info, "created_by_username", None) + created_at = _fmt_dt(getattr(more_info, "created_on_date_time", None)) + if created_by or created_at: + rows.append(("created", " ".join(v for v in (created_by, created_at) if v))) + modified_by = getattr(more_info, "last_modified_by_username", None) + modified_at = _fmt_dt(getattr(more_info, "last_modified_on_date_time", None)) + if modified_by or modified_at: + rows.append(("modified", " ".join(v for v in (modified_by, modified_at) if v))) + + lockout = getattr(doc, "log_lockout_hours", None) + if lockout: + rows.append(("lockout", f"{lockout}h")) + + return rows + + +def type_metadata_rows(t): + """[(label, value)] metadata rows for the logbook-type preview header.""" + rows = [("name", getattr(t, "name", None) or "")] + + display_name = getattr(t, "display_name", None) + if display_name: + rows.append(("display name", display_name)) + + long_display_name = getattr(t, "long_display_name", None) + if long_display_name and long_display_name != display_name: + rows.append(("long display name", long_display_name)) + + description = getattr(t, "description", None) + if description: + rows.append(("description", description)) + + return rows diff --git a/tools/developer_tools/bely-cli/test/test_tui.py b/tools/developer_tools/bely-cli/test/test_tui.py index 4a646aca3..960ba6ec2 100644 --- a/tools/developer_tools/bely-cli/test/test_tui.py +++ b/tools/developer_tools/bely-cli/test/test_tui.py @@ -2,25 +2,79 @@ import unittest from types import SimpleNamespace -from bely_cli import tui +from bely_cli.tui import format as fmt class FilterItemsTests(unittest.TestCase): def test_empty_query_returns_all(self): items = ["alpha", "beta", "gamma"] - self.assertEqual(tui.filter_items(items, "", lambda s: s), items) + self.assertEqual(fmt.filter_items(items, "", lambda s: s), items) def test_case_insensitive_substring(self): items = ["Shift Report", "Beam Study", "RF trip"] - result = tui.filter_items(items, "beam", lambda s: s) + result = fmt.filter_items(items, "beam", lambda s: s) self.assertEqual(result, ["Beam Study"]) def test_substring_anywhere(self): items = ["abc", "xbcx", "zzz"] - result = tui.filter_items(items, "bc", lambda s: s) + result = fmt.filter_items(items, "bc", lambda s: s) self.assertEqual(result, ["abc", "xbcx"]) +class TypeRowTests(unittest.TestCase): + def test_returns_name_display_description(self): + t = SimpleNamespace(name="ops", display_name="Ops", description="Operations log") + self.assertEqual(fmt.type_row(t), ("ops", "Ops", "Operations log")) + + def test_missing_fields_become_empty_strings(self): + t = SimpleNamespace(name="ops", display_name=None, description=None) + self.assertEqual(fmt.type_row(t), ("ops", "", "")) + + +class DocRowTests(unittest.TestCase): + def test_returns_name_description_systems_owner_modified(self): + more_info = SimpleNamespace( + last_modified_on_date_time=datetime.datetime(2026, 6, 19, 14, 30), + owner_username="alice", + ) + d = SimpleNamespace( + name="Shift Report", description="daily notes", + item_type_list=[SimpleNamespace(name="SR"), SimpleNamespace(name="software")], + more_info=more_info, + ) + self.assertEqual( + fmt.doc_row(d), + ("Shift Report", "daily notes", "SR, software", "alice", "2026-06-19 14:30"), + ) + + def test_none_more_info_and_item_type_list_do_not_raise(self): + d = SimpleNamespace(name=None, description=None, item_type_list=None, more_info=None) + self.assertEqual(fmt.doc_row(d), ("(unnamed)", "", "", "", "")) + + +class RowColumnArityTests(unittest.TestCase): + """Guards against a column being added to one side (row fn / COLUMNS) but not the other.""" + + def test_type_row_matches_type_columns(self): + t = SimpleNamespace(name="ops", display_name="Ops", description="Operations log") + self.assertEqual(len(fmt.type_row(t)), len(fmt.TYPE_COLUMNS)) + + def test_doc_row_matches_doc_columns(self): + d = SimpleNamespace(name=None, description=None, item_type_list=None, more_info=None) + self.assertEqual(len(fmt.doc_row(d)), len(fmt.DOC_COLUMNS)) + + +class FilterItemsWithRowFnTests(unittest.TestCase): + def test_matches_on_any_column(self): + items = [ + SimpleNamespace(name="ops", display_name="Ops", description="Operations"), + SimpleNamespace(name="controls", display_name="Controls", description="RF systems"), + ] + render = lambda it: " ".join(str(c) for c in fmt.type_row(it)) + result = fmt.filter_items(items, "rf", render) + self.assertEqual([r.name for r in result], ["controls"]) + + class FormatEntryTests(unittest.TestCase): def test_date_author_snippet(self): e = SimpleNamespace( @@ -28,7 +82,7 @@ def test_date_author_snippet(self): entered_by_username="alice", log_entry="First line\nSecond line", ) - out = tui.format_entry(e) + out = fmt.format_entry(e) self.assertIn("2026-06-19 14:30", out) self.assertIn("alice", out) self.assertIn("First line", out) @@ -40,7 +94,7 @@ def test_truncates_long_first_line(self): entered_by_username="bob", log_entry="x" * 100, ) - out = tui.format_entry(e) + out = fmt.format_entry(e) self.assertIn("...", out) def test_skips_blank_leading_lines(self): @@ -49,22 +103,7 @@ def test_skips_blank_leading_lines(self): entered_by_username="bob", log_entry="\n\n \nReal content", ) - self.assertIn("Real content", tui.format_entry(e)) - - -class StepIndexTests(unittest.TestCase): - def test_middle_moves(self): - self.assertEqual(tui.step_index(3, +1, 10), 4) - self.assertEqual(tui.step_index(3, -1, 10), 2) - - def test_clamp_low(self): - self.assertEqual(tui.step_index(0, -1, 10), 0) - - def test_clamp_high(self): - self.assertEqual(tui.step_index(9, +1, 10), 9) - - def test_empty_list(self): - self.assertEqual(tui.step_index(0, +1, 0), 0) + self.assertIn("Real content", fmt.format_entry(e)) class EntryReferenceTests(unittest.TestCase): @@ -72,10 +111,116 @@ def test_reference_fields(self): doc = SimpleNamespace(id=42, name="My Doc") entry = SimpleNamespace(log_id=99) self.assertEqual( - tui.entry_reference(doc, entry), + fmt.entry_reference(doc, entry), {"doc_id": 42, "doc_name": "My Doc", "log_id": 99}, ) +class ReferenceCommandTests(unittest.TestCase): + def test_uses_installed_command_name(self): + cmd = fmt.reference_command(42, 99) + self.assertEqual(cmd, "bely-cli entry get -d 42 --id 99") + self.assertNotIn(".py", cmd) + + +class SummarizeReactionsTests(unittest.TestCase): + def test_empty_or_none_returns_empty_string(self): + self.assertEqual(fmt.summarize_reactions(None), "") + self.assertEqual(fmt.summarize_reactions([]), "") + + def test_aggregates_by_emoji_preserving_first_seen_order(self): + reactions = [ + SimpleNamespace(reaction=SimpleNamespace(emoji="👍", name="thumbsup")), + SimpleNamespace(reaction=SimpleNamespace(emoji="🎉", name="tada")), + SimpleNamespace(reaction=SimpleNamespace(emoji="👍", name="thumbsup")), + ] + self.assertEqual(fmt.summarize_reactions(reactions), "👍 2 🎉 1") + + def test_falls_back_to_name_when_no_emoji(self): + reactions = [SimpleNamespace(reaction=SimpleNamespace(emoji=None, name="thumbsup"))] + self.assertEqual(fmt.summarize_reactions(reactions), "thumbsup 1") + + +class EntryMetadataRowsTests(unittest.TestCase): + def _entry(self, **overrides): + base = dict( + log_id=4821, + entered_by_username="alice", + entered_on_date_time=None, + last_modified_by_username=None, + last_modified_on_date_time=None, + log_replies=None, + log_reactions=None, + ) + base.update(overrides) + return SimpleNamespace(**base) + + def test_minimal_entry_has_log_id_and_doc(self): + doc = SimpleNamespace(id=1, name="Ops") + rows = fmt.entry_metadata_rows(self._entry(entered_by_username=None), doc) + labels = [label for label, _ in rows] + self.assertIn("log_id", labels) + self.assertIn("doc", labels) + self.assertNotIn("by", labels) + self.assertNotIn("replies", labels) + self.assertNotIn("reactions", labels) + + def test_replies_and_reactions_shown_when_present(self): + doc = SimpleNamespace(id=1, name="Ops") + entry = self._entry( + log_replies=[SimpleNamespace(), SimpleNamespace()], + log_reactions=[SimpleNamespace(reaction=SimpleNamespace(emoji="👍", name=None))], + ) + rows = dict(fmt.entry_metadata_rows(entry, doc)) + self.assertEqual(rows["replies"], "2") + self.assertEqual(rows["reactions"], "👍 1") + + +class DocMetadataRowsTests(unittest.TestCase): + def test_more_info_none_does_not_raise(self): + doc = SimpleNamespace( + name="Ops Log", description=None, entity_type_list=None, + item_type_list=None, more_info=None, log_lockout_hours=None, + ) + rows = dict(fmt.doc_metadata_rows(doc)) + self.assertEqual(rows["name"], "Ops Log") + self.assertNotIn("owner", rows) + self.assertNotIn("created", rows) + + def test_empty_lists_are_omitted(self): + doc = SimpleNamespace( + name="Ops Log", description=None, entity_type_list=[], + item_type_list=[], more_info=None, log_lockout_hours=None, + ) + rows = dict(fmt.doc_metadata_rows(doc)) + self.assertNotIn("logbook types", rows) + self.assertNotIn("systems", rows) + + def test_more_info_owner_and_lockout_surfaced(self): + more_info = SimpleNamespace( + owner_username="bob", created_by_username=None, + created_on_date_time=None, last_modified_by_username=None, + last_modified_on_date_time=None, + ) + doc = SimpleNamespace( + name="Ops Log", description="daily ops", entity_type_list=None, + item_type_list=None, more_info=more_info, log_lockout_hours=24, + ) + rows = dict(fmt.doc_metadata_rows(doc)) + self.assertEqual(rows["owner"], "bob") + self.assertEqual(rows["lockout"], "24h") + self.assertEqual(rows["description"], "daily ops") + + +class FormatAttachmentTests(unittest.TestCase): + def test_includes_path_when_present(self): + att = SimpleNamespace(original_filename="a.png", download_path="/x/a.png") + self.assertEqual(fmt.format_attachment(att), "a.png (/x/a.png)") + + def test_falls_back_to_unnamed(self): + att = SimpleNamespace(original_filename=None, download_path=None) + self.assertEqual(fmt.format_attachment(att), "(unnamed)") + + if __name__ == "__main__": unittest.main() diff --git a/tools/developer_tools/bely-cli/test/test_tui_app.py b/tools/developer_tools/bely-cli/test/test_tui_app.py new file mode 100644 index 000000000..b8a5feefc --- /dev/null +++ b/tools/developer_tools/bely-cli/test/test_tui_app.py @@ -0,0 +1,155 @@ +import unittest +from types import SimpleNamespace + +from textual.widgets import DataTable, Markdown, OptionList, Static + +from bely_cli.tui.app import BelyTuiApp, BrowseScreen +from bely_cli.tui.data import LogbookData + + +class FakeLogbookApi: + def get_logbook_types(self): + return [SimpleNamespace(id=1, name="ops", display_name="Ops")] + + def get_log_documents(self, logbook_type_id, limit): + return [SimpleNamespace( + id=10, name="Shift Report", description=None, entity_type_list=None, + item_type_list=None, more_info=None, log_lockout_hours=None, + )] + + def get_log_entries(self, log_document_id, load_replies, load_reactions): + return [SimpleNamespace( + log_id=100, entered_by_username="alice", entered_on_date_time=None, + last_modified_by_username=None, last_modified_on_date_time=None, + log_replies=None, log_reactions=None, log_entry="# Hello\n\nBody text", + )] + + def get_log_entry_attachments(self, log_document_id, log_id): + return [] + + +class TuiAppSmokeTests(unittest.IsolatedAsyncioTestCase): + async def test_browse_populates_list_and_drives_preview(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(data, limit=10) + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + table = screen.query_one("#nav-table", DataTable) + self.assertEqual(table.row_count, 1) + self.assertEqual(screen.shown_items[0].name, "ops") + # No info panel open yet: the table gets the full width, no preview. + self.assertFalse(screen.query_one("#preview").display) + self.assertEqual(table.styles.width.value, 100) + + await pilot.press("enter") # descend: type -> docs + await pilot.pause() + await pilot.pause() + self.assertEqual(screen.level, screen.LEVEL_DOCS) + self.assertEqual(table.row_count, 1) + self.assertEqual(screen.shown_items[0].name, "Shift Report") + self.assertFalse(screen.query_one("#preview").display) + + await pilot.press("enter") # descend: docs -> entries + await pilot.pause() + await pilot.pause() + self.assertEqual(screen.level, screen.LEVEL_ENTRIES) + nav = screen.query_one("#nav-list", OptionList) + self.assertEqual(nav.option_count, 1) + self.assertEqual(screen.shown_items[0].log_id, 100) + self.assertTrue(screen.query_one("#body-md", Markdown).display) + # Entries always show the preview, even though 'i' was never pressed. + self.assertTrue(screen.query_one("#preview").display) + + async def test_info_toggle_at_table_levels(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(data, limit=10) + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + table = screen.query_one("#nav-table", DataTable) + preview = screen.query_one("#preview") + self.assertFalse(preview.display) + # The meta content is kept current even while the panel is hidden. + self.assertTrue(str(screen.query_one("#meta", Static).render())) + + await pilot.press("i") + await pilot.pause() + self.assertTrue(preview.display) + self.assertEqual(table.styles.width.value, screen.LEVEL_WIDTH[screen.LEVEL_TYPES]) + + await pilot.press("i") + await pilot.pause() + self.assertFalse(preview.display) + self.assertEqual(table.styles.width.value, 100) + + async def test_f_key_is_a_no_op_at_table_levels(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(data, limit=10) + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + table = screen.query_one("#nav-table", DataTable) + + await pilot.press("f") + await pilot.pause() + self.assertTrue(table.display) + self.assertFalse(screen.query_one("#preview").display) + + async def test_filter_narrows_the_list(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(data, limit=10) + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + screen._apply_filter("nonexistent") + self.assertEqual(screen.shown_items, []) + screen._apply_filter("") + self.assertEqual(len(screen.shown_items), 1) + + async def test_filter_narrows_table_row_count(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(data, limit=10) + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + table = screen.query_one("#nav-table", DataTable) + self.assertEqual(table.row_count, 1) + screen._apply_filter("nonexistent") + self.assertEqual(table.row_count, 0) + screen._apply_filter("") + self.assertEqual(table.row_count, 1) + + async def test_footer_shortcuts_track_the_current_level(self): + # check_action() returns None to hide a binding from the Footer entirely + # (rather than showing it disabled), so only relevant keys ever appear. + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(data, limit=10) + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + + self.assertTrue(screen.check_action("toggle_info", ())) + self.assertTrue(screen.check_action("refresh_level", ())) + for action in screen.ENTRY_ONLY_ACTIONS: + self.assertIsNone(screen.check_action(action, ())) + + await pilot.press("enter") # type -> docs + await pilot.pause() + await pilot.press("enter") # docs -> entries + await pilot.pause() + await pilot.pause() + + self.assertIsNone(screen.check_action("toggle_info", ())) + for action in screen.ENTRY_ONLY_ACTIONS: + self.assertTrue(screen.check_action(action, ())) + + async def test_search_and_resize_bindings_are_gone(self): + keys = {b.key for b in BrowseScreen.BINDINGS} + self.assertNotIn("ctrl+s", keys) + self.assertNotIn("left_square_bracket", keys) + self.assertNotIn("right_square_bracket", keys) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/developer_tools/bely-cli/test/test_tui_data.py b/tools/developer_tools/bely-cli/test/test_tui_data.py new file mode 100644 index 000000000..ab57aea86 --- /dev/null +++ b/tools/developer_tools/bely-cli/test/test_tui_data.py @@ -0,0 +1,110 @@ +import unittest +from types import SimpleNamespace + +from bely_cli.tui.data import LogbookData + + +class FakeApi: + def __init__(self): + self.calls = [] + self.fail_next = False + + def get_logbook_types(self): + self.calls.append(("types",)) + if self.fail_next: + self.fail_next = False + raise RuntimeError("boom") + return [SimpleNamespace(id=1, name="ops")] + + def get_log_documents(self, logbook_type_id, limit): + self.calls.append(("docs", logbook_type_id, limit)) + if self.fail_next: + self.fail_next = False + raise RuntimeError("boom") + return [SimpleNamespace(id=10, name="Doc")] + + def get_log_entries(self, log_document_id, load_replies, load_reactions): + self.calls.append(("entries", log_document_id, load_replies, load_reactions)) + if self.fail_next: + self.fail_next = False + raise RuntimeError("boom") + return [SimpleNamespace(log_id=100)] + + def get_log_entry_attachments(self, log_document_id, log_id): + self.calls.append(("attachments", log_document_id, log_id)) + return [SimpleNamespace(original_filename="a.png")] + + +class LogbookDataCachingTests(unittest.TestCase): + def setUp(self): + self.api = FakeApi() + self.data = LogbookData(self.api) + + def test_types_fetched_once_then_cached(self): + self.data.logbook_types() + self.data.logbook_types() + self.assertEqual(self.api.calls.count(("types",)), 1) + + def test_docs_cached_per_type_id(self): + self.data.documents(1, 100) + self.data.documents(1, 100) + self.data.documents(2, 100) + self.assertEqual(self.api.calls.count(("docs", 1, 100)), 1) + self.assertEqual(self.api.calls.count(("docs", 2, 100)), 1) + + def test_entries_pass_load_replies_and_reactions(self): + self.data.entries(10) + self.assertIn(("entries", 10, True, True), self.api.calls) + + def test_entries_cached_per_doc_id(self): + self.data.entries(10) + self.data.entries(10) + self.assertEqual(self.api.calls.count(("entries", 10, True, True)), 1) + + def test_attachments_keyed_by_doc_and_log_id(self): + self.data.attachments(10, 100) + self.data.attachments(10, 100) + self.data.attachments(10, 200) + self.assertEqual(self.api.calls.count(("attachments", 10, 100)), 1) + self.assertEqual(self.api.calls.count(("attachments", 10, 200)), 1) + + def test_failures_are_not_cached(self): + self.api.fail_next = True + with self.assertRaises(RuntimeError): + self.data.logbook_types() + # second call retries the network instead of returning a cached failure + result = self.data.logbook_types() + self.assertEqual(result[0].name, "ops") + self.assertEqual(self.api.calls.count(("types",)), 2) + + def test_invalidate_types(self): + self.data.logbook_types() + self.data.invalidate("types") + self.data.logbook_types() + self.assertEqual(self.api.calls.count(("types",)), 2) + + def test_invalidate_docs_by_type_id(self): + self.data.documents(1, 100) + self.data.documents(2, 100) + self.data.invalidate("docs", type_id=1) + self.data.documents(1, 100) + self.data.documents(2, 100) + self.assertEqual(self.api.calls.count(("docs", 1, 100)), 2) + self.assertEqual(self.api.calls.count(("docs", 2, 100)), 1) + + def test_invalidate_entries_by_doc_id_also_drops_its_attachments(self): + self.data.entries(10) + self.data.attachments(10, 100) + self.data.invalidate("entries", doc_id=10) + self.data.entries(10) + self.data.attachments(10, 100) + self.assertEqual(self.api.calls.count(("entries", 10, True, True)), 2) + self.assertEqual(self.api.calls.count(("attachments", 10, 100)), 2) + + def test_invalidate_unknown_level_raises(self): + with self.assertRaises(ValueError): + self.data.invalidate("bogus") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/developer_tools/bely-cli/uv.lock b/tools/developer_tools/bely-cli/uv.lock index 5f1873928..7f98b57fe 100644 --- a/tools/developer_tools/bely-cli/uv.lock +++ b/tools/developer_tools/bely-cli/uv.lock @@ -39,6 +39,8 @@ dependencies = [ { name = "bely-api" }, { name = "click" }, { name = "pyyaml" }, + { name = "rich" }, + { name = "textual" }, ] [package.dev-dependencies] @@ -51,6 +53,8 @@ requires-dist = [ { name = "bely-api", path = "../python-client/dist/bely_api-2026.3.0.tar.gz" }, { name = "click", specifier = ">=8.1.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "rich", specifier = ">=13.7.0" }, + { name = "textual", specifier = ">=0.86.0" }, ] [package.metadata.requires-dev] @@ -107,6 +111,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + [[package]] name = "packaging" version = "26.3" @@ -116,6 +170,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "platformdirs" +version = "4.11.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/d7/e7bfbc86e9f99ff7807e24de7703f032e9c9ba80bb355cf26e0e9bc5a75e/platformdirs-4.11.3.tar.gz", hash = "sha256:66a73d38a849810252df809a3d8bcbda8e26f6c189920e7535ad608a48dbb5ab", size = 33050, upload-time = "2026-08-13T22:43:27.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/a9/c34aebedd3a4c9afe5101b1b8713710b3fec18087c8a36c35d2f909861bd/platformdirs-4.11.3-py3-none-any.whl", hash = "sha256:5ed065d443751de711da036041a7a214122efc4a4de393b3f4137ba5576540e7", size = 23491, upload-time = "2026-08-13T22:43:26.121Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -359,6 +422,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -368,6 +444,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -443,6 +536,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, ] +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + [[package]] name = "urllib3" version = "2.7.0" From c6b8c34c03721b44e2d926c7a632a6876d266098 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Wed, 19 Aug 2026 10:54:06 -0500 Subject: [PATCH 33/62] use uv to run tests --- tools/developer_tools/bely-cli/run_test.sh | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tools/developer_tools/bely-cli/run_test.sh b/tools/developer_tools/bely-cli/run_test.sh index ce6fdc9cd..ecabca983 100755 --- a/tools/developer_tools/bely-cli/run_test.sh +++ b/tools/developer_tools/bely-cli/run_test.sh @@ -1,17 +1,20 @@ #!/bin/bash set -e -PYTHON="${PYTHON:-python}" - # COMPONENT_DIR is set by the test harness; default to this script's dir so the # test is also runnable standalone. COMPONENT_DIR="${COMPONENT_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" +cd "$COMPONENT_DIR" + +# RUNNER lets a packaging harness drop back to a bare interpreter (RUNNER="") +# when it wants to exercise an already-installed bely-cli instead of the venv. +RUNNER="${RUNNER:-uv run}" # Unit tests (run from the project dir so unittest discovers test/). -(cd "$COMPONENT_DIR" && "$PYTHON" -m unittest) +$RUNNER python -m unittest # Smoke test: the published command loads and --format is wired per-command # (appended to a leaf command, not at the top level). -bely-cli -h > /dev/null -bely-cli doc list -h | grep -q -- --format -bely-cli tui lookup -h | grep -q -- --format +$RUNNER bely-cli -h > /dev/null +$RUNNER bely-cli doc list -h | grep -q -- --format +$RUNNER bely-cli tui lookup -h | grep -q -- --format From 6bb8700f5817e2bfbd1e5ad29fd59838d2a8efcc Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Wed, 19 Aug 2026 10:54:14 -0500 Subject: [PATCH 34/62] add claude.md --- tools/developer_tools/bely-cli/CLAUDE.md | 133 +++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tools/developer_tools/bely-cli/CLAUDE.md diff --git a/tools/developer_tools/bely-cli/CLAUDE.md b/tools/developer_tools/bely-cli/CLAUDE.md new file mode 100644 index 000000000..5ce61fc5a --- /dev/null +++ b/tools/developer_tools/bely-cli/CLAUDE.md @@ -0,0 +1,133 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +`bely-cli` is the command-line client for the BELY electronic logbook. It lives inside the +larger BELY repo (see the repo-root `CLAUDE.md` for the server/Java side) but is a +self-contained Python project with its own `pyproject.toml` and virtualenv. + +`README.md` in this directory is the user-facing reference for every command, option, key +binding, and configuration key — keep it in sync when changing the CLI surface. + +## Development commands + +```bash +uv sync # create/refresh .venv from pyproject.toml + uv.lock +uv run bely-cli -h # run the CLI from the working tree + +uv run python -m unittest # full suite (auto-discovers test/) +uv run python -m unittest test.test_tui # one module +uv run python -m unittest test.test_tui.FilterItemsTests.test_case_insensitive_substring +uv run pytest test/test_entry.py # pytest also works; unittest is what CI runs + +./run_test.sh # unit tests + a smoke test that bely-cli loads and that + # --format is wired on leaf commands (not the top level). + # Runs through `uv run` by default; set RUNNER="" to + # exercise an already-installed bely-cli instead. +``` + +`./bely-cli-test` is a convenience wrapper that presets `BELY_HOST` to the tinkerbox test +server before invoking the installed `bely-cli`. + +## Dependency on the generated API client + +`bely-api` is **not** on PyPI. `pyproject.toml` pins it to a local sdist: + +```toml +[tool.uv.sources] +bely-api = { path = "../python-client/dist/bely_api-2026.3.0.tar.gz" } +``` + +That tarball is produced from `../python-client` (`setup-api.py`, sources generated by +`generatePyClient.sh` from the running server's OpenAPI spec). When bumping the version, +update the version in `pyproject.toml` **and** `conda-recipe/meta.yaml`, then `uv sync`. + +The client exposes two importable names: `belyApi` (generated models/exceptions) and +`BelyApiFactory` (top-level module, not a package). Always construct clients through +`BelyApiFactory` rather than assembling `Configuration`/`ApiClient` by hand. + +## Packaging + +`conda-recipe/` builds the conda package (`meta.yaml` + `build.sh`, source path `./src`, +which is generated and gitignored). Both `pyproject.toml` and `conda-recipe/meta.yaml` +carry the version and the runtime dependency list — they must be updated together. + +## Architecture + +Layering is deliberate: `cli.py` is *only* the Click surface, and command +implementations never import Click. + +``` +cli.py Click groups/commands. Declares options, then delegates to a cmd_* function. +commands.py doc + config commands (cmd_new_doc, cmd_list_docs, cmd_*_config, lookups). +entry.py entry commands (cmd_add_entry, cmd_update_entry, cmd_list_entries, cmd_get_entry). +tui/ `bely-cli tui lookup` (see below). +auth.py host/username/password resolution, token cache, BelyApiFactory construction. +config.py settings.yaml read/write, path expansion, editor resolution. +common.py format-aware printing, file/stdin reading, $EDITOR round-trip, doc lookup. +``` + +Conventions that hold across all command modules: + +- **Errors are raised, not printed.** Command functions raise `ValueError`/`RuntimeError`; + `cli.main()` catches, prints `Error: ...` to stderr, and exits 1. Do not call + `sys.exit()` or print errors from a `cmd_*` function. +- **Every leaf command takes `fmt`** (`text`/`json`/`yaml`, from `FORMATS` in `common.py`). + Text output is printed inline as work happens; structured output is accumulated into a + `result` dict and emitted once at the end via `print_result`/`print_items`. Guard + human-readable prints with `if fmt == "text"`. +- **`--format` and `--no-prompt` are per-leaf-command**, applied by the `common_options` + decorator in `cli.py` — never hoisted to the group level. `run_test.sh` asserts this. +- **Non-interactive mode** is a module-level flag in `common.py` + (`set_no_prompt()` / `is_no_prompt()`), set by `--no-prompt` or automatically when + `--file=-`. Any code path that would prompt must check `is_no_prompt()` first and raise + instead. +- **Unauthenticated first, authenticated late.** Name→ID lookups and document resolution + use `auth.get_factory()`; only mutations enter the + `with auth.get_authenticated_factory() as f:` block. Validate inputs and read files + *before* any network call so bad input fails without touching the server. +- **`belyApi` / `BelyApiFactory` are imported lazily inside functions**, not at module + scope — importing the generated client costs ~1.8s and `--help` must not pay it. Same + reason `tui/__init__.py` defers importing `tui/app.py` (Textual) until `cmd_tui` runs. + +### Configuration resolution + +Host: `BELY_HOST` → `host` setting → error. Username: `BELY_USER` → `user` setting → +prompt. Password: `BELY_PASSWORD` → prompt. Editor: `EDITOR` → `editor` setting → `vi`. +Token: `token_path` setting → `/token`, cached at mode `0600` and reused; +a `401` on `test_authenticated()` deletes it and re-authenticates. + +`SETTINGS_FILE` is resolved **at import time** from `BELY_SETTINGS_FILE`, so tests that +change that env var must `importlib.reload(config)` (see `test/test_config.py`). +`load_settings()` merges an optional `setting_override_path` file on top of the base +settings (one level only, no chaining); `set_setting()` deliberately writes to the base +file only so overridden values are never baked back in. + +### TUI (`src/bely_cli/tui/`) + +Split three ways so that only one of the three needs a terminal to test: + +- `format.py` — pure data-in/string-out display helpers. No textual, no rich. +- `data.py` — `LogbookData`, the API + per-session cache. Caches per parent id *including + empty lists* (`is None` distinguishes "not fetched" from "empty"); failures are never + cached. `invalidate(level, ...)` backs the `r` key. +- `app.py` — the Textual app (`BelyTuiApp` → `BrowseScreen`). Every network call runs in a + `@work(thread=True)` method that hands results back via `self.app.call_from_thread(...)`, + because belyApi's HTTP calls are synchronous and would otherwise block the UI. + +`cmd_tui` refuses to run when stdin/stdout is not a tty. Anything the TUI writes to disk +must pass `quiet=True` (e.g. `write_entry_to_file`) — stray stdout corrupts the screen. + +Per-level key relevance lives in `BrowseScreen.ENTRY_ONLY_ACTIONS` / `TABLE_ONLY_ACTIONS` +plus `check_action()`, which returns `None` (hide the binding from the Footer) rather than +`False` (show it disabled) — `show_level()` calls `refresh_bindings()` on every level change +so the Footer re-evaluates and only ever shows keys that apply to the current level. + +### Testing style + +No network, no live server, no responses library: tests hand-roll `FakeApi` classes +returning `SimpleNamespace` stand-ins for API models, and patch +`auth.get_factory` / `auth.get_authenticated_factory` on the *command module* +(`patch.object(entry.auth, ...)`). Text output is asserted by capturing `redirect_stdout`. +The Textual app is tested with `unittest.IsolatedAsyncioTestCase` + `app.run_test()` +pilot, which needs no real terminal. From c9d0ff59613215eb2155cdb6b883fb0f45a16ebc Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Wed, 19 Aug 2026 12:33:26 -0500 Subject: [PATCH 35/62] Add a github workflow for bely-cli tests --- .github/workflows/test-bely-cli.yml | 46 +++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/test-bely-cli.yml diff --git a/.github/workflows/test-bely-cli.yml b/.github/workflows/test-bely-cli.yml new file mode 100644 index 000000000..b2e20177b --- /dev/null +++ b/.github/workflows/test-bely-cli.yml @@ -0,0 +1,46 @@ +name: Test BELY CLI + +on: + pull_request: + paths: + - 'tools/developer_tools/bely-cli/**' + - '.github/workflows/test-bely-cli.yml' + workflow_dispatch: + +jobs: + test: + name: Run BELY CLI Tests + runs-on: ubuntu-latest + + defaults: + run: + working-directory: tools/developer_tools/bely-cli + + strategy: + fail-fast: false + matrix: + python-version: ['3.10', '3.11', '3.12'] + + env: + UV_PYTHON: ${{ matrix.python-version }} + # pyproject.toml pins bely-api to a local sdist under ../python-client/dist, + # which is gitignored and so absent on a fresh checkout. The identical + # 2026.3.0 sdist is on PyPI (same sha256), so resolve just that one package + # from the registry. Job-level so `uv run` inside run_test.sh honours it too. + UV_NO_SOURCES_PACKAGE: bely-api + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-dependency-glob: tools/developer_tools/bely-cli/uv.lock + + - name: Install dependencies + run: uv sync + + - name: Run tests + run: ./run_test.sh From 3d407f6fd28643742911ddf2c410274336c9255b Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Wed, 19 Aug 2026 13:48:44 -0500 Subject: [PATCH 36/62] Expose the editing functionality of this CLI to the TUI as well. Add screens that allow adding a document adding/editing an entry. Login configuration, etc. --- tools/developer_tools/bely-cli/CLAUDE.md | 135 ++++- tools/developer_tools/bely-cli/README.md | 122 +++- tools/developer_tools/bely-cli/run_test.sh | 2 + .../bely-cli/src/bely_cli/auth.py | 57 +- .../bely-cli/src/bely_cli/cli.py | 17 +- .../bely-cli/src/bely_cli/commands.py | 137 +---- .../bely-cli/src/bely_cli/core.py | 220 +++++++ .../bely-cli/src/bely_cli/entry.py | 106 +--- .../bely-cli/src/bely_cli/tui/__init__.py | 20 +- .../bely-cli/src/bely_cli/tui/app.py | 564 ++++-------------- .../bely-cli/src/bely_cli/tui/data.py | 53 +- .../bely-cli/src/bely_cli/tui/format.py | 38 +- .../src/bely_cli/tui/screens/__init__.py | 23 + .../src/bely_cli/tui/screens/browse.py | 552 +++++++++++++++++ .../src/bely_cli/tui/screens/compose.py | 160 +++++ .../src/bely_cli/tui/screens/configscreen.py | 133 +++++ .../src/bely_cli/tui/screens/login.py | 63 ++ .../src/bely_cli/tui/screens/newdoc.py | 233 ++++++++ .../src/bely_cli/tui/screens/picker.py | 124 ++++ .../bely-cli/src/bely_cli/tui/session.py | 61 ++ .../bely-cli/test/test_auth.py | 159 +++++ .../bely-cli/test/test_core.py | 264 ++++++++ .../developer_tools/bely-cli/test/test_tui.py | 22 +- .../bely-cli/test/test_tui_app.py | 231 ++++++- .../bely-cli/test/test_tui_data.py | 141 +++++ .../bely-cli/test/test_tui_screens.py | 474 +++++++++++++++ 26 files changed, 3369 insertions(+), 742 deletions(-) create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/core.py create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/screens/__init__.py create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/screens/compose.py create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/screens/login.py create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/screens/newdoc.py create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/screens/picker.py create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/session.py create mode 100644 tools/developer_tools/bely-cli/test/test_auth.py create mode 100644 tools/developer_tools/bely-cli/test/test_core.py create mode 100644 tools/developer_tools/bely-cli/test/test_tui_screens.py diff --git a/tools/developer_tools/bely-cli/CLAUDE.md b/tools/developer_tools/bely-cli/CLAUDE.md index 5ce61fc5a..63a7ea842 100644 --- a/tools/developer_tools/bely-cli/CLAUDE.md +++ b/tools/developer_tools/bely-cli/CLAUDE.md @@ -31,7 +31,8 @@ server before invoking the installed `bely-cli`. ## Dependency on the generated API client -`bely-api` is **not** on PyPI. `pyproject.toml` pins it to a local sdist: +`bely-api` **is** published on PyPI, but `pyproject.toml` still pins it to a local sdist so +you can develop against an unpublished, freshly-regenerated client before it's released: ```toml [tool.uv.sources] @@ -39,8 +40,20 @@ bely-api = { path = "../python-client/dist/bely_api-2026.3.0.tar.gz" } ``` That tarball is produced from `../python-client` (`setup-api.py`, sources generated by -`generatePyClient.sh` from the running server's OpenAPI spec). When bumping the version, -update the version in `pyproject.toml` **and** `conda-recipe/meta.yaml`, then `uv sync`. +`generatePyClient.sh` from the running server's OpenAPI spec). It is gitignored, so a +fresh checkout of this repo does not have it — CI works around this (see below). + +When bumping the version: update the version in `pyproject.toml` **and** +`conda-recipe/meta.yaml`, `uv sync`, **and publish the new `bely-api` sdist to PyPI** +(CI resolves it from there, not from the local path). + +### CI + +`.github/workflows/test-bely-cli.yml` sets `UV_NO_SOURCES_PACKAGE: bely-api` at job level, +which makes `uv sync` and every `uv run` inside `run_test.sh` ignore the +`[tool.uv.sources]` local-path override for just that one package and resolve it from +PyPI instead — the published sdist is byte-identical to the local one (same sha256 as the +`uv.lock` entry), so this doesn't change what gets tested. The client exposes two importable names: `belyApi` (generated models/exceptions) and `BelyApiFactory` (top-level module, not a package). Always construct clients through @@ -61,12 +74,19 @@ implementations never import Click. cli.py Click groups/commands. Declares options, then delegates to a cmd_* function. commands.py doc + config commands (cmd_new_doc, cmd_list_docs, cmd_*_config, lookups). entry.py entry commands (cmd_add_entry, cmd_update_entry, cmd_list_entries, cmd_get_entry). -tui/ `bely-cli tui lookup` (see below). +core.py API operations shared by commands.py/entry.py and the TUI: no printing, no + prompting, no Click. Raises ValueError/RuntimeError like everything else here. +tui/ `bely-cli tui` (full app) and `bely-cli tui lookup` (see below). auth.py host/username/password resolution, token cache, BelyApiFactory construction. config.py settings.yaml read/write, path expansion, editor resolution. common.py format-aware printing, file/stdin reading, $EDITOR round-trip, doc lookup. ``` +`commands.py`/`entry.py` are deliberately thin: validate → call into `core.py` → print/prompt. +When adding a new API operation, put the actual API call in `core.py` (so the TUI can reuse +it) and keep the `cmd_*` function to CLI-specific concerns (prompting, `fmt` branching, +exact output strings — `test_commands.py`/`test_entry.py` assert on that stdout byte-for-byte). + Conventions that hold across all command modules: - **Errors are raised, not printed.** Command functions raise `ValueError`/`RuntimeError`; @@ -78,6 +98,12 @@ Conventions that hold across all command modules: human-readable prints with `if fmt == "text"`. - **`--format` and `--no-prompt` are per-leaf-command**, applied by the `common_options` decorator in `cli.py` — never hoisted to the group level. `run_test.sh` asserts this. + The one exception is `tui_group`: it's declared with `@cli.group("tui", + invoke_without_command=True)` and carries `common_options` plus `--limit` itself, because + bare `bely-cli tui` (no subcommand) *is* a leaf invocation — `ctx.invoked_subcommand is + None` is what triggers `cmd_tui(mode="app", ...)`. `tui lookup` is a normal leaf + subcommand underneath it. `run_test.sh` checks both: `--format` on `tui lookup -h` and + `--limit` on the bare `tui -h`. - **Non-interactive mode** is a module-level flag in `common.py` (`set_no_prompt()` / `is_no_prompt()`), set by `--no-prompt` or automatically when `--file=-`. Any code path that would prompt must check `is_no_prompt()` first and raise @@ -103,25 +129,86 @@ change that env var must `importlib.reload(config)` (see `test/test_config.py`). settings (one level only, no chaining); `set_setting()` deliberately writes to the base file only so overridden values are never baked back in. +`auth.py` splits the token dance into non-prompting primitives so the TUI can drive it +without stdin prompts, with `get_authenticated_factory()` (the CLI's context manager) built +on top of them rather than duplicating the logic: + +- `authenticated_factory_from_token()` — returns a factory authenticated with the cached + token, or `None` if there isn't one or the server rejects it (deleting the stale token + first). No prompting. +- `login(username, password)` — authenticates, caches the token, returns the authenticated + factory. Raises `ValueError` on bad credentials, `RuntimeError` otherwise — same messages + as before the split. +- `get_authenticated_factory()` — unchanged from the caller's perspective: tries + `authenticated_factory_from_token()` first, falls back to prompting + `login()` only if + that returns `None`. + +The TUI calls `authenticated_factory_from_token()` directly and only pushes its login modal +when that returns `None` — so a user who already ran an authenticated CLI command (or a +previous `tui` session) is never prompted again. + ### TUI (`src/bely_cli/tui/`) -Split three ways so that only one of the three needs a terminal to test: +Split so that most of it needs no terminal to test: - `format.py` — pure data-in/string-out display helpers. No textual, no rich. - `data.py` — `LogbookData`, the API + per-session cache. Caches per parent id *including empty lists* (`is None` distinguishes "not fetched" from "empty"); failures are never cached. `invalidate(level, ...)` backs the `r` key. -- `app.py` — the Textual app (`BelyTuiApp` → `BrowseScreen`). Every network call runs in a - `@work(thread=True)` method that hands results back via `self.app.call_from_thread(...)`, - because belyApi's HTTP calls are synchronous and would otherwise block the UI. - -`cmd_tui` refuses to run when stdin/stdout is not a tty. Anything the TUI writes to disk -must pass `quiet=True` (e.g. `write_entry_to_file`) — stray stdout corrupts the screen. - -Per-level key relevance lives in `BrowseScreen.ENTRY_ONLY_ACTIONS` / `TABLE_ONLY_ACTIONS` -plus `check_action()`, which returns `None` (hide the binding from the Footer) rather than -`False` (show it disabled) — `show_level()` calls `refresh_bindings()` on every level change -so the Footer re-evaluates and only ever shows keys that apply to the current level. +- `session.py` — `TuiSession`: the unauthenticated `factory` + `LogbookData`, plus a + lazily-populated authenticated factory. No Textual import — `try_token()`/`login(u, p)` + delegate to `auth.py`, `username()` wraps `auth.get_configured_username()`. Built once in + `cmd_tui` and handed to `BelyTuiApp`. +- `screens/` — one module per screen (`browse.py`, `newdoc.py`, `compose.py`, + `configscreen.py`, `login.py`, `picker.py`), each importing `core`/`config`/`common` + directly rather than going through the Click layer. There is no separate landing/menu + screen — `browse.py`'s `BrowseScreen` is the entry screen for both `tui` modes and takes + the `session` directly (like every other screen), not just its `data`. It's mode-aware: + `select_mode` picks lookup's select-and-exit contract vs. the full app's stay-and-preview + behavior, and `source` picks drilling in from logbook types vs. starting at the document + level with `core.recent_documents(...)`. Its `_exit_top()` quits when it's the bottom of + the screen stack (the landing case) and pops otherwise (e.g. a command-palette-pushed + "My documents" browse). +- `app.py` — `BelyTuiApp`, the shared CSS, `ensure_auth()` (see below), and + `get_system_commands()`. Pushes `BrowseScreen` directly on mount for both modes; + `mode="lookup"` sets `select_mode=True`. Everything that isn't tied to the current + logbook/document — Configuration, "My documents", logging in ahead of time, discarding + the cache — lives in the `ctrl+p` command palette via `get_system_commands()` rather than + on a menu screen. + +Two worker patterns coexist, chosen by whether the flow needs a modal: + +- **Plain fetches** (browsing, previews) use `@work(thread=True)` methods that hand results + back via `self.app.call_from_thread(...)`, because belyApi's HTTP calls are synchronous + and would otherwise block the UI. +- **Flows that need to interleave a network call with a modal screen** (saving an entry, + creating a document, logging in) use a plain async `@work` method that `await`s + `self.app.ensure_auth()` and `self.app.push_screen_wait(...)` directly, with the actual + (synchronous) API call wrapped in `await asyncio.to_thread(core.some_op, api, ...)` so it + doesn't block the event loop either. `BrowseScreen._run_compose`, `NewDocScreen._create`, + and `ComposeScreen._save` are the reference examples. Pushing `ComposeScreen` itself is + further factored into `compose.open_composer(app, doc, api, entry=None)`, which fetches a + fresh template when `entry` is `None` and returns the saved entry or `None` — the shared + helper behind `BrowseScreen`'s `n`/`u` keys and `NewDocScreen`'s post-create prompt so + neither duplicates the template-fetch/push/await dance. + +`ensure_auth()` (on `BelyTuiApp`) is the shared entry point for the second pattern: returns +the cached authenticated api if there is one; else tries `session.try_token()` off the event +loop; else pushes `LoginScreen` and calls `session.login(u, p)`, retrying on bad credentials. +Returns `None` if the user cancels the login modal, which every caller must check for before +proceeding. + +`cmd_tui` refuses to run when stdin/stdout is not a tty, and also under `--no-prompt` (a +modal-driven login can't run non-interactively). Anything the TUI writes to disk must pass +`quiet=True` (e.g. `write_entry_to_file`) — stray stdout corrupts the screen. + +Per-level key relevance in `BrowseScreen` lives in one table, `ACTION_LEVELS` (action name +-> the levels it's relevant at), read by `check_action()`, which returns `None` (hide the +binding from the Footer) rather than `False` (show it disabled) — `show_level()` calls +`refresh_bindings()` on every level change so the Footer re-evaluates and only ever shows +keys that apply to the current level. All three levels (logbook type / document / entry) +render as one `#nav-table` `DataTable`, so this gating and the filter/info-toggle plumbing +share a single code path instead of branching between a `DataTable` and an `OptionList`. ### Testing style @@ -130,4 +217,18 @@ returning `SimpleNamespace` stand-ins for API models, and patch `auth.get_factory` / `auth.get_authenticated_factory` on the *command module* (`patch.object(entry.auth, ...)`). Text output is asserted by capturing `redirect_stdout`. The Textual app is tested with `unittest.IsolatedAsyncioTestCase` + `app.run_test()` -pilot, which needs no real terminal. +pilot, which needs no real terminal. Fakes (`FakeApi`, `FakeSession`, `FakeFactory`, ...) +are hand-rolled per test file, not shared — copy and adjust rather than importing another +test module's fakes. + +Driving a modal screen (`LoginScreen`, `PickerScreen`, `ComposeScreen`, `NewDocScreen`, +`ConfigScreen`) from a test means calling `push_screen_wait` the same way production code +does — from inside a worker, not a bare `asyncio.create_task`: +`task = app.run_worker(app.push_screen_wait(...))`, then `result = await task.wait()` (or +`task.is_finished` to check without blocking). A test that pushes a screen whose actions +call `self.app.ensure_auth()` (e.g. `NewDocScreen`) needs a real +`BelyTuiApp(session, limit=..., mode=...)` as the host, not a bare `textual.app.App()` — a +`FakeSession` with `is_authenticated() == True` takes `ensure_auth()`'s fast path with no +login modal involved. Since `BelyTuiApp` now always boots through `BrowseScreen`, that +`FakeSession` also needs a `.data` (a real `LogbookData` over a fake logbook_api is +simplest) even in tests that only care about a modal pushed on top of it. diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index b897f115e..231510be2 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -116,26 +116,123 @@ List recent log documents you created, newest first. ### `tui` — interactive terminal UIs +#### `bely-cli tui` + +Launches the full interactive app, covering the same operations as the flag-driven +commands: browsing, creating documents, adding/updating entries, and viewing or editing +configuration. There's no separate menu screen — it opens straight on the list of +logbooks, the same drill-down `tui lookup` uses, just not read-only. + +```bash +bely-cli tui +bely-cli tui --limit 50 +``` + +| Option | Description | +|--------|-------------| +| `--limit INTEGER` | Recent documents to load per logbook (default: 100). | + +Browsing needs no authentication. `Enter` on an entry just keeps it in the preview +instead of exiting, and `Esc` at the logbook list quits (there's nothing above it to pop +back to). A few keys are available once you've drilled in: + +| Key | Level | Action | +|-----|-------|--------| +| `d` | Logbook, document | Create a new document — see "New document" below. Prefilled with the current logbook if you drilled into one. | +| `n` | Document, entry | Add a new entry to the current document. | +| `u` | Entry | Update the highlighted entry. | + +`n`/`u`/`d` all open a mutation flow (the entry composer, or the new-document form +below), which is the point where the app authenticates if it hasn't already (see +"Authentication" below). + +**Command palette** + +Press `ctrl+p` for everything that isn't tied to the current logbook/document — the +equivalent of Home's old menu items, plus what Textual provides by default: + +| Command | Action | +|---------|--------| +| Configuration | Opens the configuration dialog — equivalent of `config show` / `config set` / `config edit`. | +| My documents | Opens a browse starting at your recently modified documents — equivalent of `doc list`. `Esc` pops back to wherever you opened it from. | +| Log in | Authenticate now instead of waiting for the first mutation. | +| Refresh cache | Discard all cached logbook data so the next view re-fetches from the server. | +| Theme | Built-in: change the app's color theme for this session. | +| Quit | Built-in: exit the app. | + +**Entry composer** + +A Markdown-aware `TextArea` for the entry body, plus an optional attachment path: + +| Key | Action | +|-----|--------| +| `ctrl+s` | Save (and upload the attachment, if a path was entered). | +| `ctrl+e` | Suspend the TUI and open the buffer in `$EDITOR`; the edited text comes back into the `TextArea`. | +| `Esc` | Cancel; asks for confirmation first if the buffer has unsaved changes. | + +An empty new entry is skipped rather than saved, matching `entry add`'s behavior. + +**New document** + +Mirrors `doc new`: a name field, plus pickers for type, systems, and template. + +| Key | Action | +|-----|--------| +| `ctrl+t` | Pick the logbook type. | +| `ctrl+y` | Pick systems (multi-select — `space` toggles, `Enter` confirms). | +| `ctrl+m` | Pick a template, or "(no template)" to skip. | +| `ctrl+s` | Create the document. | +| `Esc` | Cancel. | + +After creating, it reproduces `doc new`'s post-create prompts: if the template already +generated an entry it offers to edit it, otherwise it offers to create one — both open the +same entry composer. + +**Configuration** + +Opened from the command palette. Mirrors `config show` / `config set` / `config edit`: +one input per setting, prefilled from `settings.yaml`, alongside a summary of the current +settings and any environment-variable overrides. + +| Key | Action | +|-----|--------| +| `ctrl+s` | Save changed fields (same effect as `config set FIELD VALUE`). | +| `ctrl+e` | Suspend the TUI and open the settings file in `$EDITOR`, then reload. | +| `r` | Reload from disk, discarding unsaved edits in the form. | +| `Esc` | Close the dialog. | + +A field whose effective value comes from an environment variable (`BELY_HOST`, `BELY_USER`, +`EDITOR`) shows that in its placeholder, and saving it warns that the env var will keep +overriding it. + +**Authentication** + +Browsing needs no login. The first time you add or update an entry, create a document, or +save a config change, the app looks for the token the CLI already caches (see +[Authentication](#authentication) above) and reuses it silently if it's valid — so if you've +already run an authenticated `bely-cli` command, or a previous `tui` session, you won't be +prompted again. Otherwise a login modal appears; a successful login is cached the same way +the CLI caches it, shared by later `bely-cli` commands and TUI sessions alike. + #### `bely-cli tui lookup` Interactively browse to find a log entry when you don't already know its document. The TUI (built on [Textual](https://textual.textualize.io/)) drills down through three levels — **logbook → recent documents → entries**. Browsing is read-only and needs no authentication. -The logbook and document levels render as full-width, aligned tables (rows stay in API order, -not sorted): +All three levels render as full-width, aligned tables (rows stay in API order, not sorted): | Level | Columns | |-------|---------| | Logbook | Name, Display, Description | | Document | Name, Description, Systems, Owner, Modified | +| Entry | Date, Author, Entry (a snippet of the first line) | -Press `i` at either of these levels to open a side info panel with a few extra fields for the -highlighted row (it splits the table's width; `i` again closes it). - -Entries stay a single-column list (date, author, and a snippet of the first line) with a preview -pane that's always shown alongside it — the entry body rendered as markdown (headings, lists, -tables, and syntax-highlighted code), since the list row itself is just a one-line snippet. +Press `i` at the logbook/document levels to open a side info panel with a few extra fields +for the highlighted row (it splits the table's width; `i` again closes it). Entries always +show a preview pane alongside the table — the entry body rendered as markdown (headings, +lists, tables, and syntax-highlighted code), since the row itself is just a one-line +snippet. ```bash bely-cli tui lookup @@ -160,14 +257,15 @@ on — `i` disappears once you drill into entries, and `s` / `y` / `e` / `f` onl | Key | Action | |-----|--------| | `Up` / `Down`, `PgUp` / `PgDn` | Move the highlight; the preview/info panel follows. | -| `/` | Focus the filter box and incrementally filter the current list (case-insensitive substring). | -| `Enter` | In the filter box, return focus to the list. Elsewhere, drill into the highlighted item, or select the entry at the entries level. | -| `Esc` / `Backspace` | Go back one level (from the list); quits from the logbook list. In the filter box, `Esc` returns focus to the list. | +| `/` | Reveal and focus the filter box; incrementally filters the current table (case-insensitive substring). It hides itself again once it loses focus with nothing typed. | +| `Enter` | In the filter box, return focus to the table. Elsewhere, drill into the highlighted row, or select the entry at the entries level. | +| `Esc` / `Backspace` | Go back one level (from the table); quits from the logbook list. In the filter box, `Esc` returns focus to the table. | +| `d` | Logbook/document levels only: create a new document (see `bely-cli tui`'s "New document" above) — a mutation, so this is where the app authenticates if it hasn't already. | | `s` | Entries level only: save the highlighted entry's markdown to a file in the current directory. | | `y` | Entries level only: copy a `bely-cli entry get` reference for the highlighted entry to the clipboard. | | `e` | Entries level only: open the highlighted entry in `$EDITOR` (view-only — nothing is sent back to the server). | | `i` | Logbook/document levels only: toggle the side info panel. | -| `f` | Entries level only: toggle the list to widen the preview pane. | +| `f` | Entries level only: toggle the table to widen the preview pane. | | `r` | Refresh the current level, bypassing the in-session cache. | | `q` | Quit without selecting. | diff --git a/tools/developer_tools/bely-cli/run_test.sh b/tools/developer_tools/bely-cli/run_test.sh index ecabca983..5e18ef6b8 100755 --- a/tools/developer_tools/bely-cli/run_test.sh +++ b/tools/developer_tools/bely-cli/run_test.sh @@ -18,3 +18,5 @@ $RUNNER python -m unittest $RUNNER bely-cli -h > /dev/null $RUNNER bely-cli doc list -h | grep -q -- --format $RUNNER bely-cli tui lookup -h | grep -q -- --format +# Bare `tui` is the one group that carries --limit/--format itself (see CLAUDE.md). +$RUNNER bely-cli tui -h | grep -q -- --limit diff --git a/tools/developer_tools/bely-cli/src/bely_cli/auth.py b/tools/developer_tools/bely-cli/src/bely_cli/auth.py index 8c8eed81a..14a83cebd 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/auth.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/auth.py @@ -92,11 +92,39 @@ def get_factory(): return BelyApiFactory(bely_url=get_host()) -def _login_and_cache(factory): - """Prompt for credentials, authenticate the factory, and persist the new token.""" +def authenticated_factory_from_token(): + """Return a factory authenticated with the cached token, or None. + + A cached token that the server rejects is deleted before returning None, + so the caller can fall back to an interactive/explicit login. Does not + prompt for anything. + """ + import belyApi + from BelyApiFactory import BelyApiFactory + + token = load_token() + if not token: + return None + + factory = BelyApiFactory(bely_url=get_host()) + factory.api_client.set_default_header(BelyApiFactory.HEADER_TOKEN_KEY, token) + try: + factory.test_authenticated() + except belyApi.exceptions.UnauthorizedException: + delete_token() + return None + return factory + + +def login(username, password): + """Authenticate with credentials, cache the resulting token, and return the factory. + + Raises ValueError on bad credentials, RuntimeError on any other failure. + """ import belyApi - username = get_username() - password = get_password(username) + from BelyApiFactory import BelyApiFactory + + factory = BelyApiFactory(bely_url=get_host()) try: factory.authenticate_user(username, password) except belyApi.exceptions.UnauthorizedException: @@ -104,6 +132,7 @@ def _login_and_cache(factory): except Exception as e: raise RuntimeError(f"Authentication failed: {e}") from e save_token(factory.get_authenticate_token()) + return factory @contextmanager @@ -118,21 +147,9 @@ def get_authenticated_factory(): 1. BELY_USER + BELY_PASSWORD env vars 2. Interactive prompt """ - import belyApi - from BelyApiFactory import BelyApiFactory - - factory = BelyApiFactory(bely_url=get_host()) - - token = load_token() - if token: - factory.api_client.set_default_header(BelyApiFactory.HEADER_TOKEN_KEY, token) - try: - factory.test_authenticated() - except belyApi.exceptions.UnauthorizedException: - delete_token() - factory.api_client.default_headers.pop(BelyApiFactory.HEADER_TOKEN_KEY, None) - _login_and_cache(factory) - else: - _login_and_cache(factory) + factory = authenticated_factory_from_token() + if factory is None: + username = get_username() + factory = login(username, get_password(username)) yield factory diff --git a/tools/developer_tools/bely-cli/src/bely_cli/cli.py b/tools/developer_tools/bely-cli/src/bely_cli/cli.py index 55ac066a6..4f8249d92 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/cli.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/cli.py @@ -153,10 +153,19 @@ def entry_get(output_format, **kwargs): # -- tui -- -@cli.group("tui") -def tui_group(): +# Bare `bely-cli tui` launches the full interactive app, so the group itself +# is a leaf invocation when no subcommand is given -- the one place +# --format/--no-prompt sit on a group rather than a leaf command. +@cli.group("tui", invoke_without_command=True) +@click.option("--limit", default=100, type=int, + help="Recent documents to load per logbook (default 100)") +@common_options +@click.pass_context +def tui_group(ctx, output_format, **kwargs): """Interactive terminal UIs.""" - pass + ctx.obj = {"output_format": output_format, **kwargs} + if ctx.invoked_subcommand is None: + cmd_tui(fmt=output_format, mode="app", **kwargs) @tui_group.command("lookup") @@ -165,7 +174,7 @@ def tui_group(): @common_options def tui_lookup(output_format, **kwargs): """Interactively browse logbooks -> documents -> entries to find a log entry.""" - cmd_tui(fmt=output_format, **kwargs) + cmd_tui(fmt=output_format, mode="lookup", **kwargs) # -- config -- diff --git a/tools/developer_tools/bely-cli/src/bely_cli/commands.py b/tools/developer_tools/bely-cli/src/bely_cli/commands.py index 28fbffa48..0da826a86 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/commands.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/commands.py @@ -2,89 +2,45 @@ from . import auth from . import config +from . import core from .common import find_logdoc, is_no_prompt, read_file_or_stdin, write_entry_to_file, open_in_editor, print_items, print_result +# Re-exported for backward compatibility: these used to live here and tests / +# callers may still import them from this module. +from .core import find_logbook_type, find_systems, find_template # noqa: F401 -ENV_VARS = ["BELY_HOST", "BELY_USER", "BELY_PASSWORD", "BELY_SETTINGS_FILE", "EDITOR"] +ENV_VARS = core.ENV_VARS def cmd_show_config(fmt="text"): """Show current configuration from settings file and environment.""" - settings = config.load_settings() - environment = {} - for var in ENV_VARS: - val = os.environ.get(var) - if val is not None: - environment[var] = "****" if "PASSWORD" in var else val + data = core.collect_config() if fmt != "text": - print_result( - { - "settings_file": config.SETTINGS_FILE, - "settings": settings, - "environment": environment, - }, - "", - fmt, - ) + print_result(data, "", fmt) return - print(f"Settings file: {config.SETTINGS_FILE}") - if settings: - for key, value in settings.items(): + print(f"Settings file: {data['settings_file']}") + if data["settings"]: + for key, value in data["settings"].items(): print(f" {key} = {value}") else: print(" (no settings)") print() print("Environment variables:") - if environment: - for var, display in environment.items(): + if data["environment"]: + for var, display in data["environment"].items(): print(f" {var} = {display}") else: print(" (none set)") -def find_logbook_type(logbook_api, name): - """Find a logbook type by name (case-insensitive). Raises ValueError if not found.""" - types = logbook_api.get_logbook_types() - for t in types: - if t.name and t.name.lower() == name.lower(): - return t - available = ", ".join(t.name for t in types if t.name) - raise ValueError(f"Unknown logbook type '{name}'. Available: {available}") - - -def find_systems(logbook_api, names_csv): - """Resolve comma-separated system names to IDs. Raises ValueError on unknown name.""" - all_systems = logbook_api.get_logbook_systems() - by_name = {s.name.lower(): s for s in all_systems} - ids = [] - for name in names_csv.split(","): - name = name.strip() - if name.lower() not in by_name: - available = ", ".join(s.name for s in all_systems) - raise ValueError(f"Unknown system '{name}'. Available: {available}") - ids.append(by_name[name.lower()].id) - return ids - - -def find_template(logbook_api, name): - """Find a template by name (case-insensitive). Raises ValueError if not found.""" - templates = logbook_api.get_logbook_templates() - for t in templates: - if t.name and t.name.lower() == name.lower(): - return t - available = ", ".join(t.name for t in templates if t.name) - raise ValueError(f"Unknown template '{name}'. Available: {available}") - def cmd_edit_config(): """Open the settings file in the user's editor.""" - config._ensure_config_dir() - if not os.path.exists(config.SETTINGS_FILE): - config.save_settings({}) + settings_file = core.ensure_settings_file() editor = config.get_editor() - os.execvp(editor, [editor, config.SETTINGS_FILE]) + os.execvp(editor, [editor, settings_file]) def cmd_set_config(field, value, fmt="text"): @@ -96,7 +52,6 @@ def cmd_set_config(field, value, fmt="text"): print_result({field: value}, f"Set {field} = {value}", fmt) - def cmd_new_doc(type_, name, file, template, systems, no_template, output_dir, list_options, fmt="text"): """Create a new log document, optionally adding a first log entry.""" @@ -136,31 +91,21 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, if not name: raise ValueError("name cannot be empty.") - logbook_type = find_logbook_type(logbook_api, type_) - system_id_list = find_systems(logbook_api, systems) if systems else None - template_id = find_template(logbook_api, template).id if template else None + logbook_type = core.find_logbook_type(logbook_api, type_) + system_id_list = core.find_systems(logbook_api, systems) if systems else None + template_id = core.find_template(logbook_api, template).id if template else None if find_logdoc(logbook_api, name): raise ValueError(f"A log document named '{name}' already exists") - - # Build document options - import belyApi - doc_opts = belyApi.LogDocumentOptions( - name=name, - logbook_type_id=logbook_type.id, - ) - if system_id_list: - doc_opts.system_id_list = system_id_list - if template_id: - doc_opts.template_id = template_id - if no_template: - doc_opts.skip_default_logbook_type_template = True - # Authenticate and create document result = {"id": None, "name": name} with auth.get_authenticated_factory() as auth_factory: logbook_api = auth_factory.get_logbook_api() - doc = logbook_api.create_logbook_document(log_document_options=doc_opts) + doc = core.create_document( + logbook_api, name, logbook_type.id, + system_id_list=system_id_list, template_id=template_id, + skip_default_template=no_template, + ) result["id"] = doc.id result["name"] = doc.name if fmt == "text": @@ -173,13 +118,8 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, entries = logbook_api.get_log_entries(log_document_id=doc.id) if content: - if entries: - entry = entries[0] - entry.log_entry = content - else: - entry = logbook_api.get_log_entry_template(log_document_id=doc.id) - entry.log_entry = content - entry = logbook_api.add_update_log_entry(log_entry=entry) + entry = entries[0] if entries else core.new_entry_template(logbook_api, doc.id) + entry = core.save_entry(logbook_api, entry, content) result["log_id"] = entry.log_id if fmt == "text": print(f"Log entry added, log_id={entry.log_id}") @@ -192,8 +132,7 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, if answer in ("y", "yes"): edited = open_in_editor(entry.log_entry or "") if edited != (entry.log_entry or ""): - entry.log_entry = edited - entry = logbook_api.add_update_log_entry(log_entry=entry) + entry = core.save_entry(logbook_api, entry, edited) print(f"Log entry updated, log_id={entry.log_id}") else: print("No changes made.") @@ -203,11 +142,10 @@ def cmd_new_doc(type_, name, file, template, systems, no_template, if fmt == "text": answer = input("Create a log entry? [y/N] ").strip().lower() if answer in ("y", "yes"): - entry = logbook_api.get_log_entry_template(log_document_id=doc.id) + entry = core.new_entry_template(logbook_api, doc.id) edited = open_in_editor(entry.log_entry or "") if edited.strip(): - entry.log_entry = edited - entry = logbook_api.add_update_log_entry(log_entry=entry) + entry = core.save_entry(logbook_api, entry, edited) result["log_id"] = entry.log_id print(f"Log entry added, log_id={entry.log_id}") else: @@ -224,19 +162,7 @@ def cmd_list_docs(limit, fmt="text"): raise ValueError("cannot determine username. Set BELY_USER or 'user' in settings.") factory = auth.get_factory() - users_api = factory.get_users_api() - try: - user_info = users_api.get_user_by_username(username=username) - except Exception as e: - raise RuntimeError(f"could not look up user '{username}': {e}") from e - - search_api = factory.get_search_api() - results = search_api.search_logbook(search_text="*", user_id=[user_info.id]) - - docs = results.document_results or [] - # Sort by last_modified_on descending - docs.sort(key=lambda d: d.last_modified_on or "", reverse=True) - docs = docs[:limit] + docs = core.recent_documents(factory, username, limit) if not docs: if fmt == "text": @@ -247,11 +173,12 @@ def cmd_list_docs(limit, fmt="text"): items = [] for d in docs: + modified = getattr(d.more_info, "last_modified_on_date_time", None) items.append({ - "id": d.object_id, - "name": d.object_name or "", + "id": d.id, + "name": d.name or "", "type": d.logbook_type or "", - "last_modified": d.last_modified_on.strftime("%Y-%m-%d %H:%M") if d.last_modified_on else "", + "last_modified": modified.strftime("%Y-%m-%d %H:%M") if modified else "", }) columns = [("id", "ID", 8), ("name", "Name", 50), ("type", "Type", 15), ("last_modified", "Last Modified", 20)] print_items(items, columns, fmt) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/core.py b/tools/developer_tools/bely-cli/src/bely_cli/core.py new file mode 100644 index 000000000..a4a5ce732 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/core.py @@ -0,0 +1,220 @@ +"""API operations shared by the Click commands and the TUI screens. + +Everything here takes an already-built `logbook_api` / `factory` (callers own +auth) and returns data or raises `ValueError`/`RuntimeError` — never prints, +never prompts, never imports Click or Textual. This is what lets the same +"add an entry" / "create a document" / "read config" logic be driven from a +`cmd_*` function (which prints) or a TUI screen (which renders widgets). + +`belyApi` is imported lazily inside the functions that need it, matching the +import-cost discipline used elsewhere in this package. +""" + +import os +from types import SimpleNamespace + +from . import config + + +ENV_VARS = ["BELY_HOST", "BELY_USER", "BELY_PASSWORD", "BELY_SETTINGS_FILE", "EDITOR"] + + +# -- logbook type / system / template lookups (name -> object/IDs) -- + +def find_logbook_type(logbook_api, name): + """Find a logbook type by name (case-insensitive). Raises ValueError if not found.""" + types = logbook_api.get_logbook_types() + for t in types: + if t.name and t.name.lower() == name.lower(): + return t + available = ", ".join(t.name for t in types if t.name) + raise ValueError(f"Unknown logbook type '{name}'. Available: {available}") + + +def find_systems(logbook_api, names_csv): + """Resolve comma-separated system names to IDs. Raises ValueError on unknown name.""" + all_systems = logbook_api.get_logbook_systems() + by_name = {s.name.lower(): s for s in all_systems} + ids = [] + for name in names_csv.split(","): + name = name.strip() + if name.lower() not in by_name: + available = ", ".join(s.name for s in all_systems) + raise ValueError(f"Unknown system '{name}'. Available: {available}") + ids.append(by_name[name.lower()].id) + return ids + + +def find_template(logbook_api, name): + """Find a template by name (case-insensitive). Raises ValueError if not found.""" + templates = logbook_api.get_logbook_templates() + for t in templates: + if t.name and t.name.lower() == name.lower(): + return t + available = ", ".join(t.name for t in templates if t.name) + raise ValueError(f"Unknown template '{name}'. Available: {available}") + + +# -- documents -- + +def resolve_doc(logbook_api, doc_name, doc_id): + """Resolve a document by name or ID. Raises ValueError on error.""" + from .common import find_logdoc + + if doc_name and doc_id: + raise ValueError("--doc-name and --doc-id are mutually exclusive.") + if not doc_name and not doc_id: + raise ValueError("--doc-name or --doc-id is required.") + if doc_id: + return SimpleNamespace(id=doc_id, name=f"id={doc_id}") + doc = find_logdoc(logbook_api, doc_name) + if not doc: + raise ValueError(f'log document "{doc_name}" not found.') + return doc + + +def create_document(logbook_api, name, logbook_type_id, system_id_list=None, + template_id=None, skip_default_template=False): + """Create a new log document and return it.""" + import belyApi + + doc_opts = belyApi.LogDocumentOptions(name=name, logbook_type_id=logbook_type_id) + if system_id_list: + doc_opts.system_id_list = system_id_list + if template_id: + doc_opts.template_id = template_id + if skip_default_template: + doc_opts.skip_default_logbook_type_template = True + return logbook_api.create_logbook_document(log_document_options=doc_opts) + + +def recent_documents(factory, username, limit): + """Return the user's most recently modified log documents, newest first. + + Returns objects shaped like log documents (id, name, description, + logbook_type, more_info.last_modified_on_date_time) so tui.format's + doc_row/doc_metadata_rows can render them like any other document. + """ + users_api = factory.get_users_api() + try: + user_info = users_api.get_user_by_username(username=username) + except Exception as e: + raise RuntimeError(f"could not look up user '{username}': {e}") from e + + search_api = factory.get_search_api() + results = search_api.search_logbook(search_text="*", user_id=[user_info.id]) + + docs = results.document_results or [] + docs.sort(key=lambda d: d.last_modified_on or "", reverse=True) + docs = docs[:limit] + + return [ + SimpleNamespace( + id=d.object_id, + name=d.object_name or "", + description=None, + logbook_type=d.logbook_type or "", + more_info=SimpleNamespace(last_modified_on_date_time=d.last_modified_on), + ) + for d in docs + ] + + +# -- entries -- + +def new_entry_template(logbook_api, doc_id): + """Return a blank/template entry for a document, ready to fill in and save.""" + return logbook_api.get_log_entry_template(log_document_id=doc_id) + + +def save_entry(logbook_api, entry, content): + """Set an entry's content and save it. Returns the saved entry.""" + entry.log_entry = content + return logbook_api.add_update_log_entry(log_entry=entry) + + +def find_entry(entries, log_id): + """Return the entry with this log_id, or None.""" + for e in entries: + if e.log_id == log_id: + return e + return None + + +def last_entry_by_user(entries, username): + """Return the most recent entry entered by username (case-insensitive), or None.""" + user_entries = [ + e for e in entries + if e.entered_by_username and e.entered_by_username.lower() == username.lower() + ] + return user_entries[-1] if user_entries else None + + +def entry_list_items(entries): + """Row dicts (log_id/date/author/snippet) for cmd_list_entries / the TUI list.""" + items = [] + for e in entries: + date = e.entered_on_date_time.strftime("%Y-%m-%d %H:%M") if e.entered_on_date_time else "" + snippet = (e.log_entry or "").strip().splitlines()[0] if e.log_entry else "" + if len(snippet) > 60: + snippet = snippet[:57] + "..." + items.append({ + "log_id": e.log_id, + "date": date, + "author": e.entered_by_username or "", + "snippet": snippet, + }) + return items + + +# -- attachments -- + +def validate_attachment_path(path): + """Expand and validate an attachment path. Raises ValueError if not a file.""" + path = os.path.expanduser(path) + if not os.path.isfile(path): + raise ValueError(f"attachment file not found: {path}") + return path + + +def upload_attachment(logbook_api, doc_id, log_id, path): + """Upload an attachment and return its details as a dict.""" + basename = os.path.basename(path) + att = logbook_api.upload_attachment( + log_document_id=doc_id, + log_id=log_id, + body=path, + append_reference=True, + file_name=basename, + ) + return { + "original_filename": att.original_filename, + "stored_filename": att.stored_filename, + "download_path": att.download_path, + "markdown_reference": att.markdown_reference, + } + + +# -- config -- + +def collect_config(): + """Return {settings_file, settings, environment} with passwords masked.""" + settings = config.load_settings() + environment = {} + for var in ENV_VARS: + val = os.environ.get(var) + if val is not None: + environment[var] = "****" if "PASSWORD" in var else val + return { + "settings_file": config.SETTINGS_FILE, + "settings": settings, + "environment": environment, + } + + +def ensure_settings_file(): + """Create the settings file (empty) if it doesn't exist yet. Returns its path.""" + config._ensure_config_dir() + if not os.path.exists(config.SETTINGS_FILE): + config.save_settings({}) + return config.SETTINGS_FILE diff --git a/tools/developer_tools/bely-cli/src/bely_cli/entry.py b/tools/developer_tools/bely-cli/src/bely_cli/entry.py index 5bae99640..b92ab0746 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/entry.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/entry.py @@ -1,22 +1,9 @@ -import os -from types import SimpleNamespace - from . import auth -from .common import find_logdoc, is_no_prompt, read_file_or_stdin, write_entry_to_file, open_in_editor, print_items, print_result - +from . import core +from .common import is_no_prompt, read_file_or_stdin, write_entry_to_file, open_in_editor, print_items, print_result -def resolve_doc(logbook_api, doc_name, doc_id): - """Resolve a document by name or ID. Raises ValueError on error.""" - if doc_name and doc_id: - raise ValueError("--doc-name and --doc-id are mutually exclusive.") - if not doc_name and not doc_id: - raise ValueError("--doc-name or --doc-id is required.") - if doc_id: - return SimpleNamespace(id=doc_id, name=f"id={doc_id}") - doc = find_logdoc(logbook_api, doc_name) - if not doc: - raise ValueError(f'log document "{doc_name}" not found.') - return doc +# Re-exported for backward compatibility: resolve_doc used to live here. +from .core import resolve_doc # noqa: F401 def upload_and_print_attachment(logbook_api, doc_id, log_id, path, fmt="text"): @@ -24,26 +11,13 @@ def upload_and_print_attachment(logbook_api, doc_id, log_id, path, fmt="text"): Prints the human-readable summary only for text format. """ - basename = os.path.basename(path) - att = logbook_api.upload_attachment( - log_document_id=doc_id, - log_id=log_id, - body=path, - append_reference=True, - file_name=basename, - ) - info = { - "original_filename": att.original_filename, - "stored_filename": att.stored_filename, - "download_path": att.download_path, - "markdown_reference": att.markdown_reference, - } + info = core.upload_attachment(logbook_api, doc_id, log_id, path) if fmt == "text": - print(f'Attachment "{basename}" uploaded') - print(f" original_filename: {att.original_filename}") - print(f" stored_filename: {att.stored_filename}") - print(f" download_path: {att.download_path}") - print(f" markdown_reference: {att.markdown_reference}") + print(f'Attachment "{info["original_filename"]}" uploaded') + print(f" original_filename: {info['original_filename']}") + print(f" stored_filename: {info['stored_filename']}") + print(f" download_path: {info['download_path']}") + print(f" markdown_reference: {info['markdown_reference']}") return info @@ -57,16 +31,14 @@ def cmd_update_entry(doc_name, doc_id, entry_id, file, text, add_attachment, fmt # Validate attachments and read content before any network calls if add_attachment: - add_attachment = os.path.expanduser(add_attachment) - if not os.path.isfile(add_attachment): - raise ValueError(f"attachment file not found: {add_attachment}") + add_attachment = core.validate_attachment_path(add_attachment) content = read_file_or_stdin(file) if file else text # Resolve document (unauthenticated) factory = auth.get_factory() logbook_api = factory.get_logbook_api() - doc = resolve_doc(logbook_api, doc_name, doc_id) + doc = core.resolve_doc(logbook_api, doc_name, doc_id) # Authenticate and find/update entry with auth.get_authenticated_factory() as auth_factory: @@ -74,32 +46,22 @@ def cmd_update_entry(doc_name, doc_id, entry_id, file, text, add_attachment, fmt entries = logbook_api.get_log_entries(log_document_id=doc.id) if entry_id: - # Find specific entry by log_id - entry = None - for e in entries: - if e.log_id == entry_id: - entry = e - break + entry = core.find_entry(entries, entry_id) if not entry: raise ValueError(f'entry with log_id={entry_id} not found in document "{doc.name}".') else: - # Find last entry by current user username = auth.get_username() if not username: raise ValueError("cannot determine username. Set BELY_USER or 'user' in settings.") - user_entries = [e for e in entries - if e.entered_by_username - and e.entered_by_username.lower() == username.lower()] - if not user_entries: + entry = core.last_entry_by_user(entries, username) + if not entry: raise ValueError(f'no entries by user "{username}" found in document "{doc.name}".') - entry = user_entries[-1] # Update entry content result = {"doc": doc.name, "log_id": entry.log_id, "status": None, "attachment": None} if content: - entry.log_entry = content - entry = logbook_api.add_update_log_entry(log_entry=entry) + entry = core.save_entry(logbook_api, entry, content) result["log_id"] = entry.log_id result["status"] = "updated" if fmt == "text": @@ -113,8 +75,7 @@ def cmd_update_entry(doc_name, doc_id, entry_id, file, text, add_attachment, fmt original = entry.log_entry or "" edited = open_in_editor(original) if edited != original: - entry.log_entry = edited - entry = logbook_api.add_update_log_entry(log_entry=entry) + entry = core.save_entry(logbook_api, entry, edited) result["log_id"] = entry.log_id result["status"] = "updated" if fmt == "text": @@ -136,29 +97,26 @@ def cmd_add_entry(doc_name, doc_id, file, text, add_attachment, fmt="text"): # Validate attachments and read content before any network calls if add_attachment: - add_attachment = os.path.expanduser(add_attachment) - if not os.path.isfile(add_attachment): - raise ValueError(f"attachment file not found: {add_attachment}") + add_attachment = core.validate_attachment_path(add_attachment) content = read_file_or_stdin(file) if file else text # Resolve document (unauthenticated) factory = auth.get_factory() logbook_api = factory.get_logbook_api() - doc = resolve_doc(logbook_api, doc_name, doc_id) + doc = core.resolve_doc(logbook_api, doc_name, doc_id) # Authenticate and create entry with auth.get_authenticated_factory() as auth_factory: logbook_api = auth_factory.get_logbook_api() - entry = logbook_api.get_log_entry_template(log_document_id=doc.id) + entry = core.new_entry_template(logbook_api, doc.id) result = {"doc": doc.name, "log_id": None, "status": None, "attachment": None} if use_editor: edited = open_in_editor(entry.log_entry or "") if edited.strip(): - entry.log_entry = edited - entry = logbook_api.add_update_log_entry(log_entry=entry) + entry = core.save_entry(logbook_api, entry, edited) result["log_id"] = entry.log_id result["status"] = "added" if fmt == "text": @@ -168,8 +126,7 @@ def cmd_add_entry(doc_name, doc_id, file, text, add_attachment, fmt="text"): if fmt == "text": print("Empty entry, skipped.") else: - entry.log_entry = content or "" - entry = logbook_api.add_update_log_entry(log_entry=entry) + entry = core.save_entry(logbook_api, entry, content or "") result["log_id"] = entry.log_id result["status"] = "added" if fmt == "text": @@ -187,7 +144,7 @@ def cmd_list_entries(doc_name, doc_id, fmt="text"): """List entries in a log document.""" factory = auth.get_factory() logbook_api = factory.get_logbook_api() - doc = resolve_doc(logbook_api, doc_name, doc_id) + doc = core.resolve_doc(logbook_api, doc_name, doc_id) entries = logbook_api.get_log_entries(log_document_id=doc.id) if not entries: @@ -197,18 +154,7 @@ def cmd_list_entries(doc_name, doc_id, fmt="text"): print_items([], [], fmt) return - items = [] - for e in entries: - date = e.entered_on_date_time.strftime("%Y-%m-%d %H:%M") if e.entered_on_date_time else "" - snippet = (e.log_entry or "").strip().splitlines()[0] if e.log_entry else "" - if len(snippet) > 60: - snippet = snippet[:57] + "..." - items.append({ - "log_id": e.log_id, - "date": date, - "author": e.entered_by_username or "", - "snippet": snippet, - }) + items = core.entry_list_items(entries) columns = [("log_id", "Log ID", 10), ("date", "Date", 18), ("author", "Author", 20), ("snippet", "Snippet", 0)] print_items(items, columns, fmt) @@ -218,14 +164,14 @@ def cmd_get_entry(doc_name, doc_id, entry_id, output_dir, fmt="text"): """Write the markdown of a log entry to a file (latest by default).""" factory = auth.get_factory() logbook_api = factory.get_logbook_api() - doc = resolve_doc(logbook_api, doc_name, doc_id) + doc = core.resolve_doc(logbook_api, doc_name, doc_id) entries = logbook_api.get_log_entries(log_document_id=doc.id) if not entries: raise ValueError(f'No entries found in document {doc.name}.') if entry_id: - entry = next((e for e in entries if e.log_id == entry_id), None) + entry = core.find_entry(entries, entry_id) if not entry: raise ValueError(f'entry with log_id={entry_id} not found in document {doc.name}.') else: diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py index c836fbc57..7aecc1893 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py @@ -1,30 +1,34 @@ -"""`bely-cli tui lookup`: interactive Textual browser for logbooks/entries. +"""`bely-cli tui` / `bely-cli tui lookup`: the Textual app entry point. -`.app` (Textual/rich) is imported lazily inside cmd_tui, not at module scope, -so `bely-cli --help` (which imports this package via cli.py) stays fast. +`.app` (Textual/rich) and `.session` are imported lazily inside cmd_tui, not +at module scope, so `bely-cli --help` (which imports this package via +cli.py) stays fast. """ from .. import auth -from ..common import print_result +from ..common import is_no_prompt, print_result from .data import LogbookData from .format import entry_reference __all__ = ["cmd_tui", "LogbookData"] -def cmd_tui(limit=100, fmt="text"): - """Interactively browse logbooks -> documents -> entries to find an entry.""" +def cmd_tui(limit=100, fmt="text", mode="app"): + """Launch the TUI: the full app (mode="app") or the browse-and-exit lookup.""" import sys if not sys.stdout.isatty() or not sys.stdin.isatty(): raise RuntimeError("the tui requires an interactive terminal.") + if is_no_prompt(): + raise RuntimeError("the tui cannot run with --no-prompt.") from .app import BelyTuiApp # lazy: keeps --help fast + from .session import TuiSession factory = auth.get_factory() - data = LogbookData(factory.get_logbook_api()) + session = TuiSession(factory) - result = BelyTuiApp(data, limit=limit).run() + result = BelyTuiApp(session, limit=limit, mode=mode).run() if not result: return doc, entry = result diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py index 34e008ad2..f82ee4648 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py @@ -1,469 +1,49 @@ -"""Textual application for `bely-cli tui lookup`. - -Master/detail browser: a nav widget on the left drills through logbook type -> -document -> entry. The logbook/document levels are full-width tables with an -optional side info panel ('i'); the entry level always shows a preview pane -(Rich metadata table + rendered markdown body) since the row itself is just a -one-line snippet. All network calls go through LogbookData inside -@work(thread=True) methods so the UI never blocks on belyApi's synchronous -HTTP calls. +"""Textual application for `bely-cli tui` / `bely-cli tui lookup`. + +`BelyTuiApp` owns the session (auth + cached data) and the shared CSS. It +pushes `BrowseScreen` directly on mount for both modes -- there is no +separate landing/menu screen. The only difference between modes is +`select_mode`: "lookup" exits with `(doc, entry)` on the original +select-and-exit contract `tui lookup` scripts depend on; "app" just browses +in place. + +Everything else the old Home menu offered (configuration, "my documents", +logging in ahead of time, discarding the cache) lives in the command palette +(`ctrl+p`), wired up via `get_system_commands()`. + +`ensure_auth()` is the shared auth gate every mutating screen goes through: +it tries the cached CLI token first (so a user who already ran an +authenticated CLI command never sees a login prompt), then falls back to +pushing LoginScreen and calling session.login(). Screens call it from an +async `@work` method (not `@work(thread=True)`) because it needs to await a +modal; the network calls inside it are pushed to a thread via +`asyncio.to_thread` so the event loop is never blocked. """ -from rich.table import Table -from textual import work -from textual.app import App -from textual.binding import Binding -from textual.containers import Horizontal, VerticalScroll -from textual.screen import Screen -from textual.widgets import DataTable, Footer, Input, Markdown, OptionList, Static - -from ..common import open_in_editor, write_entry_to_file -from .format import ( - DOC_COLUMNS, - TYPE_COLUMNS, - doc_metadata_rows, - doc_row, - entry_metadata_rows, - entry_row, - filter_items, - format_attachment, - format_doc, - format_type, - reference_command, - type_metadata_rows, - type_row, -) - - -def _rows_table(rows): - table = Table.grid(padding=(0, 1)) - table.add_column(style="bold cyan", no_wrap=True) - table.add_column(ratio=1) - for label, value in rows: - table.add_row(label, value) - return table - - -class BrowseScreen(Screen): - """Logbook type -> document -> entry drill-down with a live preview.""" - - LEVEL_TYPES, LEVEL_DOCS, LEVEL_ENTRIES = range(3) - - # Levels rendered as a DataTable; LEVEL_ENTRIES stays an OptionList. - TABLE_LEVELS = (LEVEL_TYPES, LEVEL_DOCS) - LEVEL_COLUMNS = {LEVEL_TYPES: TYPE_COLUMNS, LEVEL_DOCS: DOC_COLUMNS} - LEVEL_ROW_FN = {LEVEL_TYPES: type_row, LEVEL_DOCS: doc_row, LEVEL_ENTRIES: entry_row} - - # Per-level nav pane width (%), used whenever a preview/info panel is visible. - LEVEL_WIDTH = {LEVEL_TYPES: 42, LEVEL_DOCS: 60, LEVEL_ENTRIES: 42} - - # Actions that only make sense at one kind of level. check_action() below - # returns None for the rest, which hides the binding from the Footer - # entirely (rather than showing it disabled) so only relevant keys appear. - ENTRY_ONLY_ACTIONS = frozenset( - {"toggle_full", "save_entry", "copy_reference", "open_editor"} - ) - TABLE_ONLY_ACTIONS = frozenset({"toggle_info"}) - - BINDINGS = [ - Binding("escape", "back", "Back"), - Binding("backspace", "back", "Back", show=False), - Binding("q", "quit_app", "Quit"), - Binding("slash", "focus_filter", "Filter"), - Binding("f", "toggle_full", "Full"), - Binding("s", "save_entry", "Save"), - Binding("y", "copy_reference", "Copy ref"), - Binding("e", "open_editor", "Editor"), - Binding("r", "refresh_level", "Refresh"), - Binding("i", "toggle_info", "Info"), - ] - - def __init__(self, data, limit): - super().__init__() - self.data = data - self.limit = limit - self.level = self.LEVEL_TYPES - self.sel_type = None - self.sel_doc = None - self.all_items = [] - self.shown_items = [] - self._entry_key = None - self._nav_hidden = False - self._info_open = False - self._table_columns_for = None - - def compose(self): - yield Static(id="breadcrumb") - with Horizontal(id="body"): - yield DataTable(id="nav-table", cursor_type="row", zebra_stripes=True) - yield OptionList(id="nav-list") - with VerticalScroll(id="preview"): - yield Static(id="meta") - yield Markdown(id="body-md") - with Horizontal(id="filter-bar"): - yield Static("Filter:", id="filter-label") - yield Input(id="filter", placeholder="type to filter, / to focus") - yield Footer() - - def on_mount(self): - self.query_one("#body-md", Markdown).display = False - self.show_level(self.LEVEL_TYPES) - - # -- nav widget (table for types/docs, list for entries) -- - - def _nav(self): - """The nav widget backing the current level.""" - if self.level in self.TABLE_LEVELS: - return self.query_one("#nav-table", DataTable) - return self.query_one("#nav-list", OptionList) - - def _preview_visible(self): - """Entries always show the preview; table levels only with the 'i' toggle on.""" - return self.level == self.LEVEL_ENTRIES or self._info_open - - def _sync_panes(self): - """Show the right widget(s) for the current level/toggles and size the nav pane. - - Table levels default to a full-width table with no preview; the entry level - always shows the preview (it's the reading pane, not a duplicate of the row). - """ - use_table = self.level in self.TABLE_LEVELS - table = self.query_one("#nav-table", DataTable) - lst = self.query_one("#nav-list", OptionList) - table.display = use_table and not self._nav_hidden - lst.display = (not use_table) and not self._nav_hidden - preview_on = self._preview_visible() - self.query_one("#preview", VerticalScroll).display = preview_on - nav = table if use_table else lst - nav.set_class(not preview_on, "-full-width") - nav.styles.width = f"{self.LEVEL_WIDTH[self.level]}%" if preview_on else "100%" - - def _ensure_columns(self): - """(Re)build #nav-table's columns when the level's column set changes.""" - if self.level not in self.TABLE_LEVELS: - return - if self._table_columns_for == self.level: - return - table = self.query_one("#nav-table", DataTable) - table.clear(columns=True) - for label, width in self.LEVEL_COLUMNS[self.level]: - table.add_column(label, width=width) - self._table_columns_for = self.level - - # -- level loading -- - - def show_level(self, level, *, preserve_filter=False): - self.level = level - self._sync_panes() - self.refresh_bindings() - nav = self._nav() - if not preserve_filter: - self.query_one("#filter", Input).value = "" - nav.set_loading(True) - self.query_one("#body-md", Markdown).display = False - self.query_one("#meta", Static).update("") - if level == self.LEVEL_TYPES: - self._load_types() - elif level == self.LEVEL_DOCS: - self._load_docs(self.sel_type.id) - else: - self._load_entries(self.sel_doc.id) - - @work(thread=True, exclusive=True, group="fetch") - def _load_types(self): - try: - items = self.data.logbook_types() - except Exception as e: - self.app.call_from_thread(self._fetch_failed, str(e)) - return - self.app.call_from_thread(self._populate, items) - - @work(thread=True, exclusive=True, group="fetch") - def _load_docs(self, type_id): - try: - items = self.data.documents(type_id, self.limit) - except Exception as e: - self.app.call_from_thread(self._fetch_failed, str(e)) - return - self.app.call_from_thread(self._populate, items) - - @work(thread=True, exclusive=True, group="fetch") - def _load_entries(self, doc_id): - try: - items = self.data.entries(doc_id) - except Exception as e: - self.app.call_from_thread(self._fetch_failed, str(e)) - return - self.app.call_from_thread(self._populate, items) - - def _fetch_failed(self, message): - self._nav().set_loading(False) - self.notify(f"Fetch failed: {message}", severity="error", timeout=6) - if self.level == self.LEVEL_DOCS: - self.level = self.LEVEL_TYPES - elif self.level == self.LEVEL_ENTRIES: - self.level = self.LEVEL_DOCS - self._sync_panes() - self.refresh_bindings() - self._update_breadcrumb() - - def _populate(self, items): - nav = self._nav() - nav.set_loading(False) - self.all_items = items - self._apply_filter("") - self._update_breadcrumb() - nav.focus() - - def _apply_filter(self, query): - row_fn = self.LEVEL_ROW_FN[self.level] - self.shown_items = filter_items( - self.all_items, query, - lambda it: " ".join(str(c) for c in row_fn(it)), - ) - if self.level in self.TABLE_LEVELS: - self._ensure_columns() - table = self.query_one("#nav-table", DataTable) - table.clear() - if self.shown_items: - for it in self.shown_items: - table.add_row(*row_fn(it)) - # DataTable.clear() leaves the cursor at (0, 0); if it was - # already there, RowHighlighted won't fire, so drive the - # initial preview explicitly instead of relying on it. - self._render_meta(self.shown_items[0]) - self.query_one("#body-md", Markdown).display = False - else: - self.query_one("#meta", Static).update("(no matches)") - self.query_one("#body-md", Markdown).display = False - else: - nav = self.query_one("#nav-list", OptionList) - nav.clear_options() - if self.shown_items: - nav.add_options([row_fn(it)[0] for it in self.shown_items]) - nav.highlighted = 0 - else: - self.query_one("#meta", Static).update("(no matches)") - self.query_one("#body-md", Markdown).display = False - - def _update_breadcrumb(self): - parts = ["BELY"] - if self.sel_type is not None: - parts.append(format_type(self.sel_type)) - if self.sel_doc is not None: - parts.append(format_doc(self.sel_doc)) - if self.level == self.LEVEL_ENTRIES: - parts.append("entries") - self.query_one("#breadcrumb", Static).update(" › ".join(parts)) - - # -- preview -- - - async def on_option_list_option_highlighted(self, event): - if event.option_list.id != "nav-list": - return - item = self.shown_items[event.option_index] - await self._show_preview(item) +import asyncio - async def on_data_table_row_highlighted(self, event): - if event.data_table.id != "nav-table": - return - if event.cursor_row >= len(self.shown_items): - return - item = self.shown_items[event.cursor_row] - await self._show_preview(item) +from textual.app import App, SystemCommand - def _render_meta(self, item): - """Sync metadata render for the current level (types/docs have no async work).""" - meta = self.query_one("#meta", Static) - if self.level == self.LEVEL_TYPES: - meta.update(_rows_table(type_metadata_rows(item))) - elif self.level == self.LEVEL_DOCS: - meta.update(_rows_table(doc_metadata_rows(item))) - else: - meta.update(_rows_table(entry_metadata_rows(item, self.sel_doc))) - - async def _show_preview(self, item): - self._render_meta(item) - body_md = self.query_one("#body-md", Markdown) - if self.level == self.LEVEL_ENTRIES: - body_md.display = True - await body_md.update(item.log_entry or "") - self._load_attachments(item) - else: - body_md.display = False - - def _load_attachments(self, entry): - key = (self.sel_doc.id, entry.log_id) - self._entry_key = key - self._fetch_attachments(self.sel_doc.id, entry.log_id, entry, key) - - @work(thread=True, exclusive=True, group="attachments") - def _fetch_attachments(self, doc_id, log_id, entry, key): - try: - attachments = self.data.attachments(doc_id, log_id) - except Exception: - attachments = [] - self.app.call_from_thread(self._apply_attachments, key, entry, attachments) - - def _apply_attachments(self, key, entry, attachments): - if key != self._entry_key or not attachments: - return - meta = self.query_one("#meta", Static) - rows = entry_metadata_rows(entry, self.sel_doc) - rows.append(("attachments", "; ".join(format_attachment(a) for a in attachments))) - meta.update(_rows_table(rows)) - - # -- filter input -- - - def on_input_changed(self, event): - if event.input.id == "filter": - self._apply_filter(event.value) - - def on_input_submitted(self, event): - if event.input.id == "filter": - self._nav().focus() - - # -- selection / navigation -- - - def on_option_list_option_selected(self, event): - if event.option_list.id != "nav-list": - return - item = self.shown_items[event.option_index] - if self.level == self.LEVEL_TYPES: - self.sel_type = item - self.show_level(self.LEVEL_DOCS) - elif self.level == self.LEVEL_DOCS: - self.sel_doc = item - self.show_level(self.LEVEL_ENTRIES) - else: - self.app.exit((self.sel_doc, item)) - - def on_data_table_row_selected(self, event): - if event.data_table.id != "nav-table": - return - if event.cursor_row >= len(self.shown_items): - return - item = self.shown_items[event.cursor_row] - if self.level == self.LEVEL_TYPES: - self.sel_type = item - self.show_level(self.LEVEL_DOCS) - else: - self.sel_doc = item - self.show_level(self.LEVEL_ENTRIES) - - def action_back(self): - filter_input = self.query_one("#filter", Input) - if filter_input.has_focus: - self._nav().focus() - return - if self.level == self.LEVEL_ENTRIES: - self.sel_doc = None - self.show_level(self.LEVEL_DOCS) - elif self.level == self.LEVEL_DOCS: - self.sel_type = None - self.show_level(self.LEVEL_TYPES) - else: - self.app.exit(None) - - def action_quit_app(self): - self.app.exit(None) - - def action_focus_filter(self): - self.query_one("#filter", Input).focus() - - def action_toggle_full(self): - self._nav_hidden = not self._nav_hidden - self._sync_panes() - - def action_toggle_info(self): - self._info_open = not self._info_open - self._sync_panes() - - def check_action(self, action, parameters): - if action in self.ENTRY_ONLY_ACTIONS: - return self.level == self.LEVEL_ENTRIES or None - if action in self.TABLE_ONLY_ACTIONS: - return self.level in self.TABLE_LEVELS or None - return True - - def action_refresh_level(self): - if self.level == self.LEVEL_TYPES: - self.data.invalidate("types") - elif self.level == self.LEVEL_DOCS: - self.data.invalidate("docs", type_id=self.sel_type.id) - else: - self.data.invalidate("entries", doc_id=self.sel_doc.id) - self.show_level(self.level, preserve_filter=True) - - # -- entry actions -- - - def _current_entry(self): - if self.level != self.LEVEL_ENTRIES: - return None - nav = self.query_one("#nav-list", OptionList) - if nav.highlighted is None or not self.shown_items: - return None - return self.shown_items[nav.highlighted] - - def action_save_entry(self): - entry = self._current_entry() - if entry is None: - self.notify("Select an entry first.", severity="warning") - return - doc_name = getattr(self.sel_doc, "name", None) or str(self.sel_doc.id) - try: - path = write_entry_to_file(entry, doc_name, output_dir=None, fmt="text", quiet=True) - except Exception as e: - self.notify(f"Save failed: {e}", severity="error") - return - self.notify(f"Saved to {path}") - - def action_copy_reference(self): - entry = self._current_entry() - if entry is None: - self.notify("Select an entry first.", severity="warning") - return - ref = reference_command(self.sel_doc.id, entry.log_id) - self.app.copy_to_clipboard(ref) - self.notify(f"Copied: {ref}") - - def action_open_editor(self): - entry = self._current_entry() - if entry is None: - self.notify("Select an entry first.", severity="warning") - return - with self.app.suspend(): - open_in_editor(entry.log_entry or "") - self.notify("Back from editor (view-only; nothing was saved).") +from .screens.browse import BrowseScreen class BelyTuiApp(App): - """Top-level app: pushes BrowseScreen and returns its exit result.""" + """Top-level app: pushes Browse and returns its exit result.""" TITLE = "BELY" CSS = """ - #breadcrumb { - height: 1; - background: $primary-darken-2; - color: $text; - padding: 0 1; - } - #body { height: 1fr; } - #nav-list, #nav-table { - border-right: solid $primary; - } - - #nav-list.-full-width, #nav-table.-full-width { - border-right: none; + #nav-table { + border: round $primary; } #preview { width: 1fr; + border: round $primary-darken-1; padding: 0 1; } @@ -473,25 +53,101 @@ class BelyTuiApp(App): margin-bottom: 1; } - #filter-bar { + #status-bar { height: 1; padding: 0 1; } - #filter-label { - width: auto; - padding-right: 1; + #status-left { + width: 1fr; } #filter { width: 1fr; + margin: 0 1; + } + + #status-right { + width: auto; } """ - def __init__(self, data, limit=100): + def __init__(self, session, limit=100, mode="app"): super().__init__() - self.data = data + self.session = session self.limit = limit + self.mode = mode def on_mount(self): - self.push_screen(BrowseScreen(self.data, self.limit)) + self.theme = "textual-dark" + self.push_screen(BrowseScreen(self.session, self.limit, select_mode=(self.mode == "lookup"))) + + def get_system_commands(self, screen): + yield from super().get_system_commands(screen) + yield SystemCommand( + "Configuration", "View and edit bely-cli settings", self._cmd_config) + if isinstance(screen, BrowseScreen): + yield SystemCommand( + "My documents", "Browse your recently modified documents", self._cmd_recent) + yield SystemCommand( + "Refresh cache", "Discard all cached logbook data", self._cmd_refresh) + yield SystemCommand( + "Log in", "Authenticate now instead of at the first mutation", self._cmd_login) + + def _cmd_config(self): + from .screens.configscreen import ConfigScreen + + self.push_screen(ConfigScreen()) + + def _cmd_recent(self): + self.push_screen(BrowseScreen(self.session, self.limit, select_mode=False, source="recent")) + + def _cmd_refresh(self): + self.session.data.clear() + screen = self.screen + if isinstance(screen, BrowseScreen): + screen.show_level(screen.level, preserve_filter=True) + self.notify("Cache cleared.") + + def _cmd_login(self): + self.run_worker(self._do_login(), exclusive=True, group="login") + + async def _do_login(self): + api = await self.ensure_auth() + screen = self.screen + if isinstance(screen, BrowseScreen): + screen._update_auth_status() + if api is not None: + self.notify("Logged in.") + + async def ensure_auth(self): + """Return an authenticated logbook_api, or None if the user cancelled login. + + Safe to call repeatedly -- once authenticated for this session, later + calls just return the cached api without touching the network or UI. + """ + if self.session.is_authenticated(): + return self.session.authenticated_api() + + ok = await asyncio.to_thread(self.session.try_token) + if ok: + return self.session.authenticated_api() + + from .screens.login import LoginScreen + + prefill = self.session.username() or "" + while True: + credentials = await self.push_screen_wait(LoginScreen(prefill)) + if credentials is None: + return None + username, password = credentials + try: + await asyncio.to_thread(self.session.login, username, password) + except ValueError as e: + self.notify(str(e), severity="error") + prefill = username + continue + except RuntimeError as e: + self.notify(f"Login failed: {e}", severity="error") + return None + return self.session.authenticated_api() diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py index d87e14d97..45f10229f 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py @@ -11,6 +11,8 @@ legitimately empty result. """ +from .. import core + class LogbookData: """Wraps logbook_api with the caching the TUI needs.""" @@ -19,15 +21,40 @@ def __init__(self, logbook_api): self._logbook_api = logbook_api self._types = None + self._systems = None + self._templates = None self._docs = {} # type_id -> list of ItemDomainLogbook self._entries = {} # doc_id -> list of LogEntry self._attachments = {} # (doc_id, log_id) -> list of LogEntryAttachment + self._recent = {} # username -> list of recent documents def logbook_types(self): if self._types is None: self._types = self._logbook_api.get_logbook_types() return self._types + def logbook_systems(self): + if self._systems is None: + self._systems = self._logbook_api.get_logbook_systems() + return self._systems + + def logbook_templates(self): + if self._templates is None: + self._templates = self._logbook_api.get_logbook_templates() + return self._templates + + def recent_documents(self, factory, username, limit): + """The user's recently modified documents (see core.recent_documents). + + Cached per username; `factory` is only needed to actually fetch (it + isn't part of the cache key -- a session has exactly one factory). + """ + docs = self._recent.get(username) + if docs is None: + docs = core.recent_documents(factory, username, limit) + self._recent[username] = docs + return docs + def documents(self, type_id, limit): docs = self._docs.get(type_id) if docs is None: @@ -52,14 +79,19 @@ def attachments(self, doc_id, log_id): self._attachments[key] = attachments return attachments - def invalidate(self, level, type_id=None, doc_id=None): + def invalidate(self, level, type_id=None, doc_id=None, username=None): """Drop the cache for one level so the next fetch hits the network. - level: "types", "docs", or "entries". type_id/doc_id narrow the - invalidation to a single key; omitted, the whole level is cleared. + level: "types", "systems", "templates", "docs", "entries", or + "recent". type_id/doc_id/username narrow the invalidation to a + single key; omitted, the whole level is cleared. """ if level == "types": self._types = None + elif level == "systems": + self._systems = None + elif level == "templates": + self._templates = None elif level == "docs": if type_id is None: self._docs.clear() @@ -73,5 +105,20 @@ def invalidate(self, level, type_id=None, doc_id=None): self._entries.pop(doc_id, None) for key in [k for k in self._attachments if k[0] == doc_id]: del self._attachments[key] + elif level == "recent": + if username is None: + self._recent.clear() + else: + self._recent.pop(username, None) else: raise ValueError(f"unknown cache level: {level}") + + def clear(self): + """Discard every cache, forcing the next fetch at any level to hit the network.""" + self._types = None + self._systems = None + self._templates = None + self._docs.clear() + self._entries.clear() + self._attachments.clear() + self._recent.clear() diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/format.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/format.py index 086a42886..5944087ee 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/format.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/format.py @@ -23,22 +23,6 @@ def format_doc(d): return f"{name} - {desc}" if desc else name -def format_entry(e): - """Display string for a log entry: date, author, first-line snippet. - - Mirrors the snippet logic used by cmd_list_entries (entry.py). - """ - dt = getattr(e, "entered_on_date_time", None) - date = dt.strftime("%Y-%m-%d %H:%M") if dt else "" - author = getattr(e, "entered_by_username", None) or "" - body = getattr(e, "log_entry", None) or "" - lines = [ln for ln in body.strip().splitlines() if ln.strip()] - snippet = lines[0] if lines else "" - if len(snippet) > 60: - snippet = snippet[:57] + "..." - return f"{date} {author:<16} {snippet}".rstrip() - - def format_attachment(att): """Display string for a LogEntryAttachment.""" name = getattr(att, "original_filename", None) or "(unnamed)" @@ -102,9 +86,27 @@ def doc_row(d): return (name, description, _doc_systems(d), _doc_owner(d), _doc_modified(d)) +ENTRY_COLUMNS = [("Date", 16), ("Author", 16), ("Entry", None)] + + +def _entry_snippet(e): + """First non-blank line of the entry body, truncated to 60 chars. + + Mirrors the snippet logic used by cmd_list_entries (entry.py). + """ + body = getattr(e, "log_entry", None) or "" + lines = [ln for ln in body.strip().splitlines() if ln.strip()] + snippet = lines[0] if lines else "" + if len(snippet) > 60: + snippet = snippet[:57] + "..." + return snippet + + def entry_row(e): - """Row cells for a log entry: kept 1-tuple since entries stay list-rendered.""" - return (format_entry(e),) + """DataTable row cells for a log entry: date, author, first-line snippet.""" + date = _fmt_dt(getattr(e, "entered_on_date_time", None)) + author = getattr(e, "entered_by_username", None) or "" + return (date, author, _entry_snippet(e)) # -- filtering / navigation -- diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/__init__.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/__init__.py new file mode 100644 index 000000000..239a83705 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/__init__.py @@ -0,0 +1,23 @@ +"""Textual screens for `bely-cli tui`. + +Split one-screen-per-file so no single module balloons; each screen still +follows the layering discipline of the rest of the tui package (pure logic +lives in ..format / ..data / ..session, screens only render and dispatch). +""" + +from rich.table import Table + + +def rows_table(rows): + """A two-column Rich grid for [(label, value), ...] metadata rows. + + Shared by every screen that renders a metadata/summary block (browse's + preview pane, the new-document summary, the config screen). + """ + table = Table.grid(padding=(0, 1)) + table.add_column(style="bold cyan", no_wrap=True) + table.add_column(ratio=1) + for label, value in rows: + table.add_row(label, value) + return table + diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py new file mode 100644 index 000000000..0ea2f1ca0 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py @@ -0,0 +1,552 @@ +"""BrowseScreen: logbook type -> document -> entry drill-down with a live preview. + +This is the entry screen for both `bely-cli tui` modes -- BelyTuiApp pushes it +directly on mount, there is no separate landing/home screen: + - `bely-cli tui lookup` (select_mode=True, source="types"): the original + select-and-exit contract. Enter on an entry exits the app with + (doc, entry); escape at the top level exits with None. + - `bely-cli tui` (select_mode=False): Enter on an entry is a no-op (the + preview is already live); escape at the top level quits if this is the + bottom of the screen stack, otherwise pops back to whatever pushed this + screen (e.g. the "My documents" command from the command palette). + source="recent" starts at the document level with the current user's + recently modified documents (core.recent_documents) instead of drilling + in from logbook types. + +All three levels (types/docs/entries) render as one #nav-table DataTable, so +navigation, filtering, and the info/full-width toggles share a single code +path. All network calls go through LogbookData/`core` inside worker methods +so the UI never blocks on belyApi's synchronous HTTP calls: plain fetches use +`@work(thread=True)` + `call_from_thread`; the mutation keys ('n'/'u'/'d') use +a plain async `@work` so they can `await` the auth gate and a modal screen. +""" + +from textual import work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, VerticalScroll +from textual.screen import Screen +from textual.widgets import DataTable, Footer, Header, Input, Markdown, Static + +from . import rows_table +from ..format import ( + DOC_COLUMNS, + ENTRY_COLUMNS, + TYPE_COLUMNS, + doc_metadata_rows, + doc_row, + entry_metadata_rows, + entry_row, + filter_items, + format_attachment, + format_doc, + format_type, + reference_command, + type_metadata_rows, + type_row, +) + +LEVEL_TITLES = {0: "Logbooks", 1: "Documents", 2: "Entries"} + + +class BrowseScreen(Screen): + """Logbook type -> document -> entry drill-down with a live preview.""" + + LEVEL_TYPES, LEVEL_DOCS, LEVEL_ENTRIES = range(3) + + LEVEL_COLUMNS = {LEVEL_TYPES: TYPE_COLUMNS, LEVEL_DOCS: DOC_COLUMNS, LEVEL_ENTRIES: ENTRY_COLUMNS} + LEVEL_ROW_FN = {LEVEL_TYPES: type_row, LEVEL_DOCS: doc_row, LEVEL_ENTRIES: entry_row} + + # Per-level nav pane width (%), used whenever a preview/info panel is visible. + LEVEL_WIDTH = {LEVEL_TYPES: 42, LEVEL_DOCS: 60, LEVEL_ENTRIES: 42} + + # Which levels each action is relevant at. check_action() below returns + # None for the rest, which hides the binding from the Footer entirely + # (rather than showing it disabled) so only relevant keys ever appear. + ACTION_LEVELS = { + "toggle_full": (LEVEL_ENTRIES,), + "save_entry": (LEVEL_ENTRIES,), + "copy_reference": (LEVEL_ENTRIES,), + "open_editor": (LEVEL_ENTRIES,), + "update_entry": (LEVEL_ENTRIES,), + "new_entry": (LEVEL_DOCS, LEVEL_ENTRIES), + "new_doc": (LEVEL_TYPES, LEVEL_DOCS), + "toggle_info": (LEVEL_TYPES, LEVEL_DOCS), + } + + BINDINGS = [ + Binding("escape", "back", "Back"), + Binding("backspace", "back", "Back", show=False), + Binding("q", "quit_app", "Quit"), + Binding("slash", "focus_filter", "Filter"), + Binding("f", "toggle_full", "Full"), + Binding("s", "save_entry", "Save"), + Binding("y", "copy_reference", "Copy ref"), + Binding("e", "open_editor", "Editor"), + Binding("n", "new_entry", "New entry"), + Binding("u", "update_entry", "Update"), + Binding("d", "new_doc", "New doc"), + Binding("r", "refresh_level", "Refresh"), + Binding("i", "toggle_info", "Info"), + ] + + def __init__(self, session, limit, *, select_mode=True, source="types"): + super().__init__() + self.session = session + self.data = session.data + self.limit = limit + self.select_mode = select_mode + self.source = source + self.level = self.LEVEL_DOCS if source == "recent" else self.LEVEL_TYPES + self.sel_type = None + self.sel_doc = None + self.all_items = [] + self.shown_items = [] + self._entry_key = None + self._nav_hidden = False + self._info_open = False + self._table_columns_for = None + + def compose(self) -> ComposeResult: + yield Header() + with Horizontal(id="body"): + yield DataTable(id="nav-table", cursor_type="row", zebra_stripes=True) + with VerticalScroll(id="preview"): + yield Static(id="meta") + yield Markdown(id="body-md") + with Horizontal(id="status-bar"): + yield Static(id="status-left") + yield Input(id="filter", placeholder="type to filter") + yield Static(id="status-right") + yield Footer() + + def on_mount(self): + self.query_one("#body-md", Markdown).display = False + self.query_one("#filter", Input).display = False + self._update_auth_status() + self.show_level(self.level) + + def on_screen_resume(self): + self._update_auth_status() + + # -- nav widget -- + + def _nav(self): + return self.query_one("#nav-table", DataTable) + + def _preview_visible(self): + """Entries always show the preview; other levels only with the 'i' toggle on.""" + return self.level == self.LEVEL_ENTRIES or self._info_open + + def _sync_panes(self): + table = self._nav() + table.display = not self._nav_hidden + preview_on = self._preview_visible() + self.query_one("#preview", VerticalScroll).display = preview_on + table.styles.width = f"{self.LEVEL_WIDTH[self.level]}%" if preview_on else "100%" + table.border_title = LEVEL_TITLES[self.level] + self.query_one("#preview", VerticalScroll).border_title = ( + "Entry" if self.level == self.LEVEL_ENTRIES else "Details" + ) + + def _ensure_columns(self): + """(Re)build #nav-table's columns when the level's column set changes.""" + if self._table_columns_for == self.level: + return + table = self._nav() + table.clear(columns=True) + for label, width in self.LEVEL_COLUMNS[self.level]: + table.add_column(label, width=width) + self._table_columns_for = self.level + + # -- level loading -- + + def show_level(self, level, *, preserve_filter=False): + self.level = level + self._sync_panes() + self.refresh_bindings() + nav = self._nav() + if not preserve_filter: + filt = self.query_one("#filter", Input) + filt.value = "" + filt.display = False + nav.set_loading(True) + self.query_one("#body-md", Markdown).display = False + self.query_one("#meta", Static).update("") + if level == self.LEVEL_TYPES: + self._load_types() + elif level == self.LEVEL_DOCS: + if self.source == "recent": + self._load_recent_docs() + else: + self._load_docs(self.sel_type.id) + else: + self._load_entries(self.sel_doc.id) + + @work(thread=True, exclusive=True, group="fetch") + def _load_types(self): + try: + items = self.data.logbook_types() + except Exception as e: + self.app.call_from_thread(self._fetch_failed, str(e)) + return + self.app.call_from_thread(self._populate, items) + + @work(thread=True, exclusive=True, group="fetch") + def _load_docs(self, type_id): + try: + items = self.data.documents(type_id, self.limit) + except Exception as e: + self.app.call_from_thread(self._fetch_failed, str(e)) + return + self.app.call_from_thread(self._populate, items) + + @work(thread=True, exclusive=True, group="fetch") + def _load_recent_docs(self): + try: + username = self.session.username() + if not username: + raise RuntimeError("cannot determine username. Set BELY_USER or 'user' in settings.") + items = self.data.recent_documents(self.session.factory, username, self.limit) + except Exception as e: + self.app.call_from_thread(self._fetch_failed, str(e)) + return + self.app.call_from_thread(self._populate, items) + + @work(thread=True, exclusive=True, group="fetch") + def _load_entries(self, doc_id): + try: + items = self.data.entries(doc_id) + except Exception as e: + self.app.call_from_thread(self._fetch_failed, str(e)) + return + self.app.call_from_thread(self._populate, items) + + def _fetch_failed(self, message): + self._nav().set_loading(False) + self.notify(f"Fetch failed: {message}", severity="error", timeout=6) + if self.level == self.LEVEL_DOCS: + self.level = self.LEVEL_TYPES if self.source == "types" else self.LEVEL_DOCS + elif self.level == self.LEVEL_ENTRIES: + self.level = self.LEVEL_DOCS + self._sync_panes() + self.refresh_bindings() + self._update_header() + + def _populate(self, items): + nav = self._nav() + nav.set_loading(False) + self.all_items = items + self._apply_filter("") + self._update_header() + nav.focus() + + def _apply_filter(self, query): + row_fn = self.LEVEL_ROW_FN[self.level] + self.shown_items = filter_items( + self.all_items, query, + lambda it: " ".join(str(c) for c in row_fn(it)), + ) + self._ensure_columns() + table = self._nav() + table.clear() + self._update_status_left(query) + if self.shown_items: + for it in self.shown_items: + table.add_row(*row_fn(it)) + # DataTable.clear() leaves the cursor at (0, 0); if it was + # already there, RowHighlighted won't fire, so drive the + # initial preview explicitly instead of relying on it. + self.run_worker(self._show_preview(self.shown_items[0]), exclusive=True, group="preview") + else: + self.query_one("#meta", Static).update("(no matches)") + self.query_one("#body-md", Markdown).display = False + + def _update_status_left(self, query): + count = len(self.shown_items) + total = len(self.all_items) + noun = "row" if count == 1 else "rows" + text = f"{count} {noun}" if count == total else f'filter "{query}" -- {count} of {total}' + self.query_one("#status-left", Static).update(text) + + def _update_header(self): + parts = [] + if self.sel_type is not None: + parts.append(format_type(self.sel_type)) + if self.sel_doc is not None: + parts.append(format_doc(self.sel_doc)) + if self.level == self.LEVEL_ENTRIES: + parts.append("entries") + self.sub_title = " › ".join(parts) + + def _update_auth_status(self): + username = self.session.username() + if self.session.is_authenticated(): + status = f"{username or 'authenticated'} ●" + else: + status = f"{username} ○" if username else "no user configured" + self.query_one("#status-right", Static).update(status) + + # -- preview -- + + async def on_data_table_row_highlighted(self, event): + if event.data_table.id != "nav-table": + return + if event.cursor_row >= len(self.shown_items): + return + item = self.shown_items[event.cursor_row] + await self._show_preview(item) + + def _render_meta(self, item): + """Sync metadata render for the current level (types/docs have no async work).""" + meta = self.query_one("#meta", Static) + if self.level == self.LEVEL_TYPES: + meta.update(rows_table(type_metadata_rows(item))) + elif self.level == self.LEVEL_DOCS: + meta.update(rows_table(doc_metadata_rows(item))) + else: + meta.update(rows_table(entry_metadata_rows(item, self.sel_doc))) + + async def _show_preview(self, item): + self._render_meta(item) + body_md = self.query_one("#body-md", Markdown) + if self.level == self.LEVEL_ENTRIES: + body_md.display = True + await body_md.update(item.log_entry or "") + self._load_attachments(item) + else: + body_md.display = False + + def _load_attachments(self, entry): + key = (self.sel_doc.id, entry.log_id) + self._entry_key = key + self._fetch_attachments(self.sel_doc.id, entry.log_id, entry, key) + + @work(thread=True, exclusive=True, group="attachments") + def _fetch_attachments(self, doc_id, log_id, entry, key): + try: + attachments = self.data.attachments(doc_id, log_id) + except Exception: + attachments = [] + self.app.call_from_thread(self._apply_attachments, key, entry, attachments) + + def _apply_attachments(self, key, entry, attachments): + if key != self._entry_key or not attachments: + return + meta = self.query_one("#meta", Static) + rows = entry_metadata_rows(entry, self.sel_doc) + rows.append(("attachments", "; ".join(format_attachment(a) for a in attachments))) + meta.update(rows_table(rows)) + + # -- filter input -- + + def on_input_changed(self, event): + if event.input.id == "filter": + self._apply_filter(event.value) + + def on_input_submitted(self, event): + if event.input.id == "filter": + self._nav().focus() + event.input.display = bool(event.input.value) + + # -- selection / navigation -- + + def on_data_table_row_selected(self, event): + if event.data_table.id != "nav-table": + return + if event.cursor_row >= len(self.shown_items): + return + item = self.shown_items[event.cursor_row] + if self.level == self.LEVEL_TYPES: + self.sel_type = item + self.show_level(self.LEVEL_DOCS) + elif self.level == self.LEVEL_DOCS: + self.sel_doc = item + self.show_level(self.LEVEL_ENTRIES) + elif self.select_mode: + self.app.exit((self.sel_doc, item)) + # else: entry already selected is just the live preview; Enter is a no-op. + + def action_back(self): + filter_input = self.query_one("#filter", Input) + if filter_input.has_focus: + self._nav().focus() + filter_input.display = bool(filter_input.value) + return + if self.level == self.LEVEL_ENTRIES: + self.sel_doc = None + self.show_level(self.LEVEL_DOCS) + elif self.level == self.LEVEL_DOCS and self.source == "types": + self.sel_type = None + self.show_level(self.LEVEL_TYPES) + else: + self._exit_top() + + def _exit_top(self): + """Leave the screen from its top level: exit the app, or pop back to + whatever pushed this screen (e.g. a command-palette browse).""" + if self.select_mode or len(self.app.screen_stack) <= 1: + self.app.exit(None) + else: + self.app.pop_screen() + + def action_quit_app(self): + self.app.exit(None) + + def action_focus_filter(self): + filt = self.query_one("#filter", Input) + filt.display = True + filt.focus() + + def action_toggle_full(self): + self._nav_hidden = not self._nav_hidden + self._sync_panes() + + def action_toggle_info(self): + self._info_open = not self._info_open + self._sync_panes() + + def check_action(self, action, parameters): + levels = self.ACTION_LEVELS.get(action) + return True if levels is None else (self.level in levels or None) + + def action_refresh_level(self): + if self.level == self.LEVEL_TYPES: + self.data.invalidate("types") + elif self.level == self.LEVEL_DOCS: + if self.source == "recent": + self.data.invalidate("recent", username=self.session.username()) + else: + self.data.invalidate("docs", type_id=self.sel_type.id) + else: + self.data.invalidate("entries", doc_id=self.sel_doc.id) + self.show_level(self.level, preserve_filter=True) + + # -- current-selection helpers -- + + def _current_entry(self): + if self.level != self.LEVEL_ENTRIES: + return None + table = self._nav() + if table.cursor_row is None or table.cursor_row >= len(self.shown_items): + return None + return self.shown_items[table.cursor_row] + + def _current_doc(self): + """The document the 'n'/'u' actions apply to: the drilled-into doc at the + entry level, or the highlighted row at the doc level.""" + if self.level == self.LEVEL_ENTRIES: + return self.sel_doc + if self.level == self.LEVEL_DOCS: + table = self._nav() + if table.cursor_row is None or table.cursor_row >= len(self.shown_items): + return None + return self.shown_items[table.cursor_row] + return None + + def _current_type(self): + """The logbook type the 'd' (new document) action applies to: the + drilled-into type at the doc level, or the highlighted row at the + type level.""" + if self.level == self.LEVEL_DOCS: + return self.sel_type + if self.level == self.LEVEL_TYPES: + table = self._nav() + if table.cursor_row is None or table.cursor_row >= len(self.shown_items): + return None + return self.shown_items[table.cursor_row] + return None + + # -- entry actions -- + + def action_save_entry(self): + from ...common import write_entry_to_file + + entry = self._current_entry() + if entry is None: + self.notify("Select an entry first.", severity="warning") + return + doc_name = getattr(self.sel_doc, "name", None) or str(self.sel_doc.id) + try: + path = write_entry_to_file(entry, doc_name, output_dir=None, fmt="text", quiet=True) + except Exception as e: + self.notify(f"Save failed: {e}", severity="error") + return + self.notify(f"Saved to {path}") + + def action_copy_reference(self): + entry = self._current_entry() + if entry is None: + self.notify("Select an entry first.", severity="warning") + return + ref = reference_command(self.sel_doc.id, entry.log_id) + self.app.copy_to_clipboard(ref) + self.notify(f"Copied: {ref}") + + def action_open_editor(self): + from ...common import open_in_editor + + entry = self._current_entry() + if entry is None: + self.notify("Select an entry first.", severity="warning") + return + with self.app.suspend(): + open_in_editor(entry.log_entry or "") + self.notify("Back from editor (view-only; nothing was saved).") + + # -- add / update entry (mutating: goes through the auth gate) -- + + def action_new_entry(self): + doc = self._current_doc() + if doc is None: + self.notify("Select a document first.", severity="warning") + return + self._run_compose(doc, None) + + def action_update_entry(self): + entry = self._current_entry() + if entry is None: + self.notify("Select an entry first.", severity="warning") + return + self._run_compose(self.sel_doc, entry) + + @work + async def _run_compose(self, doc, entry): + """Authenticate, then push ComposeScreen for a new or existing entry.""" + from .compose import open_composer + + api = await self.app.ensure_auth() + if api is None: + return + + saved = await open_composer(self.app, doc, api, entry=entry) + if not saved: + return + self.sel_doc = doc + self.data.invalidate("entries", doc_id=doc.id) + if self.level == self.LEVEL_ENTRIES: + self.show_level(self.LEVEL_ENTRIES, preserve_filter=True) + else: + self.show_level(self.LEVEL_ENTRIES) + + # -- new document (mutating: goes through the auth gate) -- + + def action_new_doc(self): + self._run_new_doc() + + @work + async def _run_new_doc(self): + from .newdoc import NewDocScreen + + logbook_type = self._current_type() + doc = await self.app.push_screen_wait(NewDocScreen(self.session, logbook_type=logbook_type)) + if doc is None: + return + self.notify(f'Document "{doc.name}" created.') + if logbook_type is not None: + self.data.invalidate("docs", type_id=logbook_type.id) + username = self.session.username() + if username: + self.data.invalidate("recent", username=username) + if self.level == self.LEVEL_DOCS: + self.show_level(self.LEVEL_DOCS, preserve_filter=True) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/compose.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/compose.py new file mode 100644 index 000000000..d3a0d2e63 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/compose.py @@ -0,0 +1,160 @@ +"""ComposeScreen: TextArea editor for adding or updating a log entry. + +Auth is resolved by the caller (via `open_composer`, below) before this +screen is ever pushed -- fetching a fresh entry template already needs an +authenticated api (see core.new_entry_template), so there is no +"unauthenticated" state for this screen to handle. It only knows how to +render/edit/save, given an already-authenticated `api`. + +Dismisses with the saved entry, or None if the user cancelled / nothing +changed. +""" + +import asyncio + +from textual import work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Input, Static, TextArea + +from ... import core + + +class ComposeScreen(ModalScreen): + DEFAULT_CSS = """ + ComposeScreen { + align: center middle; + } + + #compose-dialog { + width: 90%; + height: 80%; + border: thick $primary; + background: $surface; + padding: 1 2; + } + """ + + BINDINGS = [ + Binding("ctrl+s", "submit", "Save"), + Binding("ctrl+e", "open_editor", "$EDITOR"), + Binding("escape", "cancel", "Cancel"), + ] + + def __init__(self, doc, entry, api, *, is_new): + super().__init__() + self.doc = doc + self.entry = entry + self.api = api + self.is_new = is_new + self._initial_text = entry.log_entry or "" + + def compose(self) -> ComposeResult: + title = (f'New entry in "{self.doc.name}"' if self.is_new + else f'Update entry #{self.entry.log_id} in "{self.doc.name}"') + with Vertical(id="compose-dialog"): + yield Static(title, id="compose-title") + yield TextArea(self._initial_text, language="markdown", id="compose-area") + with Horizontal(id="compose-attach-row"): + yield Static("Attachment:", id="compose-attach-label") + yield Input(placeholder="optional file path", id="compose-attach") + yield Static( + "[ctrl+s] save [ctrl+e] edit in $EDITOR [escape] cancel", + id="compose-hint", + ) + + def on_mount(self): + self.query_one("#compose-area", TextArea).focus() + + # -- cancel, with a dirty-buffer confirmation -- + + def action_cancel(self): + area = self.query_one("#compose-area", TextArea) + if area.text != self._initial_text: + self._confirm_discard() + else: + self.dismiss(None) + + @work + async def _confirm_discard(self): + from .picker import PickerScreen + + choice = await self.app.push_screen_wait( + PickerScreen("Discard unsaved changes?", ["Discard", "Keep editing"], lambda x: x) + ) + if choice == "Discard": + self.dismiss(None) + + # -- hand off to $EDITOR and back -- + + def action_open_editor(self): + from ...common import open_in_editor + + area = self.query_one("#compose-area", TextArea) + with self.app.suspend(): + edited = open_in_editor(area.text) + area.text = edited + + # -- save -- + + def action_submit(self): + self._save() + + @work + async def _save(self): + area = self.query_one("#compose-area", TextArea) + text = area.text + + if self.is_new and not text.strip(): + self.notify("Empty entry, skipped.", severity="warning") + self.dismiss(None) + return + + attach_path = self.query_one("#compose-attach", Input).value.strip() + if attach_path: + try: + attach_path = core.validate_attachment_path(attach_path) + except ValueError as e: + self.notify(str(e), severity="error") + return + + if not self.is_new and text == self._initial_text and not attach_path: + self.notify("No changes made.") + self.dismiss(None) + return + + try: + saved_entry = await asyncio.to_thread(core.save_entry, self.api, self.entry, text) + if attach_path: + await asyncio.to_thread( + core.upload_attachment, self.api, self.doc.id, saved_entry.log_id, attach_path) + except Exception as e: + self.notify(f"Save failed: {e}", severity="error") + return + + self.dismiss(saved_entry) + + +async def open_composer(app, doc, api, *, entry=None): + """Push ComposeScreen for a new or existing entry. + + When `entry` is None, fetches a fresh template first (needs an + authenticated api, same as cmd_add_entry) -- shared by BrowseScreen's + 'n'/'u' keys and NewDocScreen's post-create prompt so neither duplicates + the template-fetch/push/await dance. + + Returns the saved entry, or None if the template fetch failed or the + user cancelled / made no changes. + """ + if entry is None: + try: + entry = await asyncio.to_thread(core.new_entry_template, api, doc.id) + except Exception as e: + app.notify(f"Could not load entry template: {e}", severity="error") + return None + is_new = True + else: + is_new = False + return await app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=is_new)) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py new file mode 100644 index 000000000..0a70dfec8 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py @@ -0,0 +1,133 @@ +"""ConfigScreen: view and edit settings, mirroring `config show`/`config +set`/`config edit`. + +A modal dialog opened from the command palette. Reading is unauthenticated +(core.collect_config() just reads settings.yaml + os.environ); only saving +touches disk. +""" + +import asyncio +import subprocess + +from textual import work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Input, Static + +from ... import config, core + + +class ConfigScreen(ModalScreen): + DEFAULT_CSS = """ + ConfigScreen { + align: center middle; + } + + #config-dialog { + width: 70; + height: auto; + border: thick $primary; + background: $surface; + padding: 1 2; + } + """ + + BINDINGS = [ + Binding("escape", "back", "Back"), + Binding("ctrl+s", "save", "Save"), + Binding("ctrl+e", "open_editor", "Edit file"), + Binding("r", "reload", "Reload"), + ] + + # Fields whose effective value can be overridden by an env var the CLI + # also honors (see auth.py's precedence) -- token_path has none. + ENV_FOR_FIELD = {"host": "BELY_HOST", "user": "BELY_USER", "editor": "EDITOR"} + + def compose(self) -> ComposeResult: + with Vertical(id="config-dialog"): + yield Static(id="config-breadcrumb") + yield Static(id="config-summary") + for field in config.VALID_FIELDS: + yield Input(placeholder=field, id=f"config-{field}") + yield Static( + "[ctrl+s] save [ctrl+e] edit file [r] reload [escape] back", + id="config-hint", + ) + + def on_mount(self): + self._load() + + def action_back(self): + self.dismiss(None) + + def action_reload(self): + self._load() + + def _load(self): + data = core.collect_config() + settings = data["settings"] + env = data["environment"] + + self.query_one("#config-breadcrumb", Static).update( + f"Configuration - {data['settings_file']}") + + lines = ["Settings:"] + if settings: + lines += [f" {k} = {v}" for k, v in settings.items()] + else: + lines.append(" (no settings)") + lines.append("") + lines.append("Environment overrides:") + if env: + lines += [f" {var} = {val}" for var, val in env.items()] + else: + lines.append(" (none set)") + self.query_one("#config-summary", Static).update("\n".join(lines)) + + for field in config.VALID_FIELDS: + box = self.query_one(f"#config-{field}", Input) + box.value = str(settings.get(field, "") or "") + env_var = self.ENV_FOR_FIELD.get(field) + box.placeholder = ( + f"{field} (overridden by {env_var})" if env_var and env_var in env else field + ) + + def action_save(self): + self._save() + + @work + async def _save(self): + changed = [] + for field in config.VALID_FIELDS: + value = self.query_one(f"#config-{field}", Input).value.strip() + current = config.get_setting(field) + if value and value != (current or ""): + await asyncio.to_thread(config.set_setting, field, value) + changed.append(field) + + if not changed: + self.notify("No changes to save.") + else: + self.notify(f"Saved: {', '.join(changed)}") + env = core.collect_config()["environment"] + for field in changed: + env_var = self.ENV_FOR_FIELD.get(field) + if env_var and env_var in env: + self.notify( + f"{env_var} is set in the environment and will keep " + f"overriding the '{field}' setting.", severity="warning") + + self._load() + + def action_open_editor(self): + self._open_editor() + + @work + async def _open_editor(self): + settings_file = await asyncio.to_thread(core.ensure_settings_file) + editor = config.get_editor() + with self.app.suspend(): + subprocess.call([editor, settings_file]) + self._load() diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/login.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/login.py new file mode 100644 index 000000000..338357cdd --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/login.py @@ -0,0 +1,63 @@ +"""LoginScreen: credentials modal used by BelyTuiApp.ensure_auth(). + +Deliberately dumb: it only collects a (username, password) pair and dismisses +with it (or None on cancel). The caller does the actual network call, so this +screen needs no auth import and is trivial to test headless. +""" + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Input, Static + + +class LoginScreen(ModalScreen): + DEFAULT_CSS = """ + LoginScreen { + align: center middle; + } + + #login-dialog { + width: 60; + height: auto; + border: thick $primary; + background: $surface; + padding: 1 2; + } + """ + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + def __init__(self, prefill=""): + super().__init__() + self._prefill = prefill + + def compose(self) -> ComposeResult: + with Vertical(id="login-dialog"): + yield Static("Log in to BELY", id="login-title") + yield Input(value=self._prefill, placeholder="username", id="login-username") + yield Input(password=True, placeholder="password", id="login-password") + yield Static("[enter] submit [escape] cancel", id="login-hint") + + def on_mount(self): + field = self.query_one("#login-username", Input) + field.focus() + field.cursor_position = len(field.value) + + def on_input_submitted(self, event): + if event.input.id == "login-username": + self.query_one("#login-password", Input).focus() + elif event.input.id == "login-password": + self._submit() + + def _submit(self): + username = self.query_one("#login-username", Input).value.strip() + password = self.query_one("#login-password", Input).value + if not username: + self.notify("Username is required.", severity="warning") + return + self.dismiss((username, password)) + + def action_cancel(self): + self.dismiss(None) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/newdoc.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/newdoc.py new file mode 100644 index 000000000..1ff3734e8 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/newdoc.py @@ -0,0 +1,233 @@ +"""NewDocScreen: the `doc new` equivalent -- create a document, then optionally +compose its first entry. + +Mirrors cmd_new_doc's flow (commands.py) as closely as a form can: resolve +type/systems/template via PickerScreen (all unauthenticated lookups, same as +the CLI's `auth.get_factory()` for name resolution), ensure_auth() only when +actually creating, then reproduce the post-create entry branch -- a template +that already produced an entry offers to edit it, no entry offers to create +one. + +Dismisses with the created document, or None if cancelled before creation. +""" + +import asyncio +from types import SimpleNamespace + +from textual import work +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Input, Static + +from ... import core +from ...common import find_logdoc + +# Sentinel for "explicitly skip the default template" -- distinct from +# PickerScreen's own None-on-cancel so the two can't be confused. +_NO_TEMPLATE = SimpleNamespace(id=None, name="(no template)") + + +class NewDocScreen(ModalScreen): + DEFAULT_CSS = """ + NewDocScreen { + align: center middle; + } + + #newdoc-dialog { + width: 70; + height: auto; + border: thick $primary; + background: $surface; + padding: 1 2; + } + """ + + BINDINGS = [ + Binding("ctrl+t", "pick_type", "Type"), + Binding("ctrl+y", "pick_systems", "Systems"), + Binding("ctrl+m", "pick_template", "Template"), + Binding("ctrl+s", "submit", "Create"), + Binding("escape", "cancel", "Cancel"), + ] + + def __init__(self, session, logbook_type=None): + super().__init__() + self.session = session + self.logbook_type = logbook_type + self.systems = [] + self.template = None + self.skip_template = False + + def compose(self) -> ComposeResult: + with Vertical(id="newdoc-dialog"): + yield Static("New document", id="newdoc-title") + yield Input(placeholder="document name", id="newdoc-name") + yield Static(id="newdoc-type") + yield Static(id="newdoc-systems") + yield Static(id="newdoc-template") + yield Static( + "[ctrl+t] type [ctrl+y] systems [ctrl+m] template " + "[ctrl+s] create [escape] cancel", + id="newdoc-hint", + ) + + def on_mount(self): + self._refresh_labels() + self.query_one("#newdoc-name", Input).focus() + + def _refresh_labels(self): + type_label = self.logbook_type.name if self.logbook_type else "(none)" + self.query_one("#newdoc-type", Static).update(f"Type: {type_label}") + + names = ", ".join(s.name for s in self.systems) or "(none)" + self.query_one("#newdoc-systems", Static).update(f"Systems: {names}") + + if self.template is not None: + template_label = self.template.name + elif self.skip_template: + template_label = "(no template)" + else: + template_label = "(none)" + self.query_one("#newdoc-template", Static).update(f"Template: {template_label}") + + def action_cancel(self): + self.dismiss(None) + + # -- pickers -- + + def action_pick_type(self): + self._pick_type() + + @work + async def _pick_type(self): + from .picker import PickerScreen + + try: + types = await asyncio.to_thread(self.session.data.logbook_types) + except Exception as e: + self.notify(f"Could not load types: {e}", severity="error") + return + choice = await self.app.push_screen_wait( + PickerScreen("Logbook type", types, lambda t: t.name or "") + ) + if choice is not None: + self.logbook_type = choice + self._refresh_labels() + + def action_pick_systems(self): + self._pick_systems() + + @work + async def _pick_systems(self): + from .picker import PickerScreen + + try: + systems = await asyncio.to_thread(self.session.data.logbook_systems) + except Exception as e: + self.notify(f"Could not load systems: {e}", severity="error") + return + choice = await self.app.push_screen_wait( + PickerScreen("Systems (space to toggle)", systems, lambda s: s.name or "", multi=True) + ) + if choice is not None: + self.systems = choice + self._refresh_labels() + + def action_pick_template(self): + self._pick_template() + + @work + async def _pick_template(self): + from .picker import PickerScreen + + try: + templates = await asyncio.to_thread(self.session.data.logbook_templates) + except Exception as e: + self.notify(f"Could not load templates: {e}", severity="error") + return + items = [_NO_TEMPLATE] + list(templates) + choice = await self.app.push_screen_wait( + PickerScreen("Template", items, lambda t: t.name or "") + ) + if choice is None: + return + if choice is _NO_TEMPLATE: + self.template = None + self.skip_template = True + else: + self.template = choice + self.skip_template = False + self._refresh_labels() + + # -- create, then reproduce cmd_new_doc's post-create entry prompts -- + + def action_submit(self): + self._create() + + @work + async def _create(self): + name = self.query_one("#newdoc-name", Input).value.strip() + if not name: + self.notify("Document name is required.", severity="warning") + return + if self.logbook_type is None: + self.notify("Pick a logbook type (ctrl+t) first.", severity="warning") + return + + try: + existing = await asyncio.to_thread( + find_logdoc, self.session.factory.get_logbook_api(), name) + except Exception: + existing = None + if existing: + self.notify(f'A log document named "{name}" already exists.', severity="error") + return + + api = await self.app.ensure_auth() + if api is None: + return + + system_id_list = [s.id for s in self.systems] or None + template_id = self.template.id if self.template else None + try: + doc = await asyncio.to_thread( + core.create_document, api, name, self.logbook_type.id, + system_id_list=system_id_list, template_id=template_id, + skip_default_template=self.skip_template, + ) + except Exception as e: + self.notify(f"Create failed: {e}", severity="error") + return + + self.notify(f'Document "{doc.name}" created, id={doc.id}') + await self._post_create(api, doc) + + async def _post_create(self, api, doc): + from .compose import open_composer + from .picker import PickerScreen + + try: + entries = await asyncio.to_thread(api.get_log_entries, log_document_id=doc.id) + except Exception: + entries = [] + + if entries: + entry = entries[0] + choice = await self.app.push_screen_wait( + PickerScreen( + f"Template generated log entry #{entry.log_id}. Edit it now?", + ["Edit now", "Leave as-is"], lambda x: x, + ) + ) + if choice == "Edit now": + await open_composer(self.app, doc, api, entry=entry) + else: + choice = await self.app.push_screen_wait( + PickerScreen("Create a log entry now?", ["Create entry", "Skip"], lambda x: x) + ) + if choice == "Create entry": + await open_composer(self.app, doc, api) + + self.dismiss(doc) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/picker.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/picker.py new file mode 100644 index 000000000..403320996 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/picker.py @@ -0,0 +1,124 @@ +"""PickerScreen: a reusable single/multi-select modal. + +Wraps an OptionList with a filter Input (reusing format.filter_items, the +same filtering used by BrowseScreen). Single-select dismisses with the chosen +item on Enter. Multi-select toggles the highlighted item with `space` and +dismisses with the list of selected items on Enter -- so Enter always means +"confirm", whether that's one item or the current multi-selection. + +Also doubles as a lightweight yes/no confirm dialog: pass two plain strings +as `items` with an identity `label_fn` (see ComposeScreen's discard-changes +check and NewDocScreen's post-create prompts) rather than adding a separate +screen class just for that. +""" + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.screen import ModalScreen +from textual.widgets import Input, OptionList, Static + +from ..format import filter_items + + +class PickerScreen(ModalScreen): + DEFAULT_CSS = """ + PickerScreen { + align: center middle; + } + + #picker-dialog { + width: 60; + height: auto; + border: thick $primary; + background: $surface; + padding: 1 2; + } + + #picker-list { + height: auto; + max-height: 16; + } + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel"), + Binding("space", "toggle", "Toggle", show=False), + ] + + def __init__(self, title, items, label_fn, *, multi=False): + super().__init__() + self.title_text = title + self.items = list(items) + self.label_fn = label_fn + self.multi = multi + self.selected = set() + self.shown = list(self.items) + + def compose(self) -> ComposeResult: + hint = ("[space] toggle [enter] confirm [escape] cancel" if self.multi + else "[enter] select [escape] cancel") + with Vertical(id="picker-dialog"): + yield Static(self.title_text, id="picker-title") + yield Input(placeholder="type to filter", id="picker-filter") + yield OptionList(id="picker-list") + yield Static(hint, id="picker-hint") + + def on_mount(self): + self._populate("") + self.query_one("#picker-filter", Input).focus() + + def _option_text(self, item): + label = self.label_fn(item) + if not self.multi: + return label + idx = self.items.index(item) + mark = "[x]" if idx in self.selected else "[ ]" + return f"{mark} {label}" + + def _populate(self, query): + self.shown = filter_items(self.items, query, self.label_fn) + lst = self.query_one("#picker-list", OptionList) + highlighted = lst.highlighted + lst.clear_options() + for item in self.shown: + lst.add_option(self._option_text(item)) + if self.shown: + lst.highlighted = min(highlighted, len(self.shown) - 1) if highlighted is not None else 0 + + def on_input_changed(self, event): + if event.input.id == "picker-filter": + self._populate(event.value) + + def on_input_submitted(self, event): + if event.input.id == "picker-filter": + self.query_one("#picker-list", OptionList).focus() + + def on_option_list_option_selected(self, event): + if event.option_list.id != "picker-list": + return + if self.multi: + self._confirm_multi() + else: + self.dismiss(self.shown[event.option_index]) + + def action_toggle(self): + if not self.multi: + return + lst = self.query_one("#picker-list", OptionList) + if lst.highlighted is None or not self.shown: + return + item = self.shown[lst.highlighted] + idx = self.items.index(item) + if idx in self.selected: + self.selected.discard(idx) + else: + self.selected.add(idx) + query = self.query_one("#picker-filter", Input).value + self._populate(query) + + def _confirm_multi(self): + self.dismiss([self.items[i] for i in sorted(self.selected)]) + + def action_cancel(self): + self.dismiss(None) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/session.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/session.py new file mode 100644 index 000000000..c152514d5 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/session.py @@ -0,0 +1,61 @@ +"""TuiSession: the TUI's handle on auth + cached API access. + +No Textual import here (matches data.py) -- this is plumbing the screens use, +kept testable without a terminal. Constructed once by cmd_tui and shared by +every screen through `self.app.session`. + +Browsing (types/docs/entries/recent-docs) only ever needs the unauthenticated +`factory` that also backs `LogbookData`. Mutations (new doc, add/update +entry, attachments, config) need an authenticated `logbook_api`, obtained +lazily and cached here for the rest of the session -- this is what lets a +user who already has a cached CLI token (`bely-cli entry add ...` run +earlier, for instance) skip the login screen entirely. +""" + +from .. import auth +from .data import LogbookData + + +class TuiSession: + def __init__(self, factory): + self.factory = factory + self.data = LogbookData(factory.get_logbook_api()) + self._auth_factory = None + + def username(self): + """Configured username, or None. Does not prompt.""" + return auth.get_configured_username() + + def is_authenticated(self): + return self._auth_factory is not None + + def try_token(self): + """Try the cached CLI token. Returns True if now authenticated. + + Safe to call repeatedly/off the UI thread; does not prompt. + """ + if self._auth_factory is not None: + return True + factory = auth.authenticated_factory_from_token() + if factory is None: + return False + self._auth_factory = factory + return True + + def login(self, username, password): + """Authenticate with explicit credentials. + + Raises ValueError on bad credentials, RuntimeError otherwise -- same + as auth.login, which this delegates to. + """ + self._auth_factory = auth.login(username, password) + + def authenticated_factory(self): + """The authenticated BelyApiFactory. Raises RuntimeError if not authenticated yet.""" + if self._auth_factory is None: + raise RuntimeError("not authenticated yet") + return self._auth_factory + + def authenticated_api(self): + """The authenticated logbook_api. Raises RuntimeError if not authenticated yet.""" + return self.authenticated_factory().get_logbook_api() diff --git a/tools/developer_tools/bely-cli/test/test_auth.py b/tools/developer_tools/bely-cli/test/test_auth.py new file mode 100644 index 000000000..af2d7a290 --- /dev/null +++ b/tools/developer_tools/bely-cli/test/test_auth.py @@ -0,0 +1,159 @@ +import os +import tempfile +import unittest +from unittest.mock import patch + +from bely_cli import auth + + +class _Unauthorized(Exception): + """Stand-in for belyApi.exceptions.UnauthorizedException.""" + + +class FakeApiClient: + def __init__(self): + self.default_headers = {} + + def set_default_header(self, key, value): + self.default_headers[key] = value + + +def _fake_factory_class(valid_token=None, login_ok=True, login_token="new-token"): + """Build a stand-in for BelyApiFactory.BelyApiFactory. + + `valid_token` is the only token `test_authenticated()` accepts; + `login_ok`/`login_token` control what `authenticate_user()` does. + """ + + class FakeFactory: + HEADER_TOKEN_KEY = "token" + + def __init__(self, bely_url): + self.bely_url = bely_url + self.api_client = FakeApiClient() + + def test_authenticated(self): + if self.api_client.default_headers.get(self.HEADER_TOKEN_KEY) != valid_token: + raise _Unauthorized() + + def authenticate_user(self, username, password): + if not login_ok: + raise _Unauthorized() + self.api_client.set_default_header(self.HEADER_TOKEN_KEY, login_token) + + def get_authenticate_token(self): + return self.api_client.default_headers[self.HEADER_TOKEN_KEY] + + return FakeFactory + + +class AuthTestCase(unittest.TestCase): + """Points auth.py's token file at a scratch path and stubs get_host().""" + + def setUp(self): + tmp = tempfile.NamedTemporaryFile(delete=False) + tmp.close() + os.remove(tmp.name) # start with no cached token + self.token_file = tmp.name + self.addCleanup(lambda: os.path.exists(self.token_file) and os.remove(self.token_file)) + + p_token = patch.object(auth, "get_token_file", return_value=self.token_file) + p_token.start() + self.addCleanup(p_token.stop) + + p_host = patch.object(auth, "get_host", return_value="https://example.test/bely") + p_host.start() + self.addCleanup(p_host.stop) + + def _install_factory(self, **kwargs): + fake_cls = _fake_factory_class(**kwargs) + self._install_factory_class(fake_cls) + return fake_cls + + def _install_factory_class(self, fake_cls): + p1 = patch("BelyApiFactory.BelyApiFactory", fake_cls) + p1.start() + self.addCleanup(p1.stop) + p2 = patch("belyApi.exceptions.UnauthorizedException", _Unauthorized) + p2.start() + self.addCleanup(p2.stop) + + +class AuthenticatedFactoryFromTokenTests(AuthTestCase): + def test_no_cached_token_returns_none(self): + self._install_factory(valid_token="good-token") + self.assertIsNone(auth.authenticated_factory_from_token()) + + def test_valid_cached_token_returns_factory(self): + self._install_factory(valid_token="good-token") + auth.save_token("good-token") + + factory = auth.authenticated_factory_from_token() + + self.assertIsNotNone(factory) + self.assertEqual(factory.api_client.default_headers["token"], "good-token") + + def test_rejected_token_is_deleted_and_returns_none(self): + self._install_factory(valid_token="good-token") + auth.save_token("stale-token") + + factory = auth.authenticated_factory_from_token() + + self.assertIsNone(factory) + self.assertIsNone(auth.load_token()) + + +class LoginTests(AuthTestCase): + def test_success_caches_token_and_returns_factory(self): + self._install_factory(login_ok=True, login_token="fresh-token") + + factory = auth.login("alice", "correct") + + self.assertEqual(factory.get_authenticate_token(), "fresh-token") + self.assertEqual(auth.load_token(), "fresh-token") + + def test_bad_credentials_raise_value_error_and_leave_no_token(self): + self._install_factory(login_ok=False) + + with self.assertRaises(ValueError) as ctx: + auth.login("alice", "wrong") + + self.assertIn("alice", str(ctx.exception)) + self.assertIsNone(auth.load_token()) + + def test_other_failure_raises_runtime_error(self): + fake_cls = _fake_factory_class() + + class BoomFactory(fake_cls): + def authenticate_user(self, username, password): + raise RuntimeError("network down") + + self._install_factory_class(BoomFactory) + + with self.assertRaises(RuntimeError): + auth.login("alice", "whatever") + + +class GetAuthenticatedFactoryTests(AuthTestCase): + def test_prefers_valid_cached_token_without_prompting(self): + self._install_factory(valid_token="good-token") + auth.save_token("good-token") + + with patch.object(auth, "get_username", side_effect=AssertionError("should not prompt")), \ + patch.object(auth, "get_password", side_effect=AssertionError("should not prompt")): + with auth.get_authenticated_factory() as factory: + self.assertEqual(factory.api_client.default_headers["token"], "good-token") + + def test_falls_back_to_login_when_no_cached_token(self): + self._install_factory(login_ok=True, login_token="fresh-token") + + with patch.object(auth, "get_username", return_value="alice"), \ + patch.object(auth, "get_password", return_value="correct"): + with auth.get_authenticated_factory() as factory: + self.assertEqual(factory.get_authenticate_token(), "fresh-token") + + self.assertEqual(auth.load_token(), "fresh-token") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/developer_tools/bely-cli/test/test_core.py b/tools/developer_tools/bely-cli/test/test_core.py new file mode 100644 index 000000000..0d6a1be31 --- /dev/null +++ b/tools/developer_tools/bely-cli/test/test_core.py @@ -0,0 +1,264 @@ +import os +import tempfile +import unittest +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from bely_cli import core + + +class FakeLogbookApi: + def __init__(self): + self.types = [SimpleNamespace(id=1, name="ops", display_name="Ops")] + self.systems = [SimpleNamespace(id=2, name="SR", description="Storage Ring")] + self.templates = [SimpleNamespace(id=3, name="shift", description="Shift template")] + self.created = None + self.saved = None + self.uploaded = None + + def get_logbook_types(self): + return self.types + + def get_logbook_systems(self): + return self.systems + + def get_logbook_templates(self): + return self.templates + + def create_logbook_document(self, log_document_options): + self.created = log_document_options + return SimpleNamespace(id=42, name=log_document_options.name) + + def get_log_entry_template(self, log_document_id): + return SimpleNamespace(log_id=None, log_entry="") + + def add_update_log_entry(self, log_entry): + self.saved = log_entry + if log_entry.log_id is None: + log_entry.log_id = 99 + return log_entry + + def upload_attachment(self, log_document_id, log_id, body, append_reference, file_name): + self.uploaded = (log_document_id, log_id, body, append_reference, file_name) + return SimpleNamespace( + original_filename=file_name, + stored_filename=f"stored_{file_name}", + download_path=f"/download/{file_name}", + markdown_reference=f"![{file_name}](/download/{file_name})", + ) + + +class FindLookupTests(unittest.TestCase): + def test_find_logbook_type_case_insensitive(self): + api = FakeLogbookApi() + t = core.find_logbook_type(api, "OPS") + self.assertEqual(t.id, 1) + + def test_find_logbook_type_unknown_lists_available(self): + api = FakeLogbookApi() + with self.assertRaises(ValueError) as ctx: + core.find_logbook_type(api, "nope") + self.assertIn("ops", str(ctx.exception)) + + def test_find_systems_resolves_csv_to_ids(self): + api = FakeLogbookApi() + api.systems.append(SimpleNamespace(id=4, name="Software", description="")) + self.assertEqual(core.find_systems(api, "SR, software"), [2, 4]) + + def test_find_systems_unknown_name_raises(self): + api = FakeLogbookApi() + with self.assertRaises(ValueError): + core.find_systems(api, "nope") + + def test_find_template_case_insensitive(self): + api = FakeLogbookApi() + self.assertEqual(core.find_template(api, "SHIFT").id, 3) + + +class ResolveDocTests(unittest.TestCase): + def test_both_name_and_id_raises(self): + with self.assertRaises(ValueError): + core.resolve_doc(None, "name", 1) + + def test_neither_raises(self): + with self.assertRaises(ValueError): + core.resolve_doc(None, None, None) + + def test_by_id_does_not_touch_the_api(self): + doc = core.resolve_doc(None, None, 7) + self.assertEqual(doc.id, 7) + self.assertEqual(doc.name, "id=7") + + def test_by_name_not_found_raises(self): + api = MagicMock() + with patch("bely_cli.common.find_logdoc", return_value=None): + with self.assertRaises(ValueError): + core.resolve_doc(api, "missing", None) + + +class FakeOptions: + """Stand-in for belyApi.LogDocumentOptions: a plain object so hasattr() + reflects only what create_document actually set (unlike a MagicMock, + which auto-vivifies any attribute access).""" + + def __init__(self, name, logbook_type_id): + self.name = name + self.logbook_type_id = logbook_type_id + + +class CreateDocumentTests(unittest.TestCase): + def test_builds_options_and_creates(self): + api = FakeLogbookApi() + with patch("belyApi.LogDocumentOptions", FakeOptions): + doc = core.create_document( + api, "My Doc", 1, system_id_list=[2], template_id=3, + skip_default_template=True, + ) + opts = api.created + self.assertEqual(opts.name, "My Doc") + self.assertEqual(opts.logbook_type_id, 1) + self.assertEqual(opts.system_id_list, [2]) + self.assertEqual(opts.template_id, 3) + self.assertTrue(opts.skip_default_logbook_type_template) + self.assertEqual(doc.id, 42) + + def test_optional_fields_omitted_when_not_given(self): + api = FakeLogbookApi() + with patch("belyApi.LogDocumentOptions", FakeOptions): + core.create_document(api, "My Doc", 1) + opts = api.created + self.assertFalse(hasattr(opts, "system_id_list")) + self.assertFalse(hasattr(opts, "template_id")) + self.assertFalse(hasattr(opts, "skip_default_logbook_type_template")) + + +class EntryTests(unittest.TestCase): + def test_new_entry_template(self): + api = FakeLogbookApi() + entry = core.new_entry_template(api, 42) + self.assertEqual(entry.log_entry, "") + + def test_save_entry_sets_content_and_saves(self): + api = FakeLogbookApi() + entry = SimpleNamespace(log_id=None, log_entry="") + saved = core.save_entry(api, entry, "hello") + self.assertEqual(saved.log_entry, "hello") + self.assertEqual(saved.log_id, 99) + self.assertIs(api.saved, entry) + + def test_find_entry_found_and_missing(self): + entries = [SimpleNamespace(log_id=1), SimpleNamespace(log_id=2)] + self.assertIs(core.find_entry(entries, 2), entries[1]) + self.assertIsNone(core.find_entry(entries, 99)) + + def test_last_entry_by_user_case_insensitive_and_last_match(self): + entries = [ + SimpleNamespace(log_id=1, entered_by_username="alice"), + SimpleNamespace(log_id=2, entered_by_username="Bob"), + SimpleNamespace(log_id=3, entered_by_username="BOB"), + ] + entry = core.last_entry_by_user(entries, "bob") + self.assertEqual(entry.log_id, 3) + + def test_last_entry_by_user_no_match(self): + entries = [SimpleNamespace(log_id=1, entered_by_username="alice")] + self.assertIsNone(core.last_entry_by_user(entries, "bob")) + + def test_entry_list_items_builds_rows(self): + import datetime + entries = [ + SimpleNamespace( + log_id=1, + entered_on_date_time=datetime.datetime(2026, 1, 2, 3, 4), + entered_by_username="alice", + log_entry="first line\nsecond line", + ), + SimpleNamespace( + log_id=2, entered_on_date_time=None, entered_by_username=None, log_entry=None, + ), + ] + items = core.entry_list_items(entries) + self.assertEqual(items[0]["date"], "2026-01-02 03:04") + self.assertEqual(items[0]["author"], "alice") + self.assertEqual(items[0]["snippet"], "first line") + self.assertEqual(items[1], {"log_id": 2, "date": "", "author": "", "snippet": ""}) + + def test_entry_list_items_truncates_long_snippet(self): + entries = [SimpleNamespace( + log_id=1, entered_on_date_time=None, entered_by_username="a", + log_entry="x" * 100, + )] + snippet = core.entry_list_items(entries)[0]["snippet"] + self.assertEqual(len(snippet), 60) + self.assertTrue(snippet.endswith("...")) + + +class AttachmentTests(unittest.TestCase): + def test_validate_attachment_path_missing_raises(self): + with self.assertRaises(ValueError): + core.validate_attachment_path("/no/such/file.txt") + + def test_validate_attachment_path_expands_user(self): + with tempfile.NamedTemporaryFile() as f: + self.assertEqual(core.validate_attachment_path(f.name), f.name) + + def test_upload_attachment_returns_dict(self): + api = FakeLogbookApi() + with tempfile.NamedTemporaryFile(suffix=".png") as f: + info = core.upload_attachment(api, 42, 99, f.name) + basename = os.path.basename(f.name) + self.assertEqual(info["original_filename"], basename) + self.assertEqual(api.uploaded[0], 42) + self.assertEqual(api.uploaded[1], 99) + + +class RecentDocumentsTests(unittest.TestCase): + def test_sorts_by_last_modified_desc_and_truncates(self): + import datetime as dt + + docs = [ + SimpleNamespace(object_id=1, object_name="Old", logbook_type="ops", + last_modified_on=dt.datetime(2026, 1, 1)), + SimpleNamespace(object_id=2, object_name="New", logbook_type="ops", + last_modified_on=dt.datetime(2026, 6, 1)), + SimpleNamespace(object_id=3, object_name="Mid", logbook_type="ops", + last_modified_on=dt.datetime(2026, 3, 1)), + ] + factory = MagicMock() + factory.get_users_api.return_value.get_user_by_username.return_value = SimpleNamespace(id=7) + factory.get_search_api.return_value.search_logbook.return_value = SimpleNamespace(document_results=docs) + + result = core.recent_documents(factory, "alice", limit=2) + + self.assertEqual([d.name for d in result], ["New", "Mid"]) + self.assertEqual(result[0].id, 2) + self.assertEqual(result[0].more_info.last_modified_on_date_time, dt.datetime(2026, 6, 1)) + + def test_user_lookup_failure_wrapped_as_runtime_error(self): + factory = MagicMock() + factory.get_users_api.return_value.get_user_by_username.side_effect = Exception("boom") + with self.assertRaises(RuntimeError): + core.recent_documents(factory, "alice", limit=10) + + +class ConfigTests(unittest.TestCase): + def test_collect_config_masks_password(self): + with patch.object(core.config, "load_settings", return_value={"host": "h"}), \ + patch.dict(os.environ, {"BELY_HOST": "h", "BELY_PASSWORD": "secret"}, clear=False): + data = core.collect_config() + self.assertEqual(data["environment"]["BELY_HOST"], "h") + self.assertEqual(data["environment"]["BELY_PASSWORD"], "****") + self.assertEqual(data["settings"], {"host": "h"}) + + def test_ensure_settings_file_creates_when_missing(self): + with tempfile.TemporaryDirectory() as d: + settings_file = os.path.join(d, "sub", "settings.yaml") + with patch.object(core.config, "SETTINGS_FILE", settings_file), \ + patch.object(core.config, "CONFIG_DIR", os.path.dirname(settings_file)): + path = core.ensure_settings_file() + self.assertTrue(os.path.exists(settings_file)) + self.assertEqual(path, settings_file) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/developer_tools/bely-cli/test/test_tui.py b/tools/developer_tools/bely-cli/test/test_tui.py index 960ba6ec2..0731030f4 100644 --- a/tools/developer_tools/bely-cli/test/test_tui.py +++ b/tools/developer_tools/bely-cli/test/test_tui.py @@ -63,6 +63,10 @@ def test_doc_row_matches_doc_columns(self): d = SimpleNamespace(name=None, description=None, item_type_list=None, more_info=None) self.assertEqual(len(fmt.doc_row(d)), len(fmt.DOC_COLUMNS)) + def test_entry_row_matches_entry_columns(self): + e = SimpleNamespace(entered_on_date_time=None, entered_by_username=None, log_entry=None) + self.assertEqual(len(fmt.entry_row(e)), len(fmt.ENTRY_COLUMNS)) + class FilterItemsWithRowFnTests(unittest.TestCase): def test_matches_on_any_column(self): @@ -75,18 +79,17 @@ def test_matches_on_any_column(self): self.assertEqual([r.name for r in result], ["controls"]) -class FormatEntryTests(unittest.TestCase): +class EntryRowTests(unittest.TestCase): def test_date_author_snippet(self): e = SimpleNamespace( entered_on_date_time=datetime.datetime(2026, 6, 19, 14, 30), entered_by_username="alice", log_entry="First line\nSecond line", ) - out = fmt.format_entry(e) - self.assertIn("2026-06-19 14:30", out) - self.assertIn("alice", out) - self.assertIn("First line", out) - self.assertNotIn("Second line", out) + date, author, snippet = fmt.entry_row(e) + self.assertEqual(date, "2026-06-19 14:30") + self.assertEqual(author, "alice") + self.assertEqual(snippet, "First line") def test_truncates_long_first_line(self): e = SimpleNamespace( @@ -94,8 +97,8 @@ def test_truncates_long_first_line(self): entered_by_username="bob", log_entry="x" * 100, ) - out = fmt.format_entry(e) - self.assertIn("...", out) + _, _, snippet = fmt.entry_row(e) + self.assertIn("...", snippet) def test_skips_blank_leading_lines(self): e = SimpleNamespace( @@ -103,7 +106,8 @@ def test_skips_blank_leading_lines(self): entered_by_username="bob", log_entry="\n\n \nReal content", ) - self.assertIn("Real content", fmt.format_entry(e)) + _, _, snippet = fmt.entry_row(e) + self.assertEqual(snippet, "Real content") class EntryReferenceTests(unittest.TestCase): diff --git a/tools/developer_tools/bely-cli/test/test_tui_app.py b/tools/developer_tools/bely-cli/test/test_tui_app.py index b8a5feefc..df91c8135 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_app.py +++ b/tools/developer_tools/bely-cli/test/test_tui_app.py @@ -1,10 +1,35 @@ import unittest from types import SimpleNamespace -from textual.widgets import DataTable, Markdown, OptionList, Static +from textual.widgets import DataTable, Markdown, Static -from bely_cli.tui.app import BelyTuiApp, BrowseScreen +from bely_cli.tui.app import BelyTuiApp from bely_cli.tui.data import LogbookData +from bely_cli.tui.screens.browse import BrowseScreen + + +class FakeSession: + """Just enough of TuiSession for BrowseScreen -- select_mode="lookup" tests never + touch auth, so `factory`/`authenticated` default to values that make the n/u + (auth-gated) flows work out of the box too.""" + + def __init__(self, data, *, factory=None, username="alice", authenticated=True): + self.data = data + self.factory = factory + self._username = username + self._authenticated = authenticated + + def username(self): + return self._username + + def is_authenticated(self): + return self._authenticated + + def authenticated_api(self): + return self.factory.get_logbook_api() + + def try_token(self): + return self._authenticated class FakeLogbookApi: @@ -27,11 +52,62 @@ def get_log_entries(self, log_document_id, load_replies, load_reactions): def get_log_entry_attachments(self, log_document_id, log_id): return [] + def get_log_entry_template(self, log_document_id): + return SimpleNamespace(log_id=None, log_entry="") + + def add_update_log_entry(self, log_entry): + log_entry.log_id = 101 + return log_entry + + +class FakeUsersApi: + def __init__(self, calls): + self._calls = calls + + def get_user_by_username(self, username): + self._calls.append(("user", username)) + return SimpleNamespace(id=99) + + +class FakeSearchResults: + def __init__(self, docs): + self.document_results = docs + + +class FakeSearchApi: + def __init__(self, calls, docs): + self._calls = calls + self._docs = docs + + def search_logbook(self, search_text, user_id): + self._calls.append(("search", search_text, tuple(user_id))) + return FakeSearchResults(self._docs) + + +class FakeFactory: + """Combined stand-in for the bits of BelyApiFactory the n/u and recent-docs + flows touch: get_logbook_api() for ensure_auth(), get_users_api()/ + get_search_api() for core.recent_documents().""" + + def __init__(self, api=None, docs=None): + self.calls = [] + self._api = api + self._docs = docs or [] + + def get_logbook_api(self): + return self._api + + def get_users_api(self): + return FakeUsersApi(self.calls) + + def get_search_api(self): + return FakeSearchApi(self.calls, self._docs) + class TuiAppSmokeTests(unittest.IsolatedAsyncioTestCase): async def test_browse_populates_list_and_drives_preview(self): data = LogbookData(FakeLogbookApi()) - app = BelyTuiApp(data, limit=10) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") async with app.run_test() as pilot: await pilot.pause() screen = app.screen @@ -54,8 +130,7 @@ async def test_browse_populates_list_and_drives_preview(self): await pilot.pause() await pilot.pause() self.assertEqual(screen.level, screen.LEVEL_ENTRIES) - nav = screen.query_one("#nav-list", OptionList) - self.assertEqual(nav.option_count, 1) + self.assertEqual(table.row_count, 1) self.assertEqual(screen.shown_items[0].log_id, 100) self.assertTrue(screen.query_one("#body-md", Markdown).display) # Entries always show the preview, even though 'i' was never pressed. @@ -63,7 +138,7 @@ async def test_browse_populates_list_and_drives_preview(self): async def test_info_toggle_at_table_levels(self): data = LogbookData(FakeLogbookApi()) - app = BelyTuiApp(data, limit=10) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") async with app.run_test() as pilot: await pilot.pause() screen = app.screen @@ -85,7 +160,7 @@ async def test_info_toggle_at_table_levels(self): async def test_f_key_is_a_no_op_at_table_levels(self): data = LogbookData(FakeLogbookApi()) - app = BelyTuiApp(data, limit=10) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") async with app.run_test() as pilot: await pilot.pause() screen = app.screen @@ -98,7 +173,7 @@ async def test_f_key_is_a_no_op_at_table_levels(self): async def test_filter_narrows_the_list(self): data = LogbookData(FakeLogbookApi()) - app = BelyTuiApp(data, limit=10) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") async with app.run_test() as pilot: await pilot.pause() screen = app.screen @@ -109,7 +184,7 @@ async def test_filter_narrows_the_list(self): async def test_filter_narrows_table_row_count(self): data = LogbookData(FakeLogbookApi()) - app = BelyTuiApp(data, limit=10) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") async with app.run_test() as pilot: await pilot.pause() screen = app.screen @@ -124,14 +199,17 @@ async def test_footer_shortcuts_track_the_current_level(self): # check_action() returns None to hide a binding from the Footer entirely # (rather than showing it disabled), so only relevant keys ever appear. data = LogbookData(FakeLogbookApi()) - app = BelyTuiApp(data, limit=10) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") async with app.run_test() as pilot: await pilot.pause() screen = app.screen + entry_only_actions = [ + a for a, levels in screen.ACTION_LEVELS.items() if levels == (screen.LEVEL_ENTRIES,) + ] self.assertTrue(screen.check_action("toggle_info", ())) self.assertTrue(screen.check_action("refresh_level", ())) - for action in screen.ENTRY_ONLY_ACTIONS: + for action in entry_only_actions: self.assertIsNone(screen.check_action(action, ())) await pilot.press("enter") # type -> docs @@ -141,15 +219,144 @@ async def test_footer_shortcuts_track_the_current_level(self): await pilot.pause() self.assertIsNone(screen.check_action("toggle_info", ())) - for action in screen.ENTRY_ONLY_ACTIONS: + for action in entry_only_actions: self.assertTrue(screen.check_action(action, ())) + async def test_escape_at_landing_screen_quits_the_app(self): + # mode="app" has no separate landing/home screen -- BrowseScreen IS the + # landing screen, so escape at its top level exits like tui lookup does. + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="app") + async with app.run_test() as pilot: + await pilot.pause() + self.assertFalse(app.screen.select_mode) + await pilot.press("escape") + await pilot.pause() + self.assertIsNone(app.return_value) + + async def test_command_palette_offers_config_recent_and_login(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="app") + async with app.run_test() as pilot: + await pilot.pause() + titles = {cmd.title for cmd in app.get_system_commands(app.screen)} + self.assertIn("Configuration", titles) + self.assertIn("My documents", titles) + self.assertIn("Refresh cache", titles) + self.assertIn("Log in", titles) + async def test_search_and_resize_bindings_are_gone(self): keys = {b.key for b in BrowseScreen.BINDINGS} self.assertNotIn("ctrl+s", keys) self.assertNotIn("left_square_bracket", keys) self.assertNotIn("right_square_bracket", keys) + async def test_recent_command_pushes_docs_and_escape_returns_to_landing(self): + import datetime + + docs = [SimpleNamespace( + object_id=1, object_name="Doc A", logbook_type="ops", + last_modified_on=datetime.datetime(2024, 1, 1))] + data = LogbookData(FakeLogbookApi()) + session = FakeSession(data, factory=FakeFactory(docs=docs)) + app = BelyTuiApp(session, limit=10, mode="app") + async with app.run_test() as pilot: + await pilot.pause() + self.assertEqual(type(app.screen).__name__, "BrowseScreen") + landing = app.screen + + app._cmd_recent() # command palette's "My documents" entry + await pilot.pause() + await pilot.pause() + screen = app.screen + self.assertIsNot(screen, landing) + self.assertEqual(type(screen).__name__, "BrowseScreen") + self.assertEqual(screen.source, "recent") + self.assertEqual(screen.level, screen.LEVEL_DOCS) + self.assertEqual(screen.shown_items[0].name, "Doc A") + + await pilot.press("escape") # pops back to the landing browse screen + await pilot.pause() + self.assertIs(app.screen, landing) + + async def test_new_entry_action_visible_at_docs_and_entries_not_types(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + self.assertIsNone(screen.check_action("new_entry", ())) + + await pilot.press("enter") # type -> docs + await pilot.pause() + self.assertTrue(screen.check_action("new_entry", ())) + + await pilot.press("enter") # docs -> entries + await pilot.pause() + await pilot.pause() + self.assertTrue(screen.check_action("new_entry", ())) + + async def test_new_entry_key_creates_entry_and_refreshes(self): + api = FakeLogbookApi() + data = LogbookData(api) + session = FakeSession(data, factory=FakeFactory(api=api)) + app = BelyTuiApp(session, limit=10, mode="lookup") + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + + await pilot.press("enter") # type -> docs + await pilot.pause() + await pilot.pause() + self.assertEqual(screen.level, screen.LEVEL_DOCS) + + await pilot.press("n") # new entry on the highlighted doc + await pilot.pause() + await pilot.pause() + self.assertEqual(type(app.screen).__name__, "ComposeScreen") + + from textual.widgets import TextArea + + app.screen.query_one("#compose-area", TextArea).text = "new content" + await pilot.press("ctrl+s") + await pilot.pause() + await pilot.pause() + + self.assertEqual(type(app.screen).__name__, "BrowseScreen") + self.assertEqual(screen.level, screen.LEVEL_ENTRIES) + self.assertEqual(len(screen.shown_items), 1) + + async def test_update_entry_key_opens_compose_prefilled_and_cancel_returns(self): + api = FakeLogbookApi() + data = LogbookData(api) + session = FakeSession(data, factory=FakeFactory(api=api)) + app = BelyTuiApp(session, limit=10, mode="lookup") + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + + await pilot.press("enter") # type -> docs + await pilot.pause() + await pilot.press("enter") # docs -> entries + await pilot.pause() + await pilot.pause() + self.assertEqual(screen.level, screen.LEVEL_ENTRIES) + + await pilot.press("u") # update the highlighted entry + await pilot.pause() + await pilot.pause() + self.assertEqual(type(app.screen).__name__, "ComposeScreen") + + from textual.widgets import TextArea + + self.assertEqual( + app.screen.query_one("#compose-area", TextArea).text, "# Hello\n\nBody text") + + await pilot.press("escape") # no changes made -> dismiss immediately, no save + await pilot.pause() + self.assertEqual(type(app.screen).__name__, "BrowseScreen") + self.assertEqual(screen.level, screen.LEVEL_ENTRIES) + if __name__ == "__main__": unittest.main() diff --git a/tools/developer_tools/bely-cli/test/test_tui_data.py b/tools/developer_tools/bely-cli/test/test_tui_data.py index ab57aea86..d4ca97d96 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_data.py +++ b/tools/developer_tools/bely-cli/test/test_tui_data.py @@ -34,6 +34,58 @@ def get_log_entry_attachments(self, log_document_id, log_id): self.calls.append(("attachments", log_document_id, log_id)) return [SimpleNamespace(original_filename="a.png")] + def get_logbook_systems(self): + self.calls.append(("systems",)) + if self.fail_next: + self.fail_next = False + raise RuntimeError("boom") + return [SimpleNamespace(id=1, name="sys-a")] + + def get_logbook_templates(self): + self.calls.append(("templates",)) + if self.fail_next: + self.fail_next = False + raise RuntimeError("boom") + return [SimpleNamespace(id=1, name="tmpl-a")] + + +class FakeSearchResults: + def __init__(self, docs): + self.document_results = docs + + +class FakeUsersApi: + def __init__(self, calls): + self._calls = calls + + def get_user_by_username(self, username): + self._calls.append(("user", username)) + return SimpleNamespace(id=99) + + +class FakeSearchApi: + def __init__(self, calls, docs): + self._calls = calls + self._docs = docs + + def search_logbook(self, search_text, user_id): + self._calls.append(("search", search_text, tuple(user_id))) + return FakeSearchResults(self._docs) + + +class FakeFactory: + """Minimal factory for recent_documents(): only users/search apis are used.""" + + def __init__(self, docs): + self.calls = [] + self._docs = docs + + def get_users_api(self): + return FakeUsersApi(self.calls) + + def get_search_api(self): + return FakeSearchApi(self.calls, self._docs) + class LogbookDataCachingTests(unittest.TestCase): def setUp(self): @@ -105,6 +157,95 @@ def test_invalidate_unknown_level_raises(self): with self.assertRaises(ValueError): self.data.invalidate("bogus") + def test_systems_fetched_once_then_cached(self): + self.data.logbook_systems() + self.data.logbook_systems() + self.assertEqual(self.api.calls.count(("systems",)), 1) + + def test_templates_fetched_once_then_cached(self): + self.data.logbook_templates() + self.data.logbook_templates() + self.assertEqual(self.api.calls.count(("templates",)), 1) + + def test_invalidate_systems(self): + self.data.logbook_systems() + self.data.invalidate("systems") + self.data.logbook_systems() + self.assertEqual(self.api.calls.count(("systems",)), 2) + + def test_invalidate_templates(self): + self.data.logbook_templates() + self.data.invalidate("templates") + self.data.logbook_templates() + self.assertEqual(self.api.calls.count(("templates",)), 2) + + def test_clear_drops_every_cache(self): + self.data.logbook_types() + self.data.logbook_systems() + self.data.logbook_templates() + self.data.documents(1, 100) + self.data.entries(10) + self.data.attachments(10, 100) + + self.data.clear() + + self.data.logbook_types() + self.data.logbook_systems() + self.data.logbook_templates() + self.data.documents(1, 100) + self.data.entries(10) + self.data.attachments(10, 100) + self.assertEqual(self.api.calls.count(("types",)), 2) + self.assertEqual(self.api.calls.count(("systems",)), 2) + self.assertEqual(self.api.calls.count(("templates",)), 2) + self.assertEqual(self.api.calls.count(("docs", 1, 100)), 2) + self.assertEqual(self.api.calls.count(("entries", 10, True, True)), 2) + self.assertEqual(self.api.calls.count(("attachments", 10, 100)), 2) + + +class RecentDocumentsCachingTests(unittest.TestCase): + def setUp(self): + self.data = LogbookData(FakeApi()) + self.docs = [SimpleNamespace( + object_id=1, object_name="Doc A", logbook_type="ops", last_modified_on="t1", + )] + self.factory = FakeFactory(self.docs) + + def test_fetched_once_then_cached_per_username(self): + self.data.recent_documents(self.factory, "alice", 10) + self.data.recent_documents(self.factory, "alice", 10) + self.assertEqual(self.factory.calls.count(("user", "alice")), 1) + + def test_cached_per_username(self): + self.data.recent_documents(self.factory, "alice", 10) + self.data.recent_documents(self.factory, "bob", 10) + self.assertEqual(self.factory.calls.count(("user", "alice")), 1) + self.assertEqual(self.factory.calls.count(("user", "bob")), 1) + + def test_invalidate_recent_by_username(self): + self.data.recent_documents(self.factory, "alice", 10) + self.data.recent_documents(self.factory, "bob", 10) + self.data.invalidate("recent", username="alice") + self.data.recent_documents(self.factory, "alice", 10) + self.data.recent_documents(self.factory, "bob", 10) + self.assertEqual(self.factory.calls.count(("user", "alice")), 2) + self.assertEqual(self.factory.calls.count(("user", "bob")), 1) + + def test_invalidate_recent_without_username_clears_all(self): + self.data.recent_documents(self.factory, "alice", 10) + self.data.recent_documents(self.factory, "bob", 10) + self.data.invalidate("recent") + self.data.recent_documents(self.factory, "alice", 10) + self.data.recent_documents(self.factory, "bob", 10) + self.assertEqual(self.factory.calls.count(("user", "alice")), 2) + self.assertEqual(self.factory.calls.count(("user", "bob")), 2) + + def test_clear_drops_recent_too(self): + self.data.recent_documents(self.factory, "alice", 10) + self.data.clear() + self.data.recent_documents(self.factory, "alice", 10) + self.assertEqual(self.factory.calls.count(("user", "alice")), 2) + if __name__ == "__main__": unittest.main() diff --git a/tools/developer_tools/bely-cli/test/test_tui_screens.py b/tools/developer_tools/bely-cli/test/test_tui_screens.py new file mode 100644 index 000000000..3db78efca --- /dev/null +++ b/tools/developer_tools/bely-cli/test/test_tui_screens.py @@ -0,0 +1,474 @@ +"""Screen-level tests for the full `bely-cli tui` app: Login, Picker, Compose, +NewDoc, Config. + +Same hand-rolled FakeApi/FakeSession style as test_tui_app.py/test_tui_data.py +-- no network, no live server. All of these are modal screens, driven through +`app.push_screen_wait(...)` since that's how they're actually used (they call +`self.dismiss(...)`). +""" + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from textual.app import App +from textual.widgets import Input, Static, TextArea + +from bely_cli.tui.app import BelyTuiApp +from bely_cli.tui.data import LogbookData +from bely_cli.tui.screens import configscreen +from bely_cli.tui.screens.compose import ComposeScreen +from bely_cli.tui.screens.configscreen import ConfigScreen +from bely_cli.tui.screens.login import LoginScreen +from bely_cli.tui.screens.newdoc import NewDocScreen +from bely_cli.tui.screens.picker import PickerScreen + + +class _NF(Exception): + """Stand-in for belyApi.exceptions.NotFoundException.""" + + +class FakeLogbookApi: + def __init__(self, existing_doc=None): + self.created = None + self._existing_doc = existing_doc + + def get_logbook_types(self): + return [SimpleNamespace(id=1, name="ops", display_name="Ops")] + + def get_logbook_systems(self): + return [SimpleNamespace(id=2, name="SR")] + + def get_logbook_templates(self): + return [SimpleNamespace(id=3, name="Standard")] + + def get_log_document_by_name(self, name): + if self._existing_doc is not None: + return self._existing_doc + raise _NF() + + def create_logbook_document(self, log_document_options): + self.created = log_document_options + return SimpleNamespace(id=42, name=getattr(log_document_options, "name", "New Doc")) + + def get_log_entries(self, log_document_id): + return [] + + def get_log_entry_template(self, log_document_id): + return SimpleNamespace(log_id=None, log_entry="") + + def add_update_log_entry(self, log_entry): + log_entry.log_id = 99 + return log_entry + + +class FakeFactory: + def __init__(self, api): + self._api = api + + def get_logbook_api(self): + return self._api + + +class FakeSession: + """Always-authenticated TuiSession stand-in -- no LoginScreen involved.""" + + def __init__(self, api): + self.factory = FakeFactory(api) + self.data = LogbookData(api) + + def username(self): + return "alice" + + def is_authenticated(self): + return True + + def authenticated_api(self): + return self.factory.get_logbook_api() + + +class LoginScreenTests(unittest.IsolatedAsyncioTestCase): + async def test_submit_dismisses_with_username_and_password(self): + app = App() + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait(LoginScreen("alice"))) + await pilot.pause() + screen = app.screen + screen.query_one("#login-password", Input).value = "secret" + await pilot.press("enter") # username -> password focus + await pilot.pause() + await pilot.press("enter") # password -> submit + await pilot.pause() + result = await task.wait() + self.assertEqual(result, ("alice", "secret")) + + async def test_escape_dismisses_with_none(self): + app = App() + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait(LoginScreen())) + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + result = await task.wait() + self.assertIsNone(result) + + async def test_empty_username_blocks_submit(self): + app = App() + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait(LoginScreen())) + await pilot.pause() + screen = app.screen + screen.query_one("#login-username", Input).value = "" + await pilot.press("enter") # -> password focus + await pilot.pause() + await pilot.press("enter") # empty username: warn, stay open + await pilot.pause() + self.assertFalse(task.is_finished) + await pilot.press("escape") + await pilot.pause() + result = await task.wait() + self.assertIsNone(result) + + +class PickerScreenTests(unittest.IsolatedAsyncioTestCase): + async def test_single_select_enter_dismisses_item(self): + app = App() + items = [SimpleNamespace(name="Alpha"), SimpleNamespace(name="Beta")] + async with app.run_test() as pilot: + task = app.run_worker( + app.push_screen_wait(PickerScreen("Pick one", items, lambda i: i.name))) + await pilot.pause() + await pilot.press("enter") # filter -> list + await pilot.pause() + await pilot.press("enter") # select highlighted (Alpha) + await pilot.pause() + result = await task.wait() + self.assertEqual(result.name, "Alpha") + + async def test_filter_narrows_then_select(self): + app = App() + items = [SimpleNamespace(name="Alpha"), SimpleNamespace(name="Beta")] + async with app.run_test() as pilot: + task = app.run_worker( + app.push_screen_wait(PickerScreen("Pick one", items, lambda i: i.name))) + await pilot.pause() + await pilot.press("b", "e", "t") + await pilot.pause() + screen = app.screen + self.assertEqual(screen.shown, [items[1]]) + await pilot.press("enter") # filter -> list + await pilot.pause() + await pilot.press("enter") # select the only match (Beta) + await pilot.pause() + result = await task.wait() + self.assertEqual(result.name, "Beta") + + async def test_multi_select_space_toggles_then_enter_confirms(self): + app = App() + items = [SimpleNamespace(name="Alpha"), SimpleNamespace(name="Beta")] + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait( + PickerScreen("Pick many", items, lambda i: i.name, multi=True))) + await pilot.pause() + await pilot.press("enter") # filter -> list + await pilot.pause() + await pilot.press("space") # toggle Alpha + await pilot.press("down") + await pilot.press("space") # toggle Beta + await pilot.pause() + await pilot.press("enter") # confirm selection + await pilot.pause() + result = await task.wait() + self.assertEqual([i.name for i in result], ["Alpha", "Beta"]) + + async def test_doubles_as_confirm_dialog_with_plain_strings(self): + app = App() + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait( + PickerScreen("Discard unsaved changes?", ["Discard", "Keep editing"], lambda x: x))) + await pilot.pause() + await pilot.press("enter") # filter -> list + await pilot.pause() + await pilot.press("down") # highlight "Keep editing" + await pilot.press("enter") + await pilot.pause() + result = await task.wait() + self.assertEqual(result, "Keep editing") + + async def test_escape_cancels_with_none(self): + app = App() + items = [SimpleNamespace(name="Alpha")] + async with app.run_test() as pilot: + task = app.run_worker( + app.push_screen_wait(PickerScreen("Pick", items, lambda i: i.name))) + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + result = await task.wait() + self.assertIsNone(result) + + +class ComposeScreenTests(unittest.IsolatedAsyncioTestCase): + async def test_ctrl_s_saves_and_dismisses_with_entry(self): + api = FakeLogbookApi() + doc = SimpleNamespace(id=1, name="Doc") + entry = SimpleNamespace(log_id=None, log_entry="") + app = App() + async with app.run_test() as pilot: + task = app.run_worker( + app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=True))) + await pilot.pause() + area = app.screen.query_one("#compose-area", TextArea) + area.text = "hello world" + await pilot.press("ctrl+s") + await pilot.pause() + await pilot.pause() + saved = await task.wait() + self.assertEqual(saved.log_entry, "hello world") + self.assertEqual(saved.log_id, 99) + + async def test_empty_new_entry_is_skipped_without_saving(self): + api = FakeLogbookApi() + doc = SimpleNamespace(id=1, name="Doc") + entry = SimpleNamespace(log_id=None, log_entry="") + app = App() + async with app.run_test() as pilot: + task = app.run_worker( + app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=True))) + await pilot.pause() + await pilot.press("ctrl+s") + await pilot.pause() + await pilot.pause() + result = await task.wait() + self.assertIsNone(result) + + async def test_escape_without_changes_dismisses_immediately(self): + api = FakeLogbookApi() + doc = SimpleNamespace(id=1, name="Doc") + entry = SimpleNamespace(log_id=7, log_entry="existing text") + app = App() + async with app.run_test() as pilot: + task = app.run_worker( + app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False))) + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + result = await task.wait() + self.assertIsNone(result) + + async def test_escape_with_unsaved_changes_prompts_discard_confirmation(self): + api = FakeLogbookApi() + doc = SimpleNamespace(id=1, name="Doc") + entry = SimpleNamespace(log_id=7, log_entry="existing text") + app = App() + async with app.run_test() as pilot: + task = app.run_worker( + app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False))) + await pilot.pause() + area = app.screen.query_one("#compose-area", TextArea) + area.text = "existing text, changed" + await pilot.press("escape") + await pilot.pause() + self.assertEqual(type(app.screen).__name__, "PickerScreen") + await pilot.press("enter") # filter -> list + await pilot.pause() + await pilot.press("enter") # confirm "Discard" (highlighted first) + await pilot.pause() + result = await task.wait() + self.assertIsNone(result) + + +class NewDocScreenPrefillTests(unittest.IsolatedAsyncioTestCase): + async def test_prefilled_logbook_type_skips_the_type_picker(self): + api = FakeLogbookApi() + session = FakeSession(api) + app = BelyTuiApp(session, limit=10, mode="app") + ops_type = SimpleNamespace(id=1, name="ops") + + with patch("belyApi.LogDocumentOptions") as opts_cls, \ + patch("belyApi.exceptions.NotFoundException", _NF): + async with app.run_test() as pilot: + await pilot.pause() + task = app.run_worker( + app.push_screen_wait(NewDocScreen(session, logbook_type=ops_type))) + await pilot.pause() + + self.assertEqual( + app.screen.query_one("#newdoc-type", Static).content, "Type: ops") + + app.screen.query_one("#newdoc-name", Input).value = "New Doc" + await pilot.press("ctrl+s") # create without ever touching ctrl+t + await pilot.pause() + await pilot.pause() + + await pilot.press("enter") # filter -> list ("Create a log entry now?") + await pilot.pause() + await pilot.press("down") # highlight "Skip" + await pilot.press("enter") + await pilot.pause() + + doc = await task.wait() + + self.assertEqual(doc.id, 42) + opts_cls.assert_called_once_with(name="New Doc", logbook_type_id=1) + + +class NewDocScreenTests(unittest.IsolatedAsyncioTestCase): + async def test_create_with_type_systems_and_no_template_then_skip_entry(self): + api = FakeLogbookApi() + session = FakeSession(api) + app = BelyTuiApp(session, limit=10, mode="app") + + with patch("belyApi.LogDocumentOptions") as opts_cls, \ + patch("belyApi.exceptions.NotFoundException", _NF): + async with app.run_test() as pilot: + await pilot.pause() + task = app.run_worker(app.push_screen_wait(NewDocScreen(session))) + await pilot.pause() + app.screen.query_one("#newdoc-name", Input).value = "New Doc" + + await pilot.press("ctrl+t") # -> type picker + await pilot.pause() + await pilot.pause() + await pilot.press("enter") # filter -> list + await pilot.pause() + await pilot.press("enter") # select "ops" + await pilot.pause() + + await pilot.press("ctrl+y") # -> systems picker (multi) + await pilot.pause() + await pilot.pause() + await pilot.press("enter") # filter -> list + await pilot.pause() + await pilot.press("space") # toggle "SR" + await pilot.pause() + await pilot.press("enter") # confirm selection + await pilot.pause() + + await pilot.press("ctrl+m") # -> template picker + await pilot.pause() + await pilot.pause() + await pilot.press("enter") # filter -> list, "(no template)" highlighted + await pilot.pause() + await pilot.press("enter") # select "(no template)" + await pilot.pause() + + await pilot.press("ctrl+s") # create + await pilot.pause() + await pilot.pause() + await pilot.pause() + + # no entries came back from the (empty) template -> offered to + # create one; pick "Skip". + await pilot.press("enter") # filter -> list + await pilot.pause() + await pilot.press("down") # highlight "Skip" + await pilot.press("enter") + await pilot.pause() + + doc = await task.wait() + + self.assertEqual(doc.id, 42) + opts_cls.assert_called_once_with(name="New Doc", logbook_type_id=1) + self.assertEqual(opts_cls.return_value.system_id_list, [2]) + self.assertIs(opts_cls.return_value.skip_default_logbook_type_template, True) + + async def test_duplicate_name_is_rejected_without_creating(self): + existing = SimpleNamespace(id=7, name="Dup") + api = FakeLogbookApi(existing_doc=existing) + session = FakeSession(api) + app = BelyTuiApp(session, limit=10, mode="app") + + with patch("belyApi.exceptions.NotFoundException", _NF): + async with app.run_test() as pilot: + await pilot.pause() + task = app.run_worker(app.push_screen_wait(NewDocScreen(session))) + await pilot.pause() + app.screen.query_one("#newdoc-name", Input).value = "Dup" + + await pilot.press("ctrl+t") + await pilot.pause() + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + + await pilot.press("ctrl+s") + await pilot.pause() + await pilot.pause() + + self.assertFalse(task.is_finished) + self.assertIsNone(api.created) + + await pilot.press("escape") + await pilot.pause() + result = await task.wait() + + self.assertIsNone(result) + + +class ConfigScreenTests(unittest.IsolatedAsyncioTestCase): + async def test_escape_dismisses_the_modal(self): + state = {"settings_file": "/tmp/settings.yaml", "settings": {}, "environment": {}} + app = App() + with patch.object(configscreen.core, "collect_config", side_effect=lambda: dict(state)), \ + patch.object(configscreen.config, "get_setting", side_effect=lambda k: None): + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait(ConfigScreen())) + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + result = await task.wait() + self.assertIsNone(result) + + async def test_load_prefills_inputs_and_flags_env_overrides(self): + state = { + "settings_file": "/tmp/settings.yaml", + "settings": {"host": "https://example", "editor": "nano"}, + "environment": {"BELY_USER": "alice"}, + } + app = App() + with patch.object(configscreen.core, "collect_config", side_effect=lambda: dict(state)), \ + patch.object(configscreen.config, "get_setting", + side_effect=lambda k: state["settings"].get(k)): + async with app.run_test() as pilot: + app.push_screen(ConfigScreen()) + await pilot.pause() + screen = app.screen + self.assertEqual(screen.query_one("#config-host", Input).value, "https://example") + self.assertEqual(screen.query_one("#config-editor", Input).value, "nano") + self.assertIn( + "overridden by BELY_USER", screen.query_one("#config-user", Input).placeholder) + + async def test_save_writes_changed_fields_and_warns_on_env_override(self): + state = { + "settings_file": "/tmp/settings.yaml", + "settings": {"host": "https://old"}, + "environment": {"BELY_HOST": "https://envhost"}, + } + saved = [] + + def fake_set_setting(key, value): + saved.append((key, value)) + state["settings"][key] = value + + app = App() + with patch.object(configscreen.core, "collect_config", side_effect=lambda: dict(state)), \ + patch.object(configscreen.config, "get_setting", + side_effect=lambda k: state["settings"].get(k)), \ + patch.object(configscreen.config, "set_setting", side_effect=fake_set_setting): + async with app.run_test() as pilot: + app.push_screen(ConfigScreen()) + await pilot.pause() + screen = app.screen + screen.query_one("#config-host", Input).value = "https://new" + await pilot.press("ctrl+s") + await pilot.pause() + await pilot.pause() + + self.assertEqual(saved, [("host", "https://new")]) + + +if __name__ == "__main__": + unittest.main() From 4ebadc92e31269e065249aae5c3e92e526a45192 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Wed, 19 Aug 2026 14:32:37 -0500 Subject: [PATCH 37/62] Resolve issue where editor changes were not detected when using editor to update entry --- tools/developer_tools/bely-cli/README.md | 2 +- .../bely-cli/src/bely_cli/common.py | 19 +++++- .../src/bely_cli/tui/screens/browse.py | 55 +++++++++++++-- .../src/bely_cli/tui/screens/confirm.py | 68 +++++++++++++++++++ .../bely-cli/test/test_common.py | 46 +++++++++++++ .../bely-cli/test/test_tui_app.py | 57 ++++++++++++++++ .../bely-cli/test/test_tui_screens.py | 37 ++++++++++ 7 files changed, 275 insertions(+), 9 deletions(-) create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/screens/confirm.py create mode 100644 tools/developer_tools/bely-cli/test/test_common.py diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index 231510be2..2f778b1d0 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -263,7 +263,7 @@ on — `i` disappears once you drill into entries, and `s` / `y` / `e` / `f` onl | `d` | Logbook/document levels only: create a new document (see `bely-cli tui`'s "New document" above) — a mutation, so this is where the app authenticates if it hasn't already. | | `s` | Entries level only: save the highlighted entry's markdown to a file in the current directory. | | `y` | Entries level only: copy a `bely-cli entry get` reference for the highlighted entry to the clipboard. | -| `e` | Entries level only: open the highlighted entry in `$EDITOR` (view-only — nothing is sent back to the server). | +| `e` | Entries level only: open the highlighted entry in `$EDITOR`; if you change it, offers to save the result back to the server (a mutation, so this is where the app authenticates if it hasn't already). | | `i` | Logbook/document levels only: toggle the side info panel. | | `f` | Entries level only: toggle the table to widen the preview pane. | | `r` | Refresh the current level, bypassing the in-session cache. | diff --git a/tools/developer_tools/bely-cli/src/bely_cli/common.py b/tools/developer_tools/bely-cli/src/bely_cli/common.py index 37376e8a9..ea743e359 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/common.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/common.py @@ -1,5 +1,6 @@ import json import os +import shlex import subprocess import sys import tempfile @@ -104,8 +105,24 @@ def open_in_editor(initial_content=""): tmp_path = tmp.name try: editor = config.get_editor() - subprocess.call([editor, tmp_path]) + try: + argv = shlex.split(editor) + [tmp_path] + except ValueError as e: + raise RuntimeError(f"could not parse editor command {editor!r}: {e}") + try: + subprocess.call(argv) + except OSError as e: + raise RuntimeError(f"could not run editor {editor!r}: {e}") with open(tmp_path, "r") as f: return f.read() finally: os.unlink(tmp_path) + + +def editor_changed(original, edited): + """True if an $EDITOR round-trip produced a real change. + + Ignores a trailing newline, which most editors (vim, nano) append on save + whether or not the user typed anything. + """ + return edited.rstrip("\n") != original.rstrip("\n") diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py index 0ea2f1ca0..5dfb2cac8 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py @@ -21,6 +21,8 @@ a plain async `@work` so they can `await` the auth gate and a modal screen. """ +import asyncio + from textual import work from textual.app import ComposeResult from textual.binding import Binding @@ -82,9 +84,9 @@ class BrowseScreen(Screen): Binding("f", "toggle_full", "Full"), Binding("s", "save_entry", "Save"), Binding("y", "copy_reference", "Copy ref"), - Binding("e", "open_editor", "Editor"), + Binding("e", "open_editor", "Edit in editor"), Binding("n", "new_entry", "New entry"), - Binding("u", "update_entry", "Update"), + Binding("u", "update_entry", "Edit in TUI"), Binding("d", "new_doc", "New doc"), Binding("r", "refresh_level", "Refresh"), Binding("i", "toggle_info", "Info"), @@ -484,15 +486,54 @@ def action_copy_reference(self): self.notify(f"Copied: {ref}") def action_open_editor(self): - from ...common import open_in_editor - entry = self._current_entry() if entry is None: self.notify("Select an entry first.", severity="warning") return - with self.app.suspend(): - open_in_editor(entry.log_entry or "") - self.notify("Back from editor (view-only; nothing was saved).") + self._edit_entry_externally(entry) + + @work + async def _edit_entry_externally(self, entry): + from ...common import editor_changed, open_in_editor + from .confirm import ConfirmScreen + + original = entry.log_entry or "" + try: + with self.app.suspend(): + edited = open_in_editor(original) + except RuntimeError as e: + self.notify(str(e), severity="error") + return + + if not editor_changed(original, edited): + self.notify("No changes made.") + return + + save = await self.app.push_screen_wait( + ConfirmScreen( + f"Save changes to entry #{entry.log_id}?", + confirm_label="Save", + cancel_label="Discard", + ) + ) + if not save: + return + + api = await self.app.ensure_auth() + if api is None: + return + + from ... import core + + try: + await asyncio.to_thread(core.save_entry, api, entry, edited) + except Exception as e: + self.notify(f"Save failed: {e}", severity="error") + return + + self.data.invalidate("entries", doc_id=self.sel_doc.id) + self.show_level(self.LEVEL_ENTRIES, preserve_filter=True) + self.notify("Entry saved.") # -- add / update entry (mutating: goes through the auth gate) -- diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/confirm.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/confirm.py new file mode 100644 index 000000000..ffa7e997b --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/confirm.py @@ -0,0 +1,68 @@ +"""ConfirmScreen: a reusable yes/no modal with real buttons. + +Dismisses with True (confirm), or False on cancel/escape. Used wherever the +TUI needs a "are you sure?" gate -- ComposeScreen's discard-changes check, +BrowseScreen's save-after-external-edit prompt -- instead of repurposing +PickerScreen (a filterable list) for a plain confirmation. +""" + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Static + + +class ConfirmScreen(ModalScreen): + DEFAULT_CSS = """ + ConfirmScreen { + align: center middle; + } + + #confirm-dialog { + width: 60; + height: auto; + border: thick $primary; + background: $surface; + padding: 1 2; + } + + #confirm-buttons { + height: auto; + align: right middle; + margin-top: 1; + } + + #confirm-buttons Button { + margin-left: 1; + } + """ + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + def __init__(self, message, *, confirm_label="Yes", cancel_label="No", + confirm_variant="primary"): + super().__init__() + self.message = message + self.confirm_label = confirm_label + self.cancel_label = cancel_label + self.confirm_variant = confirm_variant + + def compose(self) -> ComposeResult: + with Vertical(id="confirm-dialog"): + yield Static(self.message, id="confirm-message") + with Horizontal(id="confirm-buttons"): + yield Button(self.cancel_label, id="confirm-cancel") + yield Button(self.confirm_label, variant=self.confirm_variant, id="confirm-confirm") + + def on_mount(self): + self.query_one("#confirm-confirm", Button).focus() + + def on_button_pressed(self, event): + if event.button.id == "confirm-confirm": + self.dismiss(True) + elif event.button.id == "confirm-cancel": + self.dismiss(False) + + def action_cancel(self): + self.dismiss(False) diff --git a/tools/developer_tools/bely-cli/test/test_common.py b/tools/developer_tools/bely-cli/test/test_common.py new file mode 100644 index 000000000..517646bf0 --- /dev/null +++ b/tools/developer_tools/bely-cli/test/test_common.py @@ -0,0 +1,46 @@ +import unittest +from unittest.mock import patch + +from bely_cli import common + + +class OpenInEditorTests(unittest.TestCase): + def test_shell_style_editor_command_is_split(self): + with patch.object(common.config, "get_editor", return_value="code -w"), \ + patch.object(common.subprocess, "call") as call: + common.open_in_editor("hello") + argv = call.call_args.args[0] + self.assertEqual(argv[:2], ["code", "-w"]) + + def test_missing_editor_raises_runtime_error(self): + with patch.object(common.config, "get_editor", return_value="not-a-real-editor"), \ + patch.object(common.subprocess, "call", side_effect=OSError("not found")): + with self.assertRaises(RuntimeError): + common.open_in_editor("hello") + + def test_returns_edited_file_contents(self): + def fake_call(argv): + path = argv[-1] + with open(path, "w") as f: + f.write("edited text") + + with patch.object(common.config, "get_editor", return_value="vi"), \ + patch.object(common.subprocess, "call", side_effect=fake_call): + result = common.open_in_editor("original text") + self.assertEqual(result, "edited text") + + +class EditorChangedTests(unittest.TestCase): + def test_identical_text_is_unchanged(self): + self.assertFalse(common.editor_changed("hello", "hello")) + + def test_trailing_newline_only_is_unchanged(self): + self.assertFalse(common.editor_changed("hello", "hello\n")) + self.assertFalse(common.editor_changed("hello\n", "hello")) + + def test_real_change_is_detected(self): + self.assertTrue(common.editor_changed("hello", "hello world")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/developer_tools/bely-cli/test/test_tui_app.py b/tools/developer_tools/bely-cli/test/test_tui_app.py index df91c8135..ae0e237e8 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_app.py +++ b/tools/developer_tools/bely-cli/test/test_tui_app.py @@ -1,5 +1,7 @@ import unittest +from contextlib import nullcontext from types import SimpleNamespace +from unittest.mock import patch from textual.widgets import DataTable, Markdown, Static @@ -357,6 +359,61 @@ async def test_update_entry_key_opens_compose_prefilled_and_cancel_returns(self) self.assertEqual(type(app.screen).__name__, "BrowseScreen") self.assertEqual(screen.level, screen.LEVEL_ENTRIES) + async def test_edit_key_with_no_editor_changes_does_not_save(self): + api = FakeLogbookApi() + data = LogbookData(api) + session = FakeSession(data, factory=FakeFactory(api=api)) + app = BelyTuiApp(session, limit=10, mode="lookup") + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + await pilot.press("enter") # type -> docs + await pilot.pause() + await pilot.press("enter") # docs -> entries + await pilot.pause() + await pilot.pause() + self.assertEqual(screen.level, screen.LEVEL_ENTRIES) + + with patch.object(app, "suspend", return_value=nullcontext()), \ + patch("bely_cli.common.open_in_editor", side_effect=lambda text: text): + await pilot.press("e") + await pilot.pause() + await pilot.pause() + + self.assertEqual(type(app.screen).__name__, "BrowseScreen") + + async def test_edit_key_with_editor_changes_offers_save_and_saves(self): + api = FakeLogbookApi() + data = LogbookData(api) + session = FakeSession(data, factory=FakeFactory(api=api)) + app = BelyTuiApp(session, limit=10, mode="lookup") + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + await pilot.press("enter") # type -> docs + await pilot.pause() + await pilot.press("enter") # docs -> entries + await pilot.pause() + await pilot.pause() + self.assertEqual(screen.level, screen.LEVEL_ENTRIES) + + with patch.object(app, "suspend", return_value=nullcontext()), \ + patch("bely_cli.common.open_in_editor", + side_effect=lambda text: text + "\nedited in $EDITOR"), \ + patch.object(api, "add_update_log_entry", wraps=api.add_update_log_entry) as save_call: + await pilot.press("e") + await pilot.pause() + await pilot.pause() + self.assertEqual(type(app.screen).__name__, "ConfirmScreen") + + await pilot.press("enter") # confirm button is focused -> Save + await pilot.pause() + await pilot.pause() + + self.assertEqual(type(app.screen).__name__, "BrowseScreen") + saved_entry = save_call.call_args.kwargs["log_entry"] + self.assertIn("edited in $EDITOR", saved_entry.log_entry) + if __name__ == "__main__": unittest.main() diff --git a/tools/developer_tools/bely-cli/test/test_tui_screens.py b/tools/developer_tools/bely-cli/test/test_tui_screens.py index 3db78efca..a13249b47 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_screens.py +++ b/tools/developer_tools/bely-cli/test/test_tui_screens.py @@ -18,6 +18,7 @@ from bely_cli.tui.data import LogbookData from bely_cli.tui.screens import configscreen from bely_cli.tui.screens.compose import ComposeScreen +from bely_cli.tui.screens.confirm import ConfirmScreen from bely_cli.tui.screens.configscreen import ConfigScreen from bely_cli.tui.screens.login import LoginScreen from bely_cli.tui.screens.newdoc import NewDocScreen @@ -470,5 +471,41 @@ def fake_set_setting(key, value): self.assertEqual(saved, [("host", "https://new")]) +class ConfirmScreenTests(unittest.IsolatedAsyncioTestCase): + async def test_confirm_button_dismisses_true(self): + from textual.widgets import Button + + app = App() + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait(ConfirmScreen("Are you sure?"))) + await pilot.pause() + app.screen.query_one("#confirm-confirm", Button).press() + await pilot.pause() + result = await task.wait() + self.assertTrue(result) + + async def test_cancel_button_dismisses_false(self): + from textual.widgets import Button + + app = App() + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait(ConfirmScreen("Are you sure?"))) + await pilot.pause() + app.screen.query_one("#confirm-cancel", Button).press() + await pilot.pause() + result = await task.wait() + self.assertFalse(result) + + async def test_escape_dismisses_false(self): + app = App() + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait(ConfirmScreen("Are you sure?"))) + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + result = await task.wait() + self.assertFalse(result) + + if __name__ == "__main__": unittest.main() From e02bee3cc9d04a49bb6567eb20864158c0f572b3 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Wed, 19 Aug 2026 14:57:14 -0500 Subject: [PATCH 38/62] Add buttons for the compose dialog. --- tools/developer_tools/bely-cli/README.md | 16 +- .../src/bely_cli/tui/screens/compose.py | 99 +++++++++--- .../bely-cli/test/test_tui_app.py | 9 +- .../bely-cli/test/test_tui_screens.py | 143 ++++++++++++++++-- 4 files changed, 224 insertions(+), 43 deletions(-) diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index 2f778b1d0..de8f736b5 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -162,13 +162,15 @@ equivalent of Home's old menu items, plus what Textual provides by default: **Entry composer** -A Markdown-aware `TextArea` for the entry body, plus an optional attachment path: - -| Key | Action | -|-----|--------| -| `ctrl+s` | Save (and upload the attachment, if a path was entered). | -| `ctrl+e` | Suspend the TUI and open the buffer in `$EDITOR`; the edited text comes back into the `TextArea`. | -| `Esc` | Cancel; asks for confirmation first if the buffer has unsaved changes. | +A Markdown-aware `TextArea` for the entry body, an optional attachment path, and three +buttons (`Tab` cycles focus between the fields and buttons; `Enter` presses the focused +button): + +| Button | Action | +|--------|--------| +| Save | Save (and upload the attachment, if a path was entered). | +| Edit in $EDITOR | Suspend the TUI and open the buffer in `$EDITOR`; the edited text comes back into the `TextArea`. | +| Cancel | Cancel; asks for confirmation first if the buffer or attachment has unsaved changes. | An empty new entry is skipped rather than saved, matching `entry add`'s behavior. diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/compose.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/compose.py index d3a0d2e63..0dcec2100 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/compose.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/compose.py @@ -17,12 +17,15 @@ from textual.binding import Binding from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import Input, Static, TextArea +from textual.widgets import Button, Input, Static, TextArea from ... import core +from ...common import editor_changed class ComposeScreen(ModalScreen): + """Buttons-only entry composer; BINDINGS below are arrow-key focus navigation, not action shortcuts.""" + DEFAULT_CSS = """ ComposeScreen { align: center middle; @@ -35,14 +38,32 @@ class ComposeScreen(ModalScreen): background: $surface; padding: 1 2; } + + #compose-area { + height: 1fr; + } + + #compose-buttons { + height: auto; + align: right middle; + margin-top: 1; + } + + #compose-buttons Button { + margin-left: 1; + } """ BINDINGS = [ - Binding("ctrl+s", "submit", "Save"), - Binding("ctrl+e", "open_editor", "$EDITOR"), - Binding("escape", "cancel", "Cancel"), + Binding("up", "focus_up", show=False), + Binding("down", "focus_down", show=False), + Binding("left", "focus_left", show=False), + Binding("right", "focus_right", show=False), ] + # Save first: right after the attachment field in tab order, since it's used most. + BUTTON_IDS = ["compose-save", "compose-editor", "compose-cancel"] + def __init__(self, doc, entry, api, *, is_new): super().__init__() self.doc = doc @@ -60,36 +81,77 @@ def compose(self) -> ComposeResult: with Horizontal(id="compose-attach-row"): yield Static("Attachment:", id="compose-attach-label") yield Input(placeholder="optional file path", id="compose-attach") - yield Static( - "[ctrl+s] save [ctrl+e] edit in $EDITOR [escape] cancel", - id="compose-hint", - ) + with Horizontal(id="compose-buttons"): + yield Button("Save", variant="primary", id="compose-save") + yield Button("Edit in $EDITOR", id="compose-editor") + yield Button("Cancel", id="compose-cancel") def on_mount(self): self.query_one("#compose-area", TextArea).focus() - # -- cancel, with a dirty-buffer confirmation -- + def on_button_pressed(self, event): + if event.button.id == "compose-save": + self._save() + elif event.button.id == "compose-editor": + self._open_editor() + elif event.button.id == "compose-cancel": + self._cancel() + + # -- arrow-key nav: TextArea/Input consume arrows themselves, so this only fires past both -- + + def action_focus_down(self): + if self.focused is self.query_one("#compose-attach", Input): + self.query_one(f"#{self.BUTTON_IDS[0]}", Button).focus() - def action_cancel(self): + def action_focus_up(self): + if isinstance(self.focused, Button): + self.query_one("#compose-attach", Input).focus() + + def action_focus_left(self): + self._cycle_button(-1) + + def action_focus_right(self): + self._cycle_button(1) + + def _cycle_button(self, delta): + focused = self.focused + if not isinstance(focused, Button): + return + idx = self.BUTTON_IDS.index(focused.id) + target_id = self.BUTTON_IDS[(idx + delta) % len(self.BUTTON_IDS)] + self.query_one(f"#{target_id}", Button).focus() + + # -- dirty check shared by cancel and save -- + + def _is_dirty(self): area = self.query_one("#compose-area", TextArea) - if area.text != self._initial_text: + attach_path = self.query_one("#compose-attach", Input).value.strip() + return editor_changed(self._initial_text, area.text) or bool(attach_path) + + # -- cancel, with a dirty-buffer confirmation -- + + def _cancel(self): + if self._is_dirty(): self._confirm_discard() else: self.dismiss(None) @work async def _confirm_discard(self): - from .picker import PickerScreen + from .confirm import ConfirmScreen - choice = await self.app.push_screen_wait( - PickerScreen("Discard unsaved changes?", ["Discard", "Keep editing"], lambda x: x) + discard = await self.app.push_screen_wait( + ConfirmScreen( + "Discard unsaved changes?", confirm_label="Discard", cancel_label="Keep editing", + confirm_variant="error", + ) ) - if choice == "Discard": + if discard: self.dismiss(None) # -- hand off to $EDITOR and back -- - def action_open_editor(self): + def _open_editor(self): from ...common import open_in_editor area = self.query_one("#compose-area", TextArea) @@ -99,9 +161,6 @@ def action_open_editor(self): # -- save -- - def action_submit(self): - self._save() - @work async def _save(self): area = self.query_one("#compose-area", TextArea) @@ -120,7 +179,7 @@ async def _save(self): self.notify(str(e), severity="error") return - if not self.is_new and text == self._initial_text and not attach_path: + if not self.is_new and not editor_changed(self._initial_text, text) and not attach_path: self.notify("No changes made.") self.dismiss(None) return diff --git a/tools/developer_tools/bely-cli/test/test_tui_app.py b/tools/developer_tools/bely-cli/test/test_tui_app.py index ae0e237e8..c363716da 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_app.py +++ b/tools/developer_tools/bely-cli/test/test_tui_app.py @@ -317,10 +317,10 @@ async def test_new_entry_key_creates_entry_and_refreshes(self): await pilot.pause() self.assertEqual(type(app.screen).__name__, "ComposeScreen") - from textual.widgets import TextArea + from textual.widgets import Button, TextArea app.screen.query_one("#compose-area", TextArea).text = "new content" - await pilot.press("ctrl+s") + app.screen.query_one("#compose-save", Button).press() await pilot.pause() await pilot.pause() @@ -349,12 +349,13 @@ async def test_update_entry_key_opens_compose_prefilled_and_cancel_returns(self) await pilot.pause() self.assertEqual(type(app.screen).__name__, "ComposeScreen") - from textual.widgets import TextArea + from textual.widgets import Button, TextArea self.assertEqual( app.screen.query_one("#compose-area", TextArea).text, "# Hello\n\nBody text") - await pilot.press("escape") # no changes made -> dismiss immediately, no save + # no changes made -> Cancel dismisses immediately, no confirmation, no save + app.screen.query_one("#compose-cancel", Button).press() await pilot.pause() self.assertEqual(type(app.screen).__name__, "BrowseScreen") self.assertEqual(screen.level, screen.LEVEL_ENTRIES) diff --git a/tools/developer_tools/bely-cli/test/test_tui_screens.py b/tools/developer_tools/bely-cli/test/test_tui_screens.py index a13249b47..487a88d23 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_screens.py +++ b/tools/developer_tools/bely-cli/test/test_tui_screens.py @@ -12,7 +12,7 @@ from unittest.mock import patch from textual.app import App -from textual.widgets import Input, Static, TextArea +from textual.widgets import Button, Input, Static, TextArea from bely_cli.tui.app import BelyTuiApp from bely_cli.tui.data import LogbookData @@ -210,7 +210,7 @@ async def test_escape_cancels_with_none(self): class ComposeScreenTests(unittest.IsolatedAsyncioTestCase): - async def test_ctrl_s_saves_and_dismisses_with_entry(self): + async def test_save_button_saves_and_dismisses_with_entry(self): api = FakeLogbookApi() doc = SimpleNamespace(id=1, name="Doc") entry = SimpleNamespace(log_id=None, log_entry="") @@ -221,7 +221,7 @@ async def test_ctrl_s_saves_and_dismisses_with_entry(self): await pilot.pause() area = app.screen.query_one("#compose-area", TextArea) area.text = "hello world" - await pilot.press("ctrl+s") + app.screen.query_one("#compose-save", Button).press() await pilot.pause() await pilot.pause() saved = await task.wait() @@ -237,13 +237,13 @@ async def test_empty_new_entry_is_skipped_without_saving(self): task = app.run_worker( app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=True))) await pilot.pause() - await pilot.press("ctrl+s") + app.screen.query_one("#compose-save", Button).press() await pilot.pause() await pilot.pause() result = await task.wait() self.assertIsNone(result) - async def test_escape_without_changes_dismisses_immediately(self): + async def test_cancel_without_changes_dismisses_immediately(self): api = FakeLogbookApi() doc = SimpleNamespace(id=1, name="Doc") entry = SimpleNamespace(log_id=7, log_entry="existing text") @@ -252,12 +252,12 @@ async def test_escape_without_changes_dismisses_immediately(self): task = app.run_worker( app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False))) await pilot.pause() - await pilot.press("escape") + app.screen.query_one("#compose-cancel", Button).press() await pilot.pause() result = await task.wait() self.assertIsNone(result) - async def test_escape_with_unsaved_changes_prompts_discard_confirmation(self): + async def test_cancel_with_unsaved_changes_prompts_discard_confirmation(self): api = FakeLogbookApi() doc = SimpleNamespace(id=1, name="Doc") entry = SimpleNamespace(log_id=7, log_entry="existing text") @@ -268,16 +268,135 @@ async def test_escape_with_unsaved_changes_prompts_discard_confirmation(self): await pilot.pause() area = app.screen.query_one("#compose-area", TextArea) area.text = "existing text, changed" - await pilot.press("escape") + app.screen.query_one("#compose-cancel", Button).press() await pilot.pause() - self.assertEqual(type(app.screen).__name__, "PickerScreen") - await pilot.press("enter") # filter -> list - await pilot.pause() - await pilot.press("enter") # confirm "Discard" (highlighted first) + self.assertEqual(type(app.screen).__name__, "ConfirmScreen") + app.screen.query_one("#confirm-confirm", Button).press() # "Discard" await pilot.pause() result = await task.wait() self.assertIsNone(result) + async def test_cancel_with_unsaved_changes_keep_editing_returns_to_compose(self): + api = FakeLogbookApi() + doc = SimpleNamespace(id=1, name="Doc") + entry = SimpleNamespace(log_id=7, log_entry="existing text") + app = App() + async with app.run_test() as pilot: + task = app.run_worker( + app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False))) + await pilot.pause() + area = app.screen.query_one("#compose-area", TextArea) + area.text = "existing text, changed" + app.screen.query_one("#compose-cancel", Button).press() + await pilot.pause() + self.assertEqual(type(app.screen).__name__, "ConfirmScreen") + app.screen.query_one("#confirm-cancel", Button).press() # "Keep editing" + await pilot.pause() + self.assertEqual(type(app.screen).__name__, "ComposeScreen") + self.assertFalse(task.is_finished) + + async def test_editor_button_opens_editor_and_updates_buffer(self): + from contextlib import nullcontext + + api = FakeLogbookApi() + doc = SimpleNamespace(id=1, name="Doc") + entry = SimpleNamespace(log_id=7, log_entry="existing text") + app = App() + async with app.run_test() as pilot: + app.run_worker(app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False))) + await pilot.pause() + screen = app.screen + with patch.object(app, "suspend", return_value=nullcontext()), \ + patch("bely_cli.common.open_in_editor", + side_effect=lambda text: text + " edited"): + screen.query_one("#compose-editor", Button).press() + await pilot.pause() + self.assertEqual( + screen.query_one("#compose-area", TextArea).text, "existing text edited") + # The buffer is now dirty from the editor round-trip; Cancel would + # open the discard-confirmation dialog -- not what this test covers. + + async def test_tab_from_attachment_field_reaches_save_next(self): + api = FakeLogbookApi() + doc = SimpleNamespace(id=1, name="Doc") + entry = SimpleNamespace(log_id=7, log_entry="existing text") + app = App() + async with app.run_test() as pilot: + app.run_worker(app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False))) + await pilot.pause() + screen = app.screen + screen.query_one("#compose-attach", Input).focus() + await pilot.pause() + await pilot.press("tab") + await pilot.pause() + self.assertIs(screen.focused, screen.query_one("#compose-save", Button)) + + async def test_down_and_up_arrows_move_between_attachment_and_buttons(self): + api = FakeLogbookApi() + doc = SimpleNamespace(id=1, name="Doc") + entry = SimpleNamespace(log_id=7, log_entry="existing text") + app = App() + async with app.run_test() as pilot: + app.run_worker(app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False))) + await pilot.pause() + screen = app.screen + screen.query_one("#compose-attach", Input).focus() + await pilot.pause() + + await pilot.press("down") + await pilot.pause() + self.assertIs(screen.focused, screen.query_one("#compose-save", Button)) + + await pilot.press("up") + await pilot.pause() + self.assertIs(screen.focused, screen.query_one("#compose-attach", Input)) + + async def test_left_and_right_arrows_cycle_between_buttons(self): + api = FakeLogbookApi() + doc = SimpleNamespace(id=1, name="Doc") + entry = SimpleNamespace(log_id=7, log_entry="existing text") + app = App() + async with app.run_test() as pilot: + app.run_worker(app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False))) + await pilot.pause() + screen = app.screen + screen.query_one("#compose-save", Button).focus() + await pilot.pause() + + await pilot.press("right") + await pilot.pause() + self.assertIs(screen.focused, screen.query_one("#compose-editor", Button)) + + await pilot.press("right") + await pilot.pause() + self.assertIs(screen.focused, screen.query_one("#compose-cancel", Button)) + + await pilot.press("right") # wraps back to the first button + await pilot.pause() + self.assertIs(screen.focused, screen.query_one("#compose-save", Button)) + + await pilot.press("left") # wraps the other way + await pilot.pause() + self.assertIs(screen.focused, screen.query_one("#compose-cancel", Button)) + + async def test_arrow_keys_in_textarea_move_the_cursor_not_focus(self): + api = FakeLogbookApi() + doc = SimpleNamespace(id=1, name="Doc") + entry = SimpleNamespace(log_id=7, log_entry="line one\nline two") + app = App() + async with app.run_test() as pilot: + app.run_worker(app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False))) + await pilot.pause() + screen = app.screen + area = screen.query_one("#compose-area", TextArea) + area.focus() + await pilot.pause() + + for key in ("down", "up", "left", "right"): + await pilot.press(key) + await pilot.pause() + self.assertIs(screen.focused, area) + class NewDocScreenPrefillTests(unittest.IsolatedAsyncioTestCase): async def test_prefilled_logbook_type_skips_the_type_picker(self): From a0b7aa23cec48b921862a3efd26ec2f4f78ac63a Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Wed, 19 Aug 2026 14:59:28 -0500 Subject: [PATCH 39/62] clean up comment. --- tools/developer_tools/bely-cli/test/test_tui_screens.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/developer_tools/bely-cli/test/test_tui_screens.py b/tools/developer_tools/bely-cli/test/test_tui_screens.py index 487a88d23..415c81d72 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_screens.py +++ b/tools/developer_tools/bely-cli/test/test_tui_screens.py @@ -313,8 +313,7 @@ async def test_editor_button_opens_editor_and_updates_buffer(self): await pilot.pause() self.assertEqual( screen.query_one("#compose-area", TextArea).text, "existing text edited") - # The buffer is now dirty from the editor round-trip; Cancel would - # open the discard-confirmation dialog -- not what this test covers. + # buffer is dirty now, so skip Cancel here -- not what this test covers async def test_tab_from_attachment_field_reaches_save_next(self): api = FakeLogbookApi() From d216b098238c857452c60eb67d87e6a7881f6d50 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Wed, 19 Aug 2026 15:11:50 -0500 Subject: [PATCH 40/62] Resolve issue where filter is not visible when looking up logbook or log document. --- .../bely-cli/src/bely_cli/tui/app.py | 3 ++ .../src/bely_cli/tui/screens/browse.py | 5 ++- .../bely-cli/test/test_tui_app.py | 41 ++++++++++++++++++- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py index f82ee4648..f8ab19522 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py @@ -64,7 +64,10 @@ class BelyTuiApp(App): #filter { width: 1fr; + height: 1; margin: 0 1; + border: none; + padding: 0; } #status-right { diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py index 5dfb2cac8..8be3941f2 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py @@ -118,7 +118,7 @@ def compose(self) -> ComposeResult: yield Markdown(id="body-md") with Horizontal(id="status-bar"): yield Static(id="status-left") - yield Input(id="filter", placeholder="type to filter") + yield Input(id="filter", placeholder="type to filter", compact=True) yield Static(id="status-right") yield Footer() @@ -169,6 +169,9 @@ def show_level(self, level, *, preserve_filter=False): self.refresh_bindings() nav = self._nav() if not preserve_filter: + # Avoid a stale-item preview if clearing the filter's async Changed lands before the new level's fetch does. + self.all_items = [] + self.shown_items = [] filt = self.query_one("#filter", Input) filt.value = "" filt.display = False diff --git a/tools/developer_tools/bely-cli/test/test_tui_app.py b/tools/developer_tools/bely-cli/test/test_tui_app.py index c363716da..09299c3fb 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_app.py +++ b/tools/developer_tools/bely-cli/test/test_tui_app.py @@ -3,7 +3,7 @@ from types import SimpleNamespace from unittest.mock import patch -from textual.widgets import DataTable, Markdown, Static +from textual.widgets import DataTable, Input, Markdown, Static from bely_cli.tui.app import BelyTuiApp from bely_cli.tui.data import LogbookData @@ -184,6 +184,45 @@ async def test_filter_narrows_the_list(self): screen._apply_filter("") self.assertEqual(len(screen.shown_items), 1) + async def test_filter_box_fits_the_one_row_status_bar(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + await pilot.press("/") + await pilot.pause() + filt = screen.query_one("#filter", Input) + status_bar = screen.query_one("#status-bar") + self.assertTrue(filt.display) + self.assertEqual(filt.region.height, status_bar.region.height) + + async def test_show_level_clears_stale_items_so_a_filter_race_cant_preview_them(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + await pilot.press("enter") # type -> docs + await pilot.pause() + await pilot.pause() + self.assertEqual(screen.level, screen.LEVEL_DOCS) + + screen._apply_filter("Shift") # non-empty filter, like the user hit '/' + self.assertEqual(len(screen.shown_items), 1) + + screen.sel_doc = screen.shown_items[0] + screen.show_level(screen.LEVEL_ENTRIES) # drill in, entries fetch still pending + self.assertEqual(screen.all_items, []) + self.assertEqual(screen.shown_items, []) + + # simulate the filter's queued Input.Changed landing before the fetch does + screen._apply_filter("") # must not crash previewing the stale doc's .log_entry + + await pilot.pause() # let the real entries fetch complete and repopulate + self.assertEqual(screen.level, screen.LEVEL_ENTRIES) + self.assertEqual(screen.shown_items[0].log_id, 100) + async def test_filter_narrows_table_row_count(self): data = LogbookData(FakeLogbookApi()) app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") From 60330b5ea51749813b5edb7d6999d1521f21b85d Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Wed, 19 Aug 2026 15:23:46 -0500 Subject: [PATCH 41/62] Resolve test issue --- .../bely-cli/src/bely_cli/tui/screens/browse.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py index 8be3941f2..fde811599 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py @@ -22,6 +22,7 @@ """ import asyncio +from functools import partial from textual import work from textual.app import ComposeResult @@ -165,6 +166,8 @@ def _ensure_columns(self): def show_level(self, level, *, preserve_filter=False): self.level = level + # cancel any in-flight preview worker so a stale, now-mistyped item can't reach _show_preview + self.app.workers.cancel_group(self, "preview") self._sync_panes() self.refresh_bindings() nav = self._nav() @@ -262,7 +265,7 @@ def _apply_filter(self, query): # DataTable.clear() leaves the cursor at (0, 0); if it was # already there, RowHighlighted won't fire, so drive the # initial preview explicitly instead of relying on it. - self.run_worker(self._show_preview(self.shown_items[0]), exclusive=True, group="preview") + self.run_worker(partial(self._show_preview, self.shown_items[0]), exclusive=True, group="preview") else: self.query_one("#meta", Static).update("(no matches)") self.query_one("#body-md", Markdown).display = False From 2f5909ee313c7d67ffe09f6dcee996f654f61445 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Wed, 19 Aug 2026 15:31:18 -0500 Subject: [PATCH 42/62] Allow saving the theme. --- tools/developer_tools/bely-cli/README.md | 9 ++-- .../bely-cli/src/bely_cli/config.py | 2 +- .../bely-cli/src/bely_cli/tui/app.py | 13 +++++- .../bely-cli/test/test_tui_app.py | 42 +++++++++++++++++++ 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index de8f736b5..77011bfc1 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -157,7 +157,7 @@ equivalent of Home's old menu items, plus what Textual provides by default: | My documents | Opens a browse starting at your recently modified documents — equivalent of `doc list`. `Esc` pops back to wherever you opened it from. | | Log in | Authenticate now instead of waiting for the first mutation. | | Refresh cache | Discard all cached logbook data so the next view re-fetches from the server. | -| Theme | Built-in: change the app's color theme for this session. | +| Theme | Built-in: change the app's color theme; the choice is saved as the `theme` setting and reused on the next launch. | | Quit | Built-in: exit the app. | **Entry composer** @@ -353,21 +353,22 @@ Open the settings file in your editor. The editor is resolved from `EDITOR`, the #### `bely-cli config set FIELD VALUE` -Set a single configuration field. `FIELD` is one of `host`, `user`, `editor`, or -`token_path`. +Set a single configuration field. `FIELD` is one of `host`, `user`, `editor`, +`token_path`, or `theme`. ```bash bely-cli config set user alice bely-cli config set host https://tinkerbox.aps.anl.gov:8181/bely bely-cli config set editor nano bely-cli config set token_path ~/.secrets/bely-token +bely-cli config set theme nord ``` ## Configuration & environment | Location / variable | Purpose | |---------------------|---------| -| `~/.config/bely/settings.yaml` | Persistent settings (`host`, `user`, `editor`, `token_path`); permissions `0600`. | +| `~/.config/bely/settings.yaml` | Persistent settings (`host`, `user`, `editor`, `token_path`, `theme`); permissions `0600`. | | `~/.config/bely/token` | Cached auth token; permissions `0600`. Override with the `token_path` setting. | | `BELY_SETTINGS_FILE` | Path to the settings file (overrides the default location). The default token sits beside it. | | `BELY_HOST` | Server URL (overrides the settings file). | diff --git a/tools/developer_tools/bely-cli/src/bely_cli/config.py b/tools/developer_tools/bely-cli/src/bely_cli/config.py index 2d4613fd8..870658d53 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/config.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/config.py @@ -3,7 +3,7 @@ DEFAULT_CONFIG_DIR = os.path.expanduser("~/.config/bely") -VALID_FIELDS = ("host", "user", "editor", "token_path") +VALID_FIELDS = ("host", "user", "editor", "token_path", "theme") def expand_path(path): diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py index f8ab19522..54e4cd39e 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py @@ -24,8 +24,11 @@ from textual.app import App, SystemCommand +from .. import config from .screens.browse import BrowseScreen +DEFAULT_THEME = "textual-dark" + class BelyTuiApp(App): """Top-level app: pushes Browse and returns its exit result.""" @@ -80,11 +83,19 @@ def __init__(self, session, limit=100, mode="app"): self.session = session self.limit = limit self.mode = mode + self._theme_loaded = False def on_mount(self): - self.theme = "textual-dark" + saved = config.get_setting("theme") + self.theme = saved if saved in self.available_themes else DEFAULT_THEME + self._theme_loaded = True self.push_screen(BrowseScreen(self.session, self.limit, select_mode=(self.mode == "lookup"))) + def watch_theme(self, theme_name): + """Persist a theme picked from the built-in palette command (not the initial on_mount load).""" + if self._theme_loaded: + config.set_setting("theme", theme_name) + def get_system_commands(self, screen): yield from super().get_system_commands(screen) yield SystemCommand( diff --git a/tools/developer_tools/bely-cli/test/test_tui_app.py b/tools/developer_tools/bely-cli/test/test_tui_app.py index 09299c3fb..84d8aa0f3 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_app.py +++ b/tools/developer_tools/bely-cli/test/test_tui_app.py @@ -455,5 +455,47 @@ async def test_edit_key_with_editor_changes_offers_save_and_saves(self): self.assertIn("edited in $EDITOR", saved_entry.log_entry) +class ThemePersistenceTests(unittest.IsolatedAsyncioTestCase): + async def test_no_saved_theme_falls_back_to_default(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + with patch("bely_cli.tui.app.config.get_setting", return_value=None), \ + patch("bely_cli.tui.app.config.set_setting") as set_setting: + async with app.run_test() as pilot: + await pilot.pause() + self.assertEqual(app.theme, "textual-dark") + set_setting.assert_not_called() + + async def test_saved_valid_theme_is_loaded_without_repersisting(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + with patch("bely_cli.tui.app.config.get_setting", return_value="nord"), \ + patch("bely_cli.tui.app.config.set_setting") as set_setting: + async with app.run_test() as pilot: + await pilot.pause() + self.assertEqual(app.theme, "nord") + set_setting.assert_not_called() + + async def test_unknown_saved_theme_falls_back_to_default(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + with patch("bely_cli.tui.app.config.get_setting", return_value="not-a-real-theme"), \ + patch("bely_cli.tui.app.config.set_setting"): + async with app.run_test() as pilot: + await pilot.pause() + self.assertEqual(app.theme, "textual-dark") + + async def test_changing_theme_after_mount_persists_it(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + with patch("bely_cli.tui.app.config.get_setting", return_value=None), \ + patch("bely_cli.tui.app.config.set_setting") as set_setting: + async with app.run_test() as pilot: + await pilot.pause() + app.theme = "gruvbox" + await pilot.pause() + set_setting.assert_called_once_with("theme", "gruvbox") + + if __name__ == "__main__": unittest.main() From f08095170f9814ace2bac810e80602ce7c00d7a4 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 20 Aug 2026 09:15:07 -0500 Subject: [PATCH 43/62] Add ability base functioanlity to parse out images from the markdown. --- .../bely-cli/src/bely_cli/core.py | 15 +++ .../bely-cli/src/bely_cli/tui/data.py | 36 ++++++- .../bely-cli/src/bely_cli/tui/mdimages.py | 100 ++++++++++++++++++ .../bely-cli/src/bely_cli/tui/session.py | 2 +- .../bely-cli/test/test_core.py | 27 +++++ .../bely-cli/test/test_tui_data.py | 78 +++++++++++++- .../bely-cli/test/test_tui_mdimages.py | 82 ++++++++++++++ 7 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/mdimages.py create mode 100644 tools/developer_tools/bely-cli/test/test_tui_mdimages.py diff --git a/tools/developer_tools/bely-cli/src/bely_cli/core.py b/tools/developer_tools/bely-cli/src/bely_cli/core.py index a4a5ce732..d3181b176 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/core.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/core.py @@ -195,6 +195,21 @@ def upload_attachment(logbook_api, doc_id, log_id, path): } +def download_attachment(download_api, stored_filename, scaling=None): + """Return an attachment's raw bytes, optionally a server-scaled variant. + + The plain get_attachment()/get_attachment1() wrappers discard the response + body (their _response_types_map maps '200' to None) -- only the + _without_preload_content variants return the actual bytes. + """ + if scaling: + response = download_api.get_attachment1_without_preload_content( + stored_filename, scaling) + else: + response = download_api.get_attachment_without_preload_content(stored_filename) + return response.data + + # -- config -- def collect_config(): diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py index 45f10229f..5e91c84f6 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py @@ -17,8 +17,14 @@ class LogbookData: """Wraps logbook_api with the caching the TUI needs.""" - def __init__(self, logbook_api): + # Bound on the image-bytes cache: images are much larger than the metadata + # the other caches hold, so unlike those this one evicts (FIFO) rather + # than growing without limit for the life of a session. + MAX_CACHED_IMAGES = 32 + + def __init__(self, logbook_api, download_api=None): self._logbook_api = logbook_api + self._download_api = download_api self._types = None self._systems = None @@ -27,6 +33,7 @@ def __init__(self, logbook_api): self._entries = {} # doc_id -> list of LogEntry self._attachments = {} # (doc_id, log_id) -> list of LogEntryAttachment self._recent = {} # username -> list of recent documents + self._image_bytes = {} # (stored_filename, scaling) -> bytes, FIFO-bounded def logbook_types(self): if self._types is None: @@ -79,6 +86,31 @@ def attachments(self, doc_id, log_id): self._attachments[key] = attachments return attachments + def attachment_bytes(self, stored_filename, scaling="scaled"): + """Raw image bytes for an attachment, preferring a server-scaled variant. + + Falls back to the unscaled original if the scaled fetch fails (e.g. the + server doesn't have a scaled variant for this file type). Failures are + not cached, matching the other caches' rule; a successful fetch is + cached per (stored_filename, scaling), FIFO-evicted past + MAX_CACHED_IMAGES since images are far larger than the metadata the + other caches hold. + """ + key = (stored_filename, scaling) + data = self._image_bytes.get(key) + if data is None: + try: + data = core.download_attachment(self._download_api, stored_filename, scaling) + except Exception: + if not scaling: + raise + data = core.download_attachment(self._download_api, stored_filename) + self._image_bytes[key] = data + if len(self._image_bytes) > self.MAX_CACHED_IMAGES: + oldest = next(iter(self._image_bytes)) + del self._image_bytes[oldest] + return data + def invalidate(self, level, type_id=None, doc_id=None, username=None): """Drop the cache for one level so the next fetch hits the network. @@ -101,6 +133,7 @@ def invalidate(self, level, type_id=None, doc_id=None, username=None): if doc_id is None: self._entries.clear() self._attachments.clear() + self._image_bytes.clear() else: self._entries.pop(doc_id, None) for key in [k for k in self._attachments if k[0] == doc_id]: @@ -122,3 +155,4 @@ def clear(self): self._entries.clear() self._attachments.clear() self._recent.clear() + self._image_bytes.clear() diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/mdimages.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/mdimages.py new file mode 100644 index 000000000..7d1a2c108 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/mdimages.py @@ -0,0 +1,100 @@ +"""Split entry markdown into renderable segments: plain markdown vs. images. + +No textual/rich import here -- pure data-in/data-out logic so it can be unit +tested without a terminal (see test/test_tui_mdimages.py), matching the style +of format.py. Only markdown_it is used, which textual already depends on. + +Server-side, an uploaded image gets a markdown reference of the form +`![name](/log/attachments/)` appended to the entry body in its own +paragraph (LogAttachmentUtility.java); the web portal rewrites that prefix to +`/api/Downloads/Attachments/` when rendering. We recognize either +form. Non-image attachments (pdf, etc.) and any other URL are left as plain +markdown links -- textual's Markdown widget already renders those as text. +""" + +from markdown_it import MarkdownIt + +IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif") + +_ATTACHMENT_PREFIXES = ("/log/attachments/", "/api/Downloads/Attachments/") + + +def attachment_name(src): + """The stored filename if src is a renderable BELY attachment reference, else None. + + Rejects external URLs (http://, https://) and non-image attachments (pdf) -- + those stay as ordinary markdown links, never fetched. + """ + if not src: + return None + for prefix in _ATTACHMENT_PREFIXES: + if src.startswith(prefix): + name = src[len(prefix):].split("/")[0] + if name.lower().endswith(IMAGE_EXTENSIONS): + return name + return None + return None + + +def _image_only_children(children): + """children of an inline token, if they are exclusively image(s) plus whitespace.""" + images = [] + for child in children: + if child.type == "image": + images.append(child) + elif child.type == "text" and not child.content.strip(): + continue + elif child.type == "softbreak": + continue + else: + return None + return images or None + + +def split_entry_markdown(text): + """Split entry markdown into ("markdown", str) / ("image", stored_name, alt) segments. + + A paragraph whose inline content is exclusively image(s) (as the server + appends after an upload) becomes one or more image segments; everything + else -- including a paragraph that mixes text and an image -- is left as + markdown, verbatim. Returns a single ("markdown", text) segment when + nothing is renderable, so the caller can take the cheap unsplit path. + """ + text = text or "" + lines = text.splitlines(keepends=True) + tokens = MarkdownIt("gfm-like").parse(text) + + segments = [] + md_start = 0 # line index where the pending markdown chunk begins + + def flush_markdown(end_line): + if end_line > md_start: + chunk = "".join(lines[md_start:end_line]) + if chunk.strip(): + segments.append(("markdown", chunk)) + + i = 0 + while i < len(tokens): + token = tokens[i] + if token.type == "paragraph_open" and token.map: + inline = tokens[i + 1] if i + 1 < len(tokens) else None + images = None + if inline is not None and inline.type == "inline" and inline.children: + images = _image_only_children(inline.children) + if images is not None: + names = [(attachment_name(img.attrs.get("src", "")), img.content) + for img in images] + if all(name for name, _alt in names): + flush_markdown(token.map[0]) + for name, alt in names: + segments.append(("image", name, alt)) + md_start = token.map[1] + i += 3 # paragraph_open, inline, paragraph_close + continue + i += 1 + + flush_markdown(len(lines)) + + if not segments: + return [("markdown", text)] + return segments diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/session.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/session.py index c152514d5..df3060a48 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/session.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/session.py @@ -19,7 +19,7 @@ class TuiSession: def __init__(self, factory): self.factory = factory - self.data = LogbookData(factory.get_logbook_api()) + self.data = LogbookData(factory.get_logbook_api(), factory.get_download_api()) self._auth_factory = None def username(self): diff --git a/tools/developer_tools/bely-cli/test/test_core.py b/tools/developer_tools/bely-cli/test/test_core.py index 0d6a1be31..400a6ee4c 100644 --- a/tools/developer_tools/bely-cli/test/test_core.py +++ b/tools/developer_tools/bely-cli/test/test_core.py @@ -212,6 +212,33 @@ def test_upload_attachment_returns_dict(self): self.assertEqual(api.uploaded[1], 99) +class FakeDownloadApi: + def __init__(self): + self.calls = [] + + def get_attachment_without_preload_content(self, attachment_name): + self.calls.append(("plain", attachment_name)) + return SimpleNamespace(data=b"original-bytes") + + def get_attachment1_without_preload_content(self, attachment_name, scaling): + self.calls.append(("scaled", attachment_name, scaling)) + return SimpleNamespace(data=b"scaled-bytes") + + +class DownloadAttachmentTests(unittest.TestCase): + def test_no_scaling_uses_plain_endpoint(self): + api = FakeDownloadApi() + data = core.download_attachment(api, "attachment.1.png") + self.assertEqual(data, b"original-bytes") + self.assertEqual(api.calls, [("plain", "attachment.1.png")]) + + def test_scaling_uses_scaled_endpoint(self): + api = FakeDownloadApi() + data = core.download_attachment(api, "attachment.1.png", "scaled") + self.assertEqual(data, b"scaled-bytes") + self.assertEqual(api.calls, [("scaled", "attachment.1.png", "scaled")]) + + class RecentDocumentsTests(unittest.TestCase): def test_sorts_by_last_modified_desc_and_truncates(self): import datetime as dt diff --git a/tools/developer_tools/bely-cli/test/test_tui_data.py b/tools/developer_tools/bely-cli/test/test_tui_data.py index d4ca97d96..eda1ff27a 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_data.py +++ b/tools/developer_tools/bely-cli/test/test_tui_data.py @@ -49,6 +49,22 @@ def get_logbook_templates(self): return [SimpleNamespace(id=1, name="tmpl-a")] +class FakeDownloadApi: + def __init__(self): + self.calls = [] + self.fail_scaled = set() # stored_filenames whose scaled fetch should fail + + def get_attachment1_without_preload_content(self, attachment_name, scaling): + self.calls.append(("scaled", attachment_name, scaling)) + if attachment_name in self.fail_scaled: + raise RuntimeError("no scaled variant") + return SimpleNamespace(data=f"scaled:{attachment_name}".encode()) + + def get_attachment_without_preload_content(self, attachment_name): + self.calls.append(("plain", attachment_name)) + return SimpleNamespace(data=f"plain:{attachment_name}".encode()) + + class FakeSearchResults: def __init__(self, docs): self.document_results = docs @@ -90,7 +106,8 @@ def get_search_api(self): class LogbookDataCachingTests(unittest.TestCase): def setUp(self): self.api = FakeApi() - self.data = LogbookData(self.api) + self.download_api = FakeDownloadApi() + self.data = LogbookData(self.api, self.download_api) def test_types_fetched_once_then_cached(self): self.data.logbook_types() @@ -153,6 +170,12 @@ def test_invalidate_entries_by_doc_id_also_drops_its_attachments(self): self.assertEqual(self.api.calls.count(("entries", 10, True, True)), 2) self.assertEqual(self.api.calls.count(("attachments", 10, 100)), 2) + def test_invalidate_entries_without_doc_id_also_drops_image_cache(self): + self.data.attachment_bytes("a.png") + self.data.invalidate("entries") + self.data.attachment_bytes("a.png") + self.assertEqual(self.download_api.calls.count(("scaled", "a.png", "scaled")), 2) + def test_invalidate_unknown_level_raises(self): with self.assertRaises(ValueError): self.data.invalidate("bogus") @@ -186,6 +209,7 @@ def test_clear_drops_every_cache(self): self.data.documents(1, 100) self.data.entries(10) self.data.attachments(10, 100) + self.data.attachment_bytes("a.png") self.data.clear() @@ -195,12 +219,14 @@ def test_clear_drops_every_cache(self): self.data.documents(1, 100) self.data.entries(10) self.data.attachments(10, 100) + self.data.attachment_bytes("a.png") self.assertEqual(self.api.calls.count(("types",)), 2) self.assertEqual(self.api.calls.count(("systems",)), 2) self.assertEqual(self.api.calls.count(("templates",)), 2) self.assertEqual(self.api.calls.count(("docs", 1, 100)), 2) self.assertEqual(self.api.calls.count(("entries", 10, True, True)), 2) self.assertEqual(self.api.calls.count(("attachments", 10, 100)), 2) + self.assertEqual(self.download_api.calls.count(("scaled", "a.png", "scaled")), 2) class RecentDocumentsCachingTests(unittest.TestCase): @@ -247,5 +273,55 @@ def test_clear_drops_recent_too(self): self.assertEqual(self.factory.calls.count(("user", "alice")), 2) +class AttachmentBytesCachingTests(unittest.TestCase): + def setUp(self): + self.download_api = FakeDownloadApi() + self.data = LogbookData(FakeApi(), self.download_api) + + def test_fetches_scaled_by_default(self): + data = self.data.attachment_bytes("a.png") + self.assertEqual(data, b"scaled:a.png") + self.assertEqual(self.download_api.calls, [("scaled", "a.png", "scaled")]) + + def test_cached_per_filename_and_scaling(self): + self.data.attachment_bytes("a.png") + self.data.attachment_bytes("a.png") + self.data.attachment_bytes("a.png", scaling=None) + self.assertEqual(self.download_api.calls.count(("scaled", "a.png", "scaled")), 1) + self.assertEqual(self.download_api.calls.count(("plain", "a.png")), 1) + + def test_falls_back_to_unscaled_when_scaled_fetch_fails(self): + self.download_api.fail_scaled.add("a.png") + data = self.data.attachment_bytes("a.png") + self.assertEqual(data, b"plain:a.png") + self.assertEqual(self.download_api.calls, [ + ("scaled", "a.png", "scaled"), ("plain", "a.png"), + ]) + + def test_fallback_result_is_cached(self): + self.download_api.fail_scaled.add("a.png") + self.data.attachment_bytes("a.png") + self.data.attachment_bytes("a.png") + self.assertEqual(self.download_api.calls.count(("plain", "a.png")), 1) + + def test_failures_are_not_cached(self): + # No scaling requested, so a failure propagates and must not be cached. + self.download_api.get_attachment_without_preload_content = lambda name: (_ for _ in ()).throw( + RuntimeError("boom")) + with self.assertRaises(RuntimeError): + self.data.attachment_bytes("a.png", scaling=None) + self.assertEqual(len(self.data._image_bytes), 0) + + def test_evicts_oldest_past_bound(self): + self.data.MAX_CACHED_IMAGES = 2 + self.data.attachment_bytes("a.png") + self.data.attachment_bytes("b.png") + self.data.attachment_bytes("c.png") + self.assertEqual(len(self.data._image_bytes), 2) + # "a.png" was evicted, so fetching it again hits the network. + self.data.attachment_bytes("a.png") + self.assertEqual(self.download_api.calls.count(("scaled", "a.png", "scaled")), 2) + + if __name__ == "__main__": unittest.main() diff --git a/tools/developer_tools/bely-cli/test/test_tui_mdimages.py b/tools/developer_tools/bely-cli/test/test_tui_mdimages.py new file mode 100644 index 000000000..5f38ff84d --- /dev/null +++ b/tools/developer_tools/bely-cli/test/test_tui_mdimages.py @@ -0,0 +1,82 @@ +import unittest + +from bely_cli.tui.mdimages import attachment_name, split_entry_markdown + + +class AttachmentNameTests(unittest.TestCase): + def test_log_attachments_prefix(self): + self.assertEqual( + attachment_name("/log/attachments/attachment.1.png"), "attachment.1.png") + + def test_api_downloads_prefix(self): + self.assertEqual( + attachment_name("/api/Downloads/Attachments/attachment.1.jpg"), + "attachment.1.jpg") + + def test_case_insensitive_extension_but_preserves_name_case(self): + self.assertEqual( + attachment_name("/log/attachments/Attachment.1.PNG"), "Attachment.1.PNG") + + def test_non_image_attachment_rejected(self): + self.assertIsNone(attachment_name("/log/attachments/report.pdf")) + + def test_external_url_rejected(self): + self.assertIsNone(attachment_name("https://example.com/x.png")) + + def test_unrelated_path_rejected(self): + self.assertIsNone(attachment_name("/some/other/path.png")) + + def test_empty_or_none(self): + self.assertIsNone(attachment_name("")) + self.assertIsNone(attachment_name(None)) + + +class SplitEntryMarkdownTests(unittest.TestCase): + def test_plain_text_returns_single_markdown_segment(self): + text = "Just some text with no images." + self.assertEqual(split_entry_markdown(text), [("markdown", text)]) + + def test_empty_and_none(self): + self.assertEqual(split_entry_markdown(""), [("markdown", "")]) + self.assertEqual(split_entry_markdown(None), [("markdown", "")]) + + def test_image_only_paragraph_becomes_image_segment(self): + text = "# Title\n\nBody text.\n\n![logo](/log/attachments/attachment.1.png)" + segments = split_entry_markdown(text) + self.assertEqual(segments[-1], ("image", "attachment.1.png", "logo")) + self.assertEqual(segments[0], ("markdown", "# Title\n\nBody text.\n\n")) + + def test_mixed_text_and_image_paragraph_stays_markdown(self): + text = "Text with an ![inline](/log/attachments/a.png) image inside." + self.assertEqual(split_entry_markdown(text), [("markdown", text)]) + + def test_two_images_in_one_paragraph_become_two_segments(self): + text = "![a](/log/attachments/a.png)\n![b](/log/attachments/b.png)\n" + segments = split_entry_markdown(text) + self.assertEqual( + segments, [("image", "a.png", "a"), ("image", "b.png", "b")]) + + def test_image_paragraph_surrounded_by_text(self): + text = "Before\n\n![a](/log/attachments/a.png)\n\nAfter" + segments = split_entry_markdown(text) + self.assertEqual(segments, [ + ("markdown", "Before\n\n"), + ("image", "a.png", "a"), + ("markdown", "\nAfter"), + ]) + + def test_pdf_attachment_paragraph_stays_markdown(self): + text = "See [report](/api/Downloads/Attachments/report.pdf) attached." + self.assertEqual(split_entry_markdown(text), [("markdown", text)]) + + def test_external_image_url_stays_markdown(self): + text = "![ext](https://example.com/x.png)" + self.assertEqual(split_entry_markdown(text), [("markdown", text)]) + + def test_api_downloads_prefix_recognized(self): + text = "![a](/api/Downloads/Attachments/a.png)" + self.assertEqual(split_entry_markdown(text), [("image", "a.png", "a")]) + + +if __name__ == "__main__": + unittest.main() From e5475838e342c0c12235c79567706c34c9880d02 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 20 Aug 2026 09:40:33 -0500 Subject: [PATCH 44/62] Add confituration integration for selection of image protocol to display in terminal. --- .../bely-cli/src/bely_cli/config.py | 2 +- .../bely-cli/src/bely_cli/tui/__init__.py | 10 ++- .../bely-cli/src/bely_cli/tui/app.py | 16 +++- .../bely-cli/src/bely_cli/tui/images.py | 56 ++++++++++++ .../src/bely_cli/tui/screens/configscreen.py | 35 +++++++- .../bely-cli/test/test_tui_images.py | 50 +++++++++++ .../bely-cli/test/test_tui_screens.py | 85 ++++++++++++++++++- 7 files changed, 248 insertions(+), 6 deletions(-) create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/images.py create mode 100644 tools/developer_tools/bely-cli/test/test_tui_images.py diff --git a/tools/developer_tools/bely-cli/src/bely_cli/config.py b/tools/developer_tools/bely-cli/src/bely_cli/config.py index 870658d53..83556babf 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/config.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/config.py @@ -3,7 +3,7 @@ DEFAULT_CONFIG_DIR = os.path.expanduser("~/.config/bely") -VALID_FIELDS = ("host", "user", "editor", "token_path", "theme") +VALID_FIELDS = ("host", "user", "editor", "token_path", "theme", "images") def expand_path(path): diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py index 7aecc1893..350a4425e 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py @@ -22,13 +22,21 @@ def cmd_tui(limit=100, fmt="text", mode="app"): if is_no_prompt(): raise RuntimeError("the tui cannot run with --no-prompt.") + from .. import config from .app import BelyTuiApp # lazy: keeps --help fast + from .images import load_image_widgets from .session import TuiSession factory = auth.get_factory() session = TuiSession(factory) - result = BelyTuiApp(session, limit=limit, mode=mode).run() + # The graphics-protocol probe in load_image_widgets() talks to the + # terminal and must happen before the Textual app takes over stdin/stdout + # -- so this runs here, not lazily inside a screen. Skipped entirely when + # images are off, so "off" also skips the terminal probe. + image_widgets = {} if config.get_setting("images") == "off" else load_image_widgets() + + result = BelyTuiApp(session, limit=limit, mode=mode, image_widgets=image_widgets).run() if not result: return doc, entry = result diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py index 54e4cd39e..dc7f4408b 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py @@ -78,13 +78,27 @@ class BelyTuiApp(App): } """ - def __init__(self, session, limit=100, mode="app"): + def __init__(self, session, limit=100, mode="app", image_widgets=None): super().__init__() self.session = session self.limit = limit self.mode = mode + self.image_widgets = image_widgets or {} self._theme_loaded = False + @property + def image_widget(self): + """The widget class for the current 'images' setting, or None. + + Reflects live config changes (e.g. from ConfigScreen) without a + restart, as long as image_widgets was populated at launch -- it's + only ever empty when the terminal probe was skipped because the app + started with images off, or textual_image isn't installed. + """ + from . import images + + return images.widget_for(self.image_widgets, config.get_setting("images")) + def on_mount(self): saved = config.get_setting("theme") self.theme = saved if saved in self.available_themes else DEFAULT_THEME diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/images.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/images.py new file mode 100644 index 000000000..e1c7c9e46 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/images.py @@ -0,0 +1,56 @@ +"""Terminal image widget resolution for the entry preview. + +`textual_image` is an optional extra (`bely-cli[images]`): base installs don't +pay for Pillow, and the TUI degrades to today's plain-markdown preview when +it isn't installed. Its protocol auto-detection queries the terminal, which +does not work once the Textual app has started -- so `load_image_widgets()` +must be called before `App.run()` (see tui/__init__.py's cmd_tui), not from +inside a screen or worker. +""" + +IMAGE_MODES = ("auto", "off", "tgp", "sixel", "halfcell", "unicode") + +# Short description per mode, shown in the TUI configuration panel's Select +# dropdown (see tui/screens/configscreen.py) and documented in README.md. +IMAGE_MODE_HELP = { + "auto": "autodetect protocol (recommended)", + "off": "no images, plain link text", + "tgp": "Kitty Graphics Protocol (kitty, Ghostty, ...)", + "sixel": "Sixel protocol (xterm, iTerm2, ...)", + "halfcell": "block-art fallback, higher resolution", + "unicode": "block-art fallback, lowest resolution", +} + + +def load_image_widgets(): + """Resolve every image mode to its Textual widget class. + + Returns {} if textual_image isn't installed. Resolving every mode up + front (not just the one currently configured) is what lets the + configuration screen switch modes without restarting the TUI. + """ + try: + from textual_image.widget import ( + HalfcellImage, Image, SixelImage, TGPImage, UnicodeImage, + ) + except ImportError: + return {} + + return { + "auto": Image, + "tgp": TGPImage, + "sixel": SixelImage, + "halfcell": HalfcellImage, + "unicode": UnicodeImage, + } + + +def widget_for(widgets, mode): + """The widget class for `mode`, or None if images are unavailable/off. + + Falls back to "auto" for an unset or unrecognized mode (e.g. a settings + file from a version with a smaller IMAGE_MODES set). + """ + if mode == "off" or not widgets: + return None + return widgets.get(mode) or widgets.get("auto") diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py index 0a70dfec8..fe6b339d3 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py @@ -14,9 +14,10 @@ from textual.binding import Binding from textual.containers import Vertical from textual.screen import ModalScreen -from textual.widgets import Input, Static +from textual.widgets import Input, Select, Static from ... import config, core +from ..images import IMAGE_MODE_HELP, IMAGE_MODES class ConfigScreen(ModalScreen): @@ -45,12 +46,25 @@ class ConfigScreen(ModalScreen): # also honors (see auth.py's precedence) -- token_path has none. ENV_FOR_FIELD = {"host": "BELY_HOST", "user": "BELY_USER", "editor": "EDITOR"} + # Fields backed by an enum rather than free text get a Select instead of + # an Input, with a default used when nothing is saved yet. + FIELD_CHOICES = {"images": IMAGE_MODES} + FIELD_DEFAULTS = {"images": "auto"} + def compose(self) -> ComposeResult: with Vertical(id="config-dialog"): yield Static(id="config-breadcrumb") yield Static(id="config-summary") for field in config.VALID_FIELDS: - yield Input(placeholder=field, id=f"config-{field}") + choices = self.FIELD_CHOICES.get(field) + if choices: + yield Select( + [(f"{choice} — {IMAGE_MODE_HELP[choice]}", choice) + for choice in choices], + allow_blank=False, id=f"config-{field}", + ) + else: + yield Input(placeholder=field, id=f"config-{field}") yield Static( "[ctrl+s] save [ctrl+e] edit file [r] reload [escape] back", id="config-hint", @@ -87,6 +101,10 @@ def _load(self): self.query_one("#config-summary", Static).update("\n".join(lines)) for field in config.VALID_FIELDS: + if field in self.FIELD_CHOICES: + select = self.query_one(f"#config-{field}", Select) + select.value = settings.get(field) or self.FIELD_DEFAULTS[field] + continue box = self.query_one(f"#config-{field}", Input) box.value = str(settings.get(field, "") or "") env_var = self.ENV_FOR_FIELD.get(field) @@ -101,6 +119,19 @@ def action_save(self): async def _save(self): changed = [] for field in config.VALID_FIELDS: + if field in self.FIELD_CHOICES: + value = self.query_one(f"#config-{field}", Select).value + default = self.FIELD_DEFAULTS[field] + current = config.get_setting(field) or default + if value != current: + await asyncio.to_thread(config.set_setting, field, value) + changed.append(field) + if field == "images" and value != "off" and not getattr( + self.app, "image_widgets", None): + self.notify( + "Restart the TUI to enable images (the terminal wasn't " + "probed for graphics support at launch).", severity="warning") + continue value = self.query_one(f"#config-{field}", Input).value.strip() current = config.get_setting(field) if value and value != (current or ""): diff --git a/tools/developer_tools/bely-cli/test/test_tui_images.py b/tools/developer_tools/bely-cli/test/test_tui_images.py new file mode 100644 index 000000000..6e53a04ab --- /dev/null +++ b/tools/developer_tools/bely-cli/test/test_tui_images.py @@ -0,0 +1,50 @@ +import sys +import unittest +from unittest.mock import patch + +from bely_cli.tui import images + + +class WidgetForTests(unittest.TestCase): + def test_off_returns_none(self): + self.assertIsNone(images.widget_for({"auto": object()}, "off")) + + def test_empty_widgets_returns_none(self): + self.assertIsNone(images.widget_for({}, "auto")) + + def test_unknown_mode_falls_back_to_auto(self): + auto = object() + self.assertIs(images.widget_for({"auto": auto}, "bogus"), auto) + + def test_none_mode_falls_back_to_auto(self): + auto = object() + self.assertIs(images.widget_for({"auto": auto}, None), auto) + + def test_explicit_mode_returns_its_class(self): + auto, sixel = object(), object() + widgets = {"auto": auto, "sixel": sixel} + self.assertIs(images.widget_for(widgets, "sixel"), sixel) + + +class LoadImageWidgetsTests(unittest.TestCase): + def test_missing_module_returns_empty_dict(self): + with patch.dict(sys.modules, {"textual_image": None, "textual_image.widget": None}): + self.assertEqual(images.load_image_widgets(), {}) + + def test_returns_all_modes_when_available(self): + fake_widget_module = type("m", (), { + "Image": object(), "TGPImage": object(), "SixelImage": object(), + "HalfcellImage": object(), "UnicodeImage": object(), + }) + with patch.dict(sys.modules, { + "textual_image": type("m", (), {})(), + "textual_image.widget": fake_widget_module, + }): + widgets = images.load_image_widgets() + self.assertEqual(set(widgets), set(images.IMAGE_MODES) - {"off"}) + self.assertIs(widgets["auto"], fake_widget_module.Image) + self.assertIs(widgets["sixel"], fake_widget_module.SixelImage) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/developer_tools/bely-cli/test/test_tui_screens.py b/tools/developer_tools/bely-cli/test/test_tui_screens.py index 415c81d72..f33dc66fd 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_screens.py +++ b/tools/developer_tools/bely-cli/test/test_tui_screens.py @@ -12,7 +12,7 @@ from unittest.mock import patch from textual.app import App -from textual.widgets import Button, Input, Static, TextArea +from textual.widgets import Button, Input, Select, Static, TextArea from bely_cli.tui.app import BelyTuiApp from bely_cli.tui.data import LogbookData @@ -588,6 +588,89 @@ def fake_set_setting(key, value): self.assertEqual(saved, [("host", "https://new")]) + async def test_images_field_is_a_select_defaulting_to_auto(self): + state = {"settings_file": "/tmp/settings.yaml", "settings": {}, "environment": {}} + app = App() + with patch.object(configscreen.core, "collect_config", side_effect=lambda: dict(state)), \ + patch.object(configscreen.config, "get_setting", side_effect=lambda k: None): + async with app.run_test() as pilot: + app.push_screen(ConfigScreen()) + await pilot.pause() + select = app.screen.query_one("#config-images", Select) + self.assertEqual(select.value, "auto") + + async def test_images_options_document_each_mode(self): + from bely_cli.tui.images import IMAGE_MODE_HELP, IMAGE_MODES + + state = {"settings_file": "/tmp/settings.yaml", "settings": {}, "environment": {}} + app = App() + with patch.object(configscreen.core, "collect_config", side_effect=lambda: dict(state)), \ + patch.object(configscreen.config, "get_setting", side_effect=lambda k: None): + async with app.run_test() as pilot: + app.push_screen(ConfigScreen()) + await pilot.pause() + select = app.screen.query_one("#config-images", Select) + labels = {value: str(label) for label, value in select._options} + self.assertEqual(set(labels), set(IMAGE_MODES)) + for mode in IMAGE_MODES: + self.assertIn(IMAGE_MODE_HELP[mode], labels[mode]) + + async def test_images_field_prefills_from_settings(self): + state = { + "settings_file": "/tmp/settings.yaml", + "settings": {"images": "sixel"}, + "environment": {}, + } + app = App() + with patch.object(configscreen.core, "collect_config", side_effect=lambda: dict(state)), \ + patch.object(configscreen.config, "get_setting", + side_effect=lambda k: state["settings"].get(k)): + async with app.run_test() as pilot: + app.push_screen(ConfigScreen()) + await pilot.pause() + select = app.screen.query_one("#config-images", Select) + self.assertEqual(select.value, "sixel") + + async def test_saving_auto_with_nothing_stored_reports_no_change(self): + state = {"settings_file": "/tmp/settings.yaml", "settings": {}, "environment": {}} + saved = [] + app = App() + with patch.object(configscreen.core, "collect_config", side_effect=lambda: dict(state)), \ + patch.object(configscreen.config, "get_setting", side_effect=lambda k: None), \ + patch.object(configscreen.config, "set_setting", + side_effect=lambda k, v: saved.append((k, v))): + async with app.run_test() as pilot: + app.push_screen(ConfigScreen()) + await pilot.pause() + await pilot.press("ctrl+s") + await pilot.pause() + await pilot.pause() + self.assertEqual(saved, []) + + async def test_changing_images_select_saves_the_new_value(self): + state = {"settings_file": "/tmp/settings.yaml", "settings": {}, "environment": {}} + saved = [] + + def fake_set_setting(key, value): + saved.append((key, value)) + state["settings"][key] = value + + app = App() + with patch.object(configscreen.core, "collect_config", side_effect=lambda: dict(state)), \ + patch.object(configscreen.config, "get_setting", + side_effect=lambda k: state["settings"].get(k)), \ + patch.object(configscreen.config, "set_setting", side_effect=fake_set_setting): + async with app.run_test() as pilot: + app.push_screen(ConfigScreen()) + await pilot.pause() + select = app.screen.query_one("#config-images", Select) + select.value = "unicode" + await pilot.press("ctrl+s") + await pilot.pause() + await pilot.pause() + + self.assertEqual(saved, [("images", "unicode")]) + class ConfirmScreenTests(unittest.IsolatedAsyncioTestCase): async def test_confirm_button_dismisses_true(self): From 4d5a889f4b2361db7813cd8d2b794ade61a48011 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 20 Aug 2026 11:04:27 -0500 Subject: [PATCH 45/62] Integrate viewing images into the tui --- .../bely-cli/src/bely_cli/core.py | 7 +- .../bely-cli/src/bely_cli/tui/app.py | 31 ++- .../src/bely_cli/tui/screens/browse.py | 124 +++++++++++- .../bely-cli/test/test_tui_app.py | 179 ++++++++++++++++++ 4 files changed, 319 insertions(+), 22 deletions(-) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/core.py b/tools/developer_tools/bely-cli/src/bely_cli/core.py index d3181b176..38c88871b 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/core.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/core.py @@ -196,12 +196,7 @@ def upload_attachment(logbook_api, doc_id, log_id, path): def download_attachment(download_api, stored_filename, scaling=None): - """Return an attachment's raw bytes, optionally a server-scaled variant. - - The plain get_attachment()/get_attachment1() wrappers discard the response - body (their _response_types_map maps '200' to None) -- only the - _without_preload_content variants return the actual bytes. - """ + """Return an attachment's raw bytes, optionally a server-scaled variant.""" if scaling: response = download_api.get_attachment1_without_preload_content( stored_filename, scaling) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py index dc7f4408b..262ac38f6 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py @@ -56,6 +56,29 @@ class BelyTuiApp(App): margin-bottom: 1; } + #body-blocks { + height: auto; + } + + .entry-image { + /* width/height must both be "auto" -- otherwise height is unbounded and can exceed the renderer's cell limit. */ + width: auto; + height: auto; + max-width: 100%; + max-height: 30; + margin-bottom: 1; + } + + .img-loading { + color: $text-muted; + margin-bottom: 1; + } + + .img-error { + color: $error; + margin-bottom: 1; + } + #status-bar { height: 1; padding: 0 1; @@ -88,13 +111,7 @@ def __init__(self, session, limit=100, mode="app", image_widgets=None): @property def image_widget(self): - """The widget class for the current 'images' setting, or None. - - Reflects live config changes (e.g. from ConfigScreen) without a - restart, as long as image_widgets was populated at launch -- it's - only ever empty when the terminal probe was skipped because the app - started with images off, or textual_image isn't installed. - """ + """The widget class for the current 'images' setting, or None; reflects live config changes.""" from . import images return images.widget_for(self.image_widgets, config.get_setting("images")) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py index fde811599..3576412c3 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py @@ -27,10 +27,11 @@ from textual import work from textual.app import ComposeResult from textual.binding import Binding -from textual.containers import Horizontal, VerticalScroll +from textual.containers import Horizontal, Vertical, VerticalScroll from textual.screen import Screen from textual.widgets import DataTable, Footer, Header, Input, Markdown, Static +from ... import config from . import rows_table from ..format import ( DOC_COLUMNS, @@ -48,10 +49,22 @@ type_metadata_rows, type_row, ) +from ..mdimages import split_entry_markdown LEVEL_TITLES = {0: "Logbooks", 1: "Documents", 2: "Entries"} +def decode_image_bytes(data): + """Decode raw image bytes into a PIL Image, fully loaded; separate so tests can patch it without Pillow.""" + import io + + from PIL import Image as PILImage + + image = PILImage.open(io.BytesIO(data)) + image.load() # decode fully while still off the event loop + return image + + class BrowseScreen(Screen): """Logbook type -> document -> entry drill-down with a live preview.""" @@ -106,6 +119,7 @@ def __init__(self, session, limit, *, select_mode=True, source="types"): self.all_items = [] self.shown_items = [] self._entry_key = None + self._render_token = 0 self._nav_hidden = False self._info_open = False self._table_columns_for = None @@ -117,6 +131,7 @@ def compose(self) -> ComposeResult: with VerticalScroll(id="preview"): yield Static(id="meta") yield Markdown(id="body-md") + yield Vertical(id="body-blocks") with Horizontal(id="status-bar"): yield Static(id="status-left") yield Input(id="filter", placeholder="type to filter", compact=True) @@ -125,6 +140,7 @@ def compose(self) -> ComposeResult: def on_mount(self): self.query_one("#body-md", Markdown).display = False + self.query_one("#body-blocks", Vertical).display = False self.query_one("#filter", Input).display = False self._update_auth_status() self.show_level(self.level) @@ -166,8 +182,9 @@ def _ensure_columns(self): def show_level(self, level, *, preserve_filter=False): self.level = level - # cancel any in-flight preview worker so a stale, now-mistyped item can't reach _show_preview + # cancel any in-flight preview/image workers so a stale, now-mistyped item can't reach _show_preview self.app.workers.cancel_group(self, "preview") + self.app.workers.cancel_group(self, "images") self._sync_panes() self.refresh_bindings() nav = self._nav() @@ -180,6 +197,7 @@ def show_level(self, level, *, preserve_filter=False): filt.display = False nav.set_loading(True) self.query_one("#body-md", Markdown).display = False + self.query_one("#body-blocks", Vertical).display = False self.query_one("#meta", Static).update("") if level == self.LEVEL_TYPES: self._load_types() @@ -269,6 +287,7 @@ def _apply_filter(self, query): else: self.query_one("#meta", Static).update("(no matches)") self.query_one("#body-md", Markdown).display = False + self.query_one("#body-blocks", Vertical).display = False def _update_status_left(self, query): count = len(self.shown_items) @@ -318,17 +337,104 @@ def _render_meta(self, item): async def _show_preview(self, item): self._render_meta(item) body_md = self.query_one("#body-md", Markdown) - if self.level == self.LEVEL_ENTRIES: + body_blocks = self.query_one("#body-blocks", Vertical) + if self.level != self.LEVEL_ENTRIES: + body_md.display = False + body_blocks.display = False + return + + key = (self.sel_doc.id, item.log_id) + self._entry_key = key + self._load_attachments(item) + + segments = split_entry_markdown(item.log_entry or "") + widget_cls = getattr(self.app, "image_widget", None) + image_segments_present = any(seg[0] == "image" for seg in segments) + + if widget_cls is None or not image_segments_present: + if widget_cls is None and image_segments_present: + self._maybe_hint_images_unavailable() + body_blocks.display = False body_md.display = True await body_md.update(item.log_entry or "") - self._load_attachments(item) - else: - body_md.display = False + return + + body_md.display = False + body_blocks.display = True + await self._render_segments(key, segments, widget_cls) + + def _maybe_hint_images_unavailable(self): + """Nudge toward the optional extra, once per session, unless images are off.""" + if getattr(self.app, "_images_hint_shown", False): + return + if config.get_setting("images") == "off": + return + self.app._images_hint_shown = True + self.notify( + "This entry has images -- install the optional extra to view them " + "inline: pip install 'bely-cli[images]'", + timeout=8, + markup=False, + ) + + async def _render_segments(self, key, segments, widget_cls): + """Mount markdown/image placeholders and fetch each image; _render_token guards _show_preview's double-call race.""" + self._render_token += 1 + token = self._render_token + self.app.workers.cancel_group(self, "images") + body_blocks = self.query_one("#body-blocks", Vertical) + await body_blocks.remove_children() + if key != self._entry_key or token != self._render_token: + return + + widgets = [] + pending_images = [] # (placeholder, stored_name, alt) + for segment in segments: + if segment[0] == "markdown": + widgets.append(Markdown(segment[1])) + else: + _, stored_name, alt = segment + placeholder = Static( + f"\U0001f5bc {alt or stored_name} (loading...)", classes="img-loading") + widgets.append(placeholder) + pending_images.append((placeholder, stored_name, alt)) + + await body_blocks.mount_all(widgets) + if key != self._entry_key or token != self._render_token: + return + for placeholder, stored_name, alt in pending_images: + self._fetch_image(key, token, stored_name, alt, placeholder, widget_cls) + + @work(thread=True, group="images") + def _fetch_image(self, key, token, stored_name, alt, placeholder, widget_cls): + try: + data = self.data.attachment_bytes(stored_name) + pil_image = decode_image_bytes(data) + except Exception as e: + self.app.call_from_thread( + self._image_failed, key, token, placeholder, stored_name, str(e)) + return + self.app.call_from_thread(self._apply_image, key, token, placeholder, widget_cls, pil_image) + + def _stale_render(self, key, token, placeholder): + return key != self._entry_key or token != self._render_token or not placeholder.is_mounted + + async def _apply_image(self, key, token, placeholder, widget_cls, pil_image): + if self._stale_render(key, token, placeholder): + return + image_widget = widget_cls(pil_image, classes="entry-image") + await self.query_one("#body-blocks", Vertical).mount(image_widget, after=placeholder) + await placeholder.remove() + + async def _image_failed(self, key, token, placeholder, stored_name, message): + if self._stale_render(key, token, placeholder): + return + placeholder.update(f"\U0001f5bc {stored_name} (failed to load: {message})") + placeholder.remove_class("img-loading") + placeholder.add_class("img-error") def _load_attachments(self, entry): - key = (self.sel_doc.id, entry.log_id) - self._entry_key = key - self._fetch_attachments(self.sel_doc.id, entry.log_id, entry, key) + self._fetch_attachments(self.sel_doc.id, entry.log_id, entry, self._entry_key) @work(thread=True, exclusive=True, group="attachments") def _fetch_attachments(self, doc_id, log_id, entry, key): diff --git a/tools/developer_tools/bely-cli/test/test_tui_app.py b/tools/developer_tools/bely-cli/test/test_tui_app.py index 84d8aa0f3..2d09948c9 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_app.py +++ b/tools/developer_tools/bely-cli/test/test_tui_app.py @@ -3,10 +3,12 @@ from types import SimpleNamespace from unittest.mock import patch +from textual.containers import Vertical from textual.widgets import DataTable, Input, Markdown, Static from bely_cli.tui.app import BelyTuiApp from bely_cli.tui.data import LogbookData +from bely_cli.tui.screens import browse from bely_cli.tui.screens.browse import BrowseScreen @@ -62,6 +64,34 @@ def add_update_log_entry(self, log_entry): return log_entry +class FakeLogbookApiWithImage(FakeLogbookApi): + """Entry body is a single image-only paragraph (as the server appends after upload).""" + + def get_log_entries(self, log_document_id, load_replies, load_reactions): + return [SimpleNamespace( + log_id=100, entered_by_username="alice", entered_on_date_time=None, + last_modified_by_username=None, last_modified_on_date_time=None, + log_replies=None, log_reactions=None, + log_entry="Body text.\n\n![logo](/log/attachments/attachment.1.png)", + )] + + +class FakeDownloadApi: + def get_attachment1_without_preload_content(self, attachment_name, scaling): + return SimpleNamespace(data=b"fake-scaled-bytes") + + def get_attachment_without_preload_content(self, attachment_name): + return SimpleNamespace(data=b"fake-bytes") + + +class FakeImageWidget(Static): + """Stand-in for textual_image.widget.Image: never touches a real terminal.""" + + def __init__(self, image, classes=None): + super().__init__(f"", classes=classes) + self.image = image + + class FakeUsersApi: def __init__(self, calls): self._calls = calls @@ -497,5 +527,154 @@ async def test_changing_theme_after_mount_persists_it(self): set_setting.assert_called_once_with("theme", "gruvbox") +async def _wait_until(pilot, predicate, attempts=30): + """Poll for `predicate()`; pilot.pause() alone can return before _fetch_image's worker thread lands.""" + for _ in range(attempts): + if predicate(): + return + await pilot.pause(0.01) + raise AssertionError("condition never became true") + + +class ImagePreviewTests(unittest.IsolatedAsyncioTestCase): + """BrowseScreen's image-segment rendering path; patches decode_image_bytes so no Pillow install is needed.""" + + def _make_app(self, *, image_widgets): + data = LogbookData(FakeLogbookApiWithImage(), FakeDownloadApi()) + app = BelyTuiApp( + FakeSession(data), limit=10, mode="lookup", image_widgets=image_widgets) + return app + + async def test_image_only_entry_renders_fake_widget_and_hides_markdown(self): + app = self._make_app(image_widgets={"auto": FakeImageWidget}) + with patch("bely_cli.tui.app.config.get_setting", return_value=None), \ + patch.object(browse, "decode_image_bytes", return_value="decoded-image"): + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") # type -> docs + await pilot.pause() + await pilot.press("enter") # docs -> entries + await pilot.pause() + await pilot.pause() + + screen = app.screen + body_blocks = screen.query_one("#body-blocks", Vertical) + await _wait_until(pilot, lambda: screen.query(FakeImageWidget)) + + self.assertFalse(screen.query_one("#body-md", Markdown).display) + self.assertTrue(body_blocks.display) + images = list(screen.query(FakeImageWidget)) + self.assertEqual(len(images), 1) + self.assertEqual(images[0].image, "decoded-image") + # The markdown segment before the image is still rendered. + markdown_segments = list(body_blocks.query(Markdown)) + self.assertEqual(len(markdown_segments), 1) + + async def test_no_image_widget_falls_back_to_plain_markdown(self): + # image_widgets={} -- as if textual_image weren't installed, or images: off. + app = self._make_app(image_widgets={}) + with patch("bely_cli.tui.app.config.get_setting", return_value=None): + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + await pilot.pause() + + screen = app.screen + self.assertTrue(screen.query_one("#body-md", Markdown).display) + self.assertFalse(screen.query_one("#body-blocks", Vertical).display) + self.assertEqual(len(list(screen.query(FakeImageWidget))), 0) + + async def test_non_image_entry_uses_plain_markdown_even_with_image_widget(self): + # FakeLogbookApi (not the *WithImage variant): text-only entry, so the + # fast path applies even though an image widget is available. + data = LogbookData(FakeLogbookApi(), FakeDownloadApi()) + app = BelyTuiApp( + FakeSession(data), limit=10, mode="lookup", + image_widgets={"auto": FakeImageWidget}) + with patch("bely_cli.tui.app.config.get_setting", return_value=None): + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + await pilot.pause() + + screen = app.screen + self.assertTrue(screen.query_one("#body-md", Markdown).display) + self.assertFalse(screen.query_one("#body-blocks", Vertical).display) + + async def test_image_fetch_failure_shows_error_placeholder(self): + app = self._make_app(image_widgets={"auto": FakeImageWidget}) + with patch("bely_cli.tui.app.config.get_setting", return_value=None), \ + patch.object(browse, "decode_image_bytes", side_effect=RuntimeError("bad image")): + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + await pilot.pause() + + screen = app.screen + + def has_error(): + return screen.query(".img-error") + + await _wait_until(pilot, has_error) + self.assertEqual(len(list(screen.query(FakeImageWidget))), 0) + + async def test_switching_entries_before_fetch_completes_does_not_mount_stale_image(self): + """Arrowing away mid-fetch must not land an image in the wrong entry's preview.""" + import threading + + class TwoEntryApi(FakeLogbookApiWithImage): + def get_log_entries(self, log_document_id, load_replies, load_reactions): + return super().get_log_entries(log_document_id, load_replies, load_reactions) + [ + SimpleNamespace( + log_id=200, entered_by_username="alice", entered_on_date_time=None, + last_modified_by_username=None, last_modified_on_date_time=None, + log_replies=None, log_reactions=None, log_entry="Plain second entry.", + ), + ] + + data = LogbookData(TwoEntryApi(), FakeDownloadApi()) + app = BelyTuiApp( + FakeSession(data), limit=10, mode="lookup", + image_widgets={"auto": FakeImageWidget}) + release = threading.Event() + + def slow_decode(data): + # Block the worker thread until the test moves the cursor away. + release.wait(timeout=2) + return "decoded-image" + + with patch("bely_cli.tui.app.config.get_setting", return_value=None), \ + patch.object(browse, "decode_image_bytes", side_effect=slow_decode): + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + await pilot.pause() + + screen = app.screen + self.assertEqual(screen._entry_key, (10, 100)) + + await pilot.press("down") # move to the second (image-free) entry + await pilot.pause() + await pilot.pause() + self.assertEqual(screen._entry_key, (10, 200)) + + release.set() + await _wait_until(pilot, lambda: True, attempts=5) # let the worker drain + + self.assertEqual(len(list(screen.query(FakeImageWidget))), 0) + + if __name__ == "__main__": unittest.main() From aadf7711354283e1c156a101579ccd31d77651e9 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 20 Aug 2026 11:04:40 -0500 Subject: [PATCH 46/62] Shorten comments --- .../bely-cli/src/bely_cli/tui/__init__.py | 5 +--- .../bely-cli/src/bely_cli/tui/data.py | 15 ++-------- .../bely-cli/src/bely_cli/tui/images.py | 26 +++-------------- .../bely-cli/src/bely_cli/tui/mdimages.py | 29 ++----------------- .../src/bely_cli/tui/screens/configscreen.py | 4 +-- 5 files changed, 11 insertions(+), 68 deletions(-) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py index 350a4425e..f321149f2 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/__init__.py @@ -30,10 +30,7 @@ def cmd_tui(limit=100, fmt="text", mode="app"): factory = auth.get_factory() session = TuiSession(factory) - # The graphics-protocol probe in load_image_widgets() talks to the - # terminal and must happen before the Textual app takes over stdin/stdout - # -- so this runs here, not lazily inside a screen. Skipped entirely when - # images are off, so "off" also skips the terminal probe. + # Must probe the terminal before the Textual app takes over stdin/stdout; "off" skips the probe entirely. image_widgets = {} if config.get_setting("images") == "off" else load_image_widgets() result = BelyTuiApp(session, limit=limit, mode=mode, image_widgets=image_widgets).run() diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py index 5e91c84f6..daeb78af2 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/data.py @@ -17,10 +17,7 @@ class LogbookData: """Wraps logbook_api with the caching the TUI needs.""" - # Bound on the image-bytes cache: images are much larger than the metadata - # the other caches hold, so unlike those this one evicts (FIFO) rather - # than growing without limit for the life of a session. - MAX_CACHED_IMAGES = 32 + MAX_CACHED_IMAGES = 32 # FIFO-bounded: images are much larger than the other caches' entries def __init__(self, logbook_api, download_api=None): self._logbook_api = logbook_api @@ -87,15 +84,7 @@ def attachments(self, doc_id, log_id): return attachments def attachment_bytes(self, stored_filename, scaling="scaled"): - """Raw image bytes for an attachment, preferring a server-scaled variant. - - Falls back to the unscaled original if the scaled fetch fails (e.g. the - server doesn't have a scaled variant for this file type). Failures are - not cached, matching the other caches' rule; a successful fetch is - cached per (stored_filename, scaling), FIFO-evicted past - MAX_CACHED_IMAGES since images are far larger than the metadata the - other caches hold. - """ + """Raw image bytes, preferring a server-scaled variant; falls back to the original on failure.""" key = (stored_filename, scaling) data = self._image_bytes.get(key) if data is None: diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/images.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/images.py index e1c7c9e46..93008ded7 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/images.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/images.py @@ -1,17 +1,8 @@ -"""Terminal image widget resolution for the entry preview. - -`textual_image` is an optional extra (`bely-cli[images]`): base installs don't -pay for Pillow, and the TUI degrades to today's plain-markdown preview when -it isn't installed. Its protocol auto-detection queries the terminal, which -does not work once the Textual app has started -- so `load_image_widgets()` -must be called before `App.run()` (see tui/__init__.py's cmd_tui), not from -inside a screen or worker. -""" +"""Terminal image widget resolution -- must run before App.run() (see cmd_tui), since detection probes the terminal.""" IMAGE_MODES = ("auto", "off", "tgp", "sixel", "halfcell", "unicode") -# Short description per mode, shown in the TUI configuration panel's Select -# dropdown (see tui/screens/configscreen.py) and documented in README.md. +# Short description per mode, shown in the config panel's dropdown (configscreen.py). IMAGE_MODE_HELP = { "auto": "autodetect protocol (recommended)", "off": "no images, plain link text", @@ -23,12 +14,7 @@ def load_image_widgets(): - """Resolve every image mode to its Textual widget class. - - Returns {} if textual_image isn't installed. Resolving every mode up - front (not just the one currently configured) is what lets the - configuration screen switch modes without restarting the TUI. - """ + """Resolve every image mode to its Textual widget class; {} if textual_image isn't installed.""" try: from textual_image.widget import ( HalfcellImage, Image, SixelImage, TGPImage, UnicodeImage, @@ -46,11 +32,7 @@ def load_image_widgets(): def widget_for(widgets, mode): - """The widget class for `mode`, or None if images are unavailable/off. - - Falls back to "auto" for an unset or unrecognized mode (e.g. a settings - file from a version with a smaller IMAGE_MODES set). - """ + """The widget class for `mode`, or None if images are unavailable/off; unrecognized modes fall back to auto.""" if mode == "off" or not widgets: return None return widgets.get(mode) or widgets.get("auto") diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/mdimages.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/mdimages.py index 7d1a2c108..4b8377535 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/mdimages.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/mdimages.py @@ -1,16 +1,4 @@ -"""Split entry markdown into renderable segments: plain markdown vs. images. - -No textual/rich import here -- pure data-in/data-out logic so it can be unit -tested without a terminal (see test/test_tui_mdimages.py), matching the style -of format.py. Only markdown_it is used, which textual already depends on. - -Server-side, an uploaded image gets a markdown reference of the form -`![name](/log/attachments/)` appended to the entry body in its own -paragraph (LogAttachmentUtility.java); the web portal rewrites that prefix to -`/api/Downloads/Attachments/` when rendering. We recognize either -form. Non-image attachments (pdf, etc.) and any other URL are left as plain -markdown links -- textual's Markdown widget already renders those as text. -""" +"""Split entry markdown into renderable segments: plain markdown vs. images. Pure, no textual/rich import.""" from markdown_it import MarkdownIt @@ -20,11 +8,7 @@ def attachment_name(src): - """The stored filename if src is a renderable BELY attachment reference, else None. - - Rejects external URLs (http://, https://) and non-image attachments (pdf) -- - those stay as ordinary markdown links, never fetched. - """ + """The stored filename if src is a renderable BELY attachment reference, else None.""" if not src: return None for prefix in _ATTACHMENT_PREFIXES: @@ -52,14 +36,7 @@ def _image_only_children(children): def split_entry_markdown(text): - """Split entry markdown into ("markdown", str) / ("image", stored_name, alt) segments. - - A paragraph whose inline content is exclusively image(s) (as the server - appends after an upload) becomes one or more image segments; everything - else -- including a paragraph that mixes text and an image -- is left as - markdown, verbatim. Returns a single ("markdown", text) segment when - nothing is renderable, so the caller can take the cheap unsplit path. - """ + """Split into ("markdown", str) / ("image", stored_name, alt) segments; image-only paragraphs become images.""" text = text or "" lines = text.splitlines(keepends=True) tokens = MarkdownIt("gfm-like").parse(text) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py index fe6b339d3..415b2203a 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py @@ -46,9 +46,7 @@ class ConfigScreen(ModalScreen): # also honors (see auth.py's precedence) -- token_path has none. ENV_FOR_FIELD = {"host": "BELY_HOST", "user": "BELY_USER", "editor": "EDITOR"} - # Fields backed by an enum rather than free text get a Select instead of - # an Input, with a default used when nothing is saved yet. - FIELD_CHOICES = {"images": IMAGE_MODES} + FIELD_CHOICES = {"images": IMAGE_MODES} # enum fields get a Select instead of an Input FIELD_DEFAULTS = {"images": "auto"} def compose(self) -> ComposeResult: From 3e50224e242acf27df5165213637980f0c4eeb7a Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 20 Aug 2026 11:25:55 -0500 Subject: [PATCH 47/62] Add documentation and packaging for image support. --- tools/developer_tools/bely-cli/README.md | 75 ++++++++- .../bely-cli/conda-recipe/meta.yaml | 1 + tools/developer_tools/bely-cli/pyproject.toml | 5 +- tools/developer_tools/bely-cli/uv.lock | 155 +++++++++++++++++- 4 files changed, 227 insertions(+), 9 deletions(-) diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index 77011bfc1..eeceee5c2 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -15,6 +15,18 @@ uv sync uv run bely-cli -h ``` +`uv run` only works from inside this directory. To get a `bely-cli` on your `$PATH` that +still tracks your local working-tree edits (no `uv run` prefix, usable from anywhere), +install it as an editable uv tool instead: + +```bash +uv tool install --editable . +bely-cli -h +``` +Re-run that command (add `--force` to overwrite without prompting) any time `pyproject.toml`'s +dependencies change — code edits alone are picked up immediately, but new/changed +dependencies aren't installed into the tool's environment until you reinstall. + For deployment, install the conda package built from `conda-recipe/` (see `conda-recipe/conda-build.sh`): @@ -22,6 +34,15 @@ For deployment, install the conda package built from `conda-recipe/` (see conda install bely-cli -c ``` +To view images inline in `bely-cli tui` (see [Images](#images) below), install the optional +`images` extra — it pulls in Pillow and `textual-image`, which the base install skips: + +```bash +uv sync --extra images # development, via uv run +uv tool install --force --editable '.[images]' # development, editable tool install +conda install bely-cli textual-image -c # deployment +``` + ## Getting started Once installed, the command is on your `PATH`: @@ -192,9 +213,10 @@ same entry composer. **Configuration** -Opened from the command palette. Mirrors `config show` / `config set` / `config edit`: -one input per setting, prefilled from `settings.yaml`, alongside a summary of the current -settings and any environment-variable overrides. +Opened from the command palette. Mirrors `config show` / `config set` / `config edit`: one +field per setting (a dropdown for `images`, a text input for everything else), prefilled +from `settings.yaml`, alongside a summary of the current settings and any +environment-variable overrides. | Key | Action | |-----|--------| @@ -283,8 +305,46 @@ log-id: 42 With `--format json` / `--format yaml` the selected reference is printed as structured data instead. -Note: attachment images referenced from entry markdown render as links in the preview, not -inline images — use `s` or `e` to view the full entry, or fetch the attachment directly. +#### Images + +When an entry's markdown references an image attachment, the preview (in both `tui` and +`tui lookup`) renders it inline as an actual picture if your terminal and the `images` +setting support it, instead of a plain link. + +Requires the optional `images` extra (see [Installation](#installation)) and a terminal with +a graphics protocol. Controlled by the `images` setting, one of: + +| Mode | Renders via | +|------|-------------| +| `auto` (default) | Autodetects the terminal's protocol — recommended. | +| `off` | No images; the old link-text preview. | +| `tgp` | Kitty Graphics Protocol (kitty, Ghostty, WezTerm, Konsole, ...). | +| `sixel` | Sixel (xterm, foot, iTerm2, WezTerm, Windows Terminal ≥1.22, ...). | +| `halfcell` | Block-art fallback (higher resolution), no graphics protocol needed. | +| `unicode` | Block-art fallback (lowest resolution), works in any terminal. | + +```bash +bely-cli config set images sixel +``` +or from the TUI's Configuration dialog (`ctrl+p` → Configuration) — the `images` field is a +dropdown listing all six modes with a short description of each. A mode change there takes +effect on the very next entry you preview, no restart needed — unless the TUI was launched +with `images: off` (which skips the terminal graphics probe at startup entirely), in which +case it warns that a restart is required. + +If the extra isn't installed, entries with images show a one-time notice suggesting +`pip install 'bely-cli[images]'`; the link-text preview otherwise behaves exactly as before. +Only BELY attachment images (`![...](/log/attachments/...)`) are ever fetched — external +`http(s)://` image URLs in entry markdown are left as plain links, never downloaded. + +**tmux**: `tgp` does not work through tmux — `textual-image` writes Kitty's raw escape +sequences directly to the terminal without tmux's DCS passthrough wrapping, so tmux can't +forward them regardless of configuration. `sixel` does work, but needs tmux ≥3.3 with: +```tmux +set -g allow-passthrough on +set -ga terminal-features ',*:RGB:sixel' +``` +`halfcell` needs no tmux configuration at all and is the more reliable choice inside tmux. ### `entry` — log entries @@ -354,7 +414,7 @@ Open the settings file in your editor. The editor is resolved from `EDITOR`, the #### `bely-cli config set FIELD VALUE` Set a single configuration field. `FIELD` is one of `host`, `user`, `editor`, -`token_path`, or `theme`. +`token_path`, `theme`, or `images`. ```bash bely-cli config set user alice @@ -362,13 +422,14 @@ bely-cli config set host https://tinkerbox.aps.anl.gov:8181/bely bely-cli config set editor nano bely-cli config set token_path ~/.secrets/bely-token bely-cli config set theme nord +bely-cli config set images sixel ``` ## Configuration & environment | Location / variable | Purpose | |---------------------|---------| -| `~/.config/bely/settings.yaml` | Persistent settings (`host`, `user`, `editor`, `token_path`, `theme`); permissions `0600`. | +| `~/.config/bely/settings.yaml` | Persistent settings (`host`, `user`, `editor`, `token_path`, `theme`, `images`); permissions `0600`. | | `~/.config/bely/token` | Cached auth token; permissions `0600`. Override with the `token_path` setting. | | `BELY_SETTINGS_FILE` | Path to the settings file (overrides the default location). The default token sits beside it. | | `BELY_HOST` | Server URL (overrides the settings file). | diff --git a/tools/developer_tools/bely-cli/conda-recipe/meta.yaml b/tools/developer_tools/bely-cli/conda-recipe/meta.yaml index 25c3be914..b41bba122 100644 --- a/tools/developer_tools/bely-cli/conda-recipe/meta.yaml +++ b/tools/developer_tools/bely-cli/conda-recipe/meta.yaml @@ -34,6 +34,7 @@ test: - test requires: - python + - textual-image # optional at runtime (tui image rendering); here so tests exercise the real widget commands: - python -m unittest discover -s test -v - bely-cli -h diff --git a/tools/developer_tools/bely-cli/pyproject.toml b/tools/developer_tools/bely-cli/pyproject.toml index e3395322b..0b55c7d5e 100644 --- a/tools/developer_tools/bely-cli/pyproject.toml +++ b/tools/developer_tools/bely-cli/pyproject.toml @@ -18,6 +18,9 @@ dependencies = [ "rich>=13.7.0", ] +[project.optional-dependencies] +images = ["textual-image[textual]>=0.12.0"] + [project.urls] Homepage = "https://github.com/AdvancedPhotonSource/BELY" @@ -25,7 +28,7 @@ Homepage = "https://github.com/AdvancedPhotonSource/BELY" bely-cli = "bely_cli.cli:main" [dependency-groups] -dev = ["pytest>=7.0.0"] +dev = ["pytest>=7.0.0", "textual-image[textual]>=0.12.0"] [tool.setuptools.packages.find] where = ["src"] diff --git a/tools/developer_tools/bely-cli/uv.lock b/tools/developer_tools/bely-cli/uv.lock index 7f98b57fe..87c9485f2 100644 --- a/tools/developer_tools/bely-cli/uv.lock +++ b/tools/developer_tools/bely-cli/uv.lock @@ -1,6 +1,10 @@ version = 1 revision = 3 requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version < '3.12'", +] [[package]] name = "annotated-types" @@ -43,9 +47,17 @@ dependencies = [ { name = "textual" }, ] +[package.optional-dependencies] +images = [ + { name = "textual-image", version = "0.12.0", source = { registry = "https://pypi.org/simple" }, extra = ["textual"], marker = "python_full_version < '3.12'" }, + { name = "textual-image", version = "0.13.2", source = { registry = "https://pypi.org/simple" }, extra = ["textual"], marker = "python_full_version >= '3.12'" }, +] + [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "textual-image", version = "0.12.0", source = { registry = "https://pypi.org/simple" }, extra = ["textual"], marker = "python_full_version < '3.12'" }, + { name = "textual-image", version = "0.13.2", source = { registry = "https://pypi.org/simple" }, extra = ["textual"], marker = "python_full_version >= '3.12'" }, ] [package.metadata] @@ -55,10 +67,15 @@ requires-dist = [ { name = "pyyaml", specifier = ">=6.0.0" }, { name = "rich", specifier = ">=13.7.0" }, { name = "textual", specifier = ">=0.86.0" }, + { name = "textual-image", extras = ["textual"], marker = "extra == 'images'", specifier = ">=0.12.0" }, ] +provides-extras = ["images"] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=7.0.0" }] +dev = [ + { name = "pytest", specifier = ">=7.0.0" }, + { name = "textual-image", extras = ["textual"], specifier = ">=0.12.0" }, +] [[package]] name = "certifi" @@ -170,6 +187,100 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + [[package]] name = "platformdirs" version = "4.11.3" @@ -461,6 +572,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, ] +[[package]] +name = "textual-image" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "pillow" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/e7/c82ea0604874b6d51d5717a0911061ae5810e36dad2e4d2b11fa7d54cdaa/textual_image-0.12.0.tar.gz", hash = "sha256:fdd0b5ff9c8a99740bc360a99ce014d563fa97d07a5b49b472470809f57c0a74", size = 116403, upload-time = "2026-04-12T17:37:44.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/2c/38ac586a3834a3bd8cbcf0c8ea1ae23bf68b9cc13b96a67f47a825479dd3/textual_image-0.12.0-py3-none-any.whl", hash = "sha256:0b13f62fe6a29f4ed6dfd641f2779ffe92dde41f16163fa1de69158477aa50aa", size = 115626, upload-time = "2026-04-12T17:37:41.238Z" }, +] + +[package.optional-dependencies] +textual = [ + { name = "textual" }, +] + +[[package]] +name = "textual-image" +version = "0.13.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "pillow" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/77/b2128ced69556bfbb8e1c19d8f013e621cf12531eaba4e9b09e1cfa81e37/textual_image-0.13.2.tar.gz", hash = "sha256:8ca0cee2bfcd7734de5b16a1936da226b77b745e28830d9cf2bc202cb70e43ee", size = 2185178, upload-time = "2026-05-30T21:42:33.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/be/60aafd7263a6daa8cc4017e8bd4452c85b63ccd82a64f02636ca46e073b7/textual_image-0.13.2-py3-none-any.whl", hash = "sha256:41635f545ce2d0b3544763a2d10f25ada7f64c51acef77483b80fbfcf7ab22ee", size = 118469, upload-time = "2026-05-30T21:42:31.964Z" }, +] + +[package.optional-dependencies] +textual = [ + { name = "textual" }, +] + [[package]] name = "tomli" version = "2.4.1" From 81874a9f359ea5b15c24771de99aa14c8bec3fe7 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 20 Aug 2026 13:38:32 -0500 Subject: [PATCH 48/62] Resolve issue with going back to a blank screen and blank screen when log entry is full screen. --- tools/developer_tools/bely-cli/CLAUDE.md | 7 ++- tools/developer_tools/bely-cli/README.md | 6 +- .../bely-cli/src/bely_cli/tui/app.py | 4 +- .../src/bely_cli/tui/screens/browse.py | 28 +++++----- .../bely-cli/test/test_tui_app.py | 55 ++++++++++++++++++- 5 files changed, 77 insertions(+), 23 deletions(-) diff --git a/tools/developer_tools/bely-cli/CLAUDE.md b/tools/developer_tools/bely-cli/CLAUDE.md index 63a7ea842..999079d32 100644 --- a/tools/developer_tools/bely-cli/CLAUDE.md +++ b/tools/developer_tools/bely-cli/CLAUDE.md @@ -166,9 +166,10 @@ Split so that most of it needs no terminal to test: the `session` directly (like every other screen), not just its `data`. It's mode-aware: `select_mode` picks lookup's select-and-exit contract vs. the full app's stay-and-preview behavior, and `source` picks drilling in from logbook types vs. starting at the document - level with `core.recent_documents(...)`. Its `_exit_top()` quits when it's the bottom of - the screen stack (the landing case) and pops otherwise (e.g. a command-palette-pushed - "My documents" browse). + level with `core.recent_documents(...)`. Its `_exit_top()` pops back to whatever pushed + this browse (e.g. a command-palette-pushed "My documents" browse), or -- when `root=True` + (the landing screen `app.py` pushes on mount) -- does nothing but notify, since `q` is the + only quit key and an accidental escape shouldn't drop the user into an empty terminal. - `app.py` — `BelyTuiApp`, the shared CSS, `ensure_auth()` (see below), and `get_system_commands()`. Pushes `BrowseScreen` directly on mount for both modes; `mode="lookup"` sets `select_mode=True`. Everything that isn't tied to the current diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index eeceee5c2..bff0f3606 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -154,8 +154,8 @@ bely-cli tui --limit 50 | `--limit INTEGER` | Recent documents to load per logbook (default: 100). | Browsing needs no authentication. `Enter` on an entry just keeps it in the preview -instead of exiting, and `Esc` at the logbook list quits (there's nothing above it to pop -back to). A few keys are available once you've drilled in: +instead of exiting, and `Esc` at the logbook list does nothing (there's nothing above it to +pop back to) — press `q` to quit. A few keys are available once you've drilled in: | Key | Level | Action | |-----|-------|--------| @@ -283,7 +283,7 @@ on — `i` disappears once you drill into entries, and `s` / `y` / `e` / `f` onl | `Up` / `Down`, `PgUp` / `PgDn` | Move the highlight; the preview/info panel follows. | | `/` | Reveal and focus the filter box; incrementally filters the current table (case-insensitive substring). It hides itself again once it loses focus with nothing typed. | | `Enter` | In the filter box, return focus to the table. Elsewhere, drill into the highlighted row, or select the entry at the entries level. | -| `Esc` / `Backspace` | Go back one level (from the table); quits from the logbook list. In the filter box, `Esc` returns focus to the table. | +| `Esc` / `Backspace` | Go back one level (from the table); does nothing at the logbook list — press `q` to quit. In the filter box, `Esc` returns focus to the table. | | `d` | Logbook/document levels only: create a new document (see `bely-cli tui`'s "New document" above) — a mutation, so this is where the app authenticates if it hasn't already. | | `s` | Entries level only: save the highlighted entry's markdown to a file in the current directory. | | `y` | Entries level only: copy a `bely-cli entry get` reference for the highlighted entry to the clipboard. | diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py index 262ac38f6..809011b76 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/app.py @@ -120,7 +120,9 @@ def on_mount(self): saved = config.get_setting("theme") self.theme = saved if saved in self.available_themes else DEFAULT_THEME self._theme_loaded = True - self.push_screen(BrowseScreen(self.session, self.limit, select_mode=(self.mode == "lookup"))) + self.push_screen( + BrowseScreen(self.session, self.limit, select_mode=(self.mode == "lookup"), root=True) + ) def watch_theme(self, theme_name): """Persist a theme picked from the built-in palette command (not the initial on_mount load).""" diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py index 3576412c3..2bd6ca5b3 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py @@ -4,14 +4,13 @@ directly on mount, there is no separate landing/home screen: - `bely-cli tui lookup` (select_mode=True, source="types"): the original select-and-exit contract. Enter on an entry exits the app with - (doc, entry); escape at the top level exits with None. + (doc, entry). - `bely-cli tui` (select_mode=False): Enter on an entry is a no-op (the - preview is already live); escape at the top level quits if this is the - bottom of the screen stack, otherwise pops back to whatever pushed this - screen (e.g. the "My documents" command from the command palette). - source="recent" starts at the document level with the current user's - recently modified documents (core.recent_documents) instead of drilling - in from logbook types. + preview is already live). source="recent" starts at the document level + with the current user's recently modified documents + (core.recent_documents) instead of drilling in from logbook types. + +Escape at the top level pops back to whatever pushed this screen, or (when `root=True`) does nothing -- `q` quits instead. All three levels (types/docs/entries) render as one #nav-table DataTable, so navigation, filtering, and the info/full-width toggles share a single code @@ -106,13 +105,14 @@ class BrowseScreen(Screen): Binding("i", "toggle_info", "Info"), ] - def __init__(self, session, limit, *, select_mode=True, source="types"): + def __init__(self, session, limit, *, select_mode=True, source="types", root=False): super().__init__() self.session = session self.data = session.data self.limit = limit self.select_mode = select_mode self.source = source + self.root = root self.level = self.LEVEL_DOCS if source == "recent" else self.LEVEL_TYPES self.sel_type = None self.sel_doc = None @@ -182,6 +182,9 @@ def _ensure_columns(self): def show_level(self, level, *, preserve_filter=False): self.level = level + # "f" full-screen only applies at the entries level; reset it when leaving. + if level != self.LEVEL_ENTRIES: + self._nav_hidden = False # cancel any in-flight preview/image workers so a stale, now-mistyped item can't reach _show_preview self.app.workers.cancel_group(self, "preview") self.app.workers.cancel_group(self, "images") @@ -497,12 +500,11 @@ def action_back(self): self._exit_top() def _exit_top(self): - """Leave the screen from its top level: exit the app, or pop back to - whatever pushed this screen (e.g. a command-palette browse).""" - if self.select_mode or len(self.app.screen_stack) <= 1: - self.app.exit(None) - else: + """Escape at the top level: pop back to whatever pushed this screen, or notify if this is the landing screen.""" + if not self.root: self.app.pop_screen() + return + self.notify("Press q to quit.") def action_quit_app(self): self.app.exit(None) diff --git a/tools/developer_tools/bely-cli/test/test_tui_app.py b/tools/developer_tools/bely-cli/test/test_tui_app.py index 2d09948c9..e849480a5 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_app.py +++ b/tools/developer_tools/bely-cli/test/test_tui_app.py @@ -203,6 +203,31 @@ async def test_f_key_is_a_no_op_at_table_levels(self): self.assertTrue(table.display) self.assertFalse(screen.query_one("#preview").display) + async def test_going_back_from_full_screen_entry_restores_the_docs_view(self): + # Regression: escaping out of full-screen entries must not hide both panes. + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + async with app.run_test() as pilot: + await pilot.pause() + screen = app.screen + table = screen.query_one("#nav-table", DataTable) + preview = screen.query_one("#preview") + + await pilot.press("enter") # type -> docs + await pilot.pause() + await pilot.press("enter") # docs -> entries + await pilot.pause() + + await pilot.press("f") # full-screen the entry preview + await pilot.pause() + self.assertFalse(table.display) + self.assertTrue(preview.display) + + await pilot.press("escape") # back to docs + await pilot.pause() + self.assertTrue(table.display) + self.assertFalse(preview.display) + async def test_filter_narrows_the_list(self): data = LogbookData(FakeLogbookApi()) app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") @@ -293,16 +318,40 @@ async def test_footer_shortcuts_track_the_current_level(self): for action in entry_only_actions: self.assertTrue(screen.check_action(action, ())) - async def test_escape_at_landing_screen_quits_the_app(self): - # mode="app" has no separate landing/home screen -- BrowseScreen IS the - # landing screen, so escape at its top level exits like tui lookup does. + async def test_escape_at_landing_screen_does_not_quit(self): + # BrowseScreen is the landing screen in mode="app" too; only `q` quits. data = LogbookData(FakeLogbookApi()) app = BelyTuiApp(FakeSession(data), limit=10, mode="app") async with app.run_test() as pilot: await pilot.pause() + landing = app.screen self.assertFalse(app.screen.select_mode) await pilot.press("escape") await pilot.pause() + self.assertIs(app.screen, landing) + self.assertTrue(app.is_running) + self.assertEqual(len(app._notifications), 1) + + async def test_escape_at_lookup_landing_screen_does_not_quit(self): + # tui lookup's landing screen behaves the same as tui's -- q is the only quit key. + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + async with app.run_test() as pilot: + await pilot.pause() + landing = app.screen + self.assertTrue(app.screen.select_mode) + await pilot.press("escape") + await pilot.pause() + self.assertIs(app.screen, landing) + self.assertTrue(app.is_running) + + async def test_q_quits_the_app(self): + data = LogbookData(FakeLogbookApi()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="app") + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("q") + await pilot.pause() self.assertIsNone(app.return_value) async def test_command_palette_offers_config_recent_and_login(self): From 2faf007079887bbeaef83a357c26ae214bf0be78 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 20 Aug 2026 13:58:09 -0500 Subject: [PATCH 49/62] Add a basic dialog with save and cancel with ctrl+s and escape key bindings. --- tools/developer_tools/bely-cli/CLAUDE.md | 8 ++ tools/developer_tools/bely-cli/README.md | 8 +- .../src/bely_cli/tui/screens/compose.py | 68 +++--------- .../src/bely_cli/tui/screens/confirm.py | 40 ++----- .../src/bely_cli/tui/screens/dialog.py | 105 ++++++++++++++++++ .../bely-cli/test/test_tui_screens.py | 83 ++++++++++++++ 6 files changed, 226 insertions(+), 86 deletions(-) create mode 100644 tools/developer_tools/bely-cli/src/bely_cli/tui/screens/dialog.py diff --git a/tools/developer_tools/bely-cli/CLAUDE.md b/tools/developer_tools/bely-cli/CLAUDE.md index 999079d32..c963caaf2 100644 --- a/tools/developer_tools/bely-cli/CLAUDE.md +++ b/tools/developer_tools/bely-cli/CLAUDE.md @@ -159,6 +159,14 @@ Split so that most of it needs no terminal to test: lazily-populated authenticated factory. No Textual import — `try_token()`/`login(u, p)` delegate to `auth.py`, `username()` wraps `auth.get_configured_username()`. Built once in `cmd_tui` and handed to `BelyTuiApp`. +- `screens/dialog.py` — `DialogScreen`, the shared base every modal (`compose.py`, + `confirm.py`, `newdoc.py`, `configscreen.py`, `login.py`, `picker.py`) subclasses: a + bordered `.dialog` panel plus a `.dialog-buttons` row, `Esc` bound to cancel, and + up/down/left/right arrow actions that move between `BUTTON_ROWS` (a subclass-declared + list of button-id rows) — fields consume arrows themselves, so these only fire once focus + reaches the buttons. `ctrl+s` submits where a dialog has one primary action; button labels + carry the `^S`/`Esc` hints (`dialog.SAVE_HINT`/`CANCEL_HINT`) instead of a separate hint + line. - `screens/` — one module per screen (`browse.py`, `newdoc.py`, `compose.py`, `configscreen.py`, `login.py`, `picker.py`), each importing `core`/`config`/`common` directly rather than going through the Click layer. There is no separate landing/menu diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index bff0f3606..03c913d81 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -184,14 +184,14 @@ equivalent of Home's old menu items, plus what Textual provides by default: **Entry composer** A Markdown-aware `TextArea` for the entry body, an optional attachment path, and three -buttons (`Tab` cycles focus between the fields and buttons; `Enter` presses the focused -button): +buttons (`Tab`/arrows move focus between the fields and buttons; `Enter` presses the +focused button): | Button | Action | |--------|--------| -| Save | Save (and upload the attachment, if a path was entered). | +| Save `^S` | Save (and upload the attachment, if a path was entered). Also `ctrl+s` from anywhere in the dialog. | | Edit in $EDITOR | Suspend the TUI and open the buffer in `$EDITOR`; the edited text comes back into the `TextArea`. | -| Cancel | Cancel; asks for confirmation first if the buffer or attachment has unsaved changes. | +| Cancel `Esc` | Cancel; asks for confirmation first if the buffer or attachment has unsaved changes. Also `Esc` from anywhere in the dialog. | An empty new entry is skipped rather than saved, matching `entry add`'s behavior. diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/compose.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/compose.py index 0dcec2100..05e386249 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/compose.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/compose.py @@ -16,53 +16,31 @@ from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical -from textual.screen import ModalScreen from textual.widgets import Button, Input, Static, TextArea from ... import core from ...common import editor_changed +from .dialog import CANCEL_HINT, SAVE_HINT, DialogButtons, DialogScreen, hinted_label -class ComposeScreen(ModalScreen): - """Buttons-only entry composer; BINDINGS below are arrow-key focus navigation, not action shortcuts.""" +class ComposeScreen(DialogScreen): + """Markdown-aware entry composer with an optional attachment field.""" DEFAULT_CSS = """ - ComposeScreen { - align: center middle; - } - #compose-dialog { width: 90%; height: 80%; - border: thick $primary; - background: $surface; - padding: 1 2; } #compose-area { height: 1fr; } - - #compose-buttons { - height: auto; - align: right middle; - margin-top: 1; - } - - #compose-buttons Button { - margin-left: 1; - } """ - BINDINGS = [ - Binding("up", "focus_up", show=False), - Binding("down", "focus_down", show=False), - Binding("left", "focus_left", show=False), - Binding("right", "focus_right", show=False), - ] + BINDINGS = [Binding("ctrl+s", "submit", "Save", show=False)] # Save first: right after the attachment field in tab order, since it's used most. - BUTTON_IDS = ["compose-save", "compose-editor", "compose-cancel"] + BUTTON_ROWS = [["compose-save", "compose-editor", "compose-cancel"]] def __init__(self, doc, entry, api, *, is_new): super().__init__() @@ -75,16 +53,16 @@ def __init__(self, doc, entry, api, *, is_new): def compose(self) -> ComposeResult: title = (f'New entry in "{self.doc.name}"' if self.is_new else f'Update entry #{self.entry.log_id} in "{self.doc.name}"') - with Vertical(id="compose-dialog"): + with Vertical(id="compose-dialog", classes="dialog"): yield Static(title, id="compose-title") yield TextArea(self._initial_text, language="markdown", id="compose-area") with Horizontal(id="compose-attach-row"): yield Static("Attachment:", id="compose-attach-label") yield Input(placeholder="optional file path", id="compose-attach") - with Horizontal(id="compose-buttons"): - yield Button("Save", variant="primary", id="compose-save") - yield Button("Edit in $EDITOR", id="compose-editor") - yield Button("Cancel", id="compose-cancel") + with DialogButtons(): + yield Button(hinted_label("Save", SAVE_HINT), variant="primary", id="compose-save") + yield Button(hinted_label("Edit in $EDITOR"), id="compose-editor") + yield Button(hinted_label("Cancel", CANCEL_HINT), id="compose-cancel") def on_mount(self): self.query_one("#compose-area", TextArea).focus() @@ -97,29 +75,11 @@ def on_button_pressed(self, event): elif event.button.id == "compose-cancel": self._cancel() - # -- arrow-key nav: TextArea/Input consume arrows themselves, so this only fires past both -- + def action_submit(self): + self._save() - def action_focus_down(self): - if self.focused is self.query_one("#compose-attach", Input): - self.query_one(f"#{self.BUTTON_IDS[0]}", Button).focus() - - def action_focus_up(self): - if isinstance(self.focused, Button): - self.query_one("#compose-attach", Input).focus() - - def action_focus_left(self): - self._cycle_button(-1) - - def action_focus_right(self): - self._cycle_button(1) - - def _cycle_button(self, delta): - focused = self.focused - if not isinstance(focused, Button): - return - idx = self.BUTTON_IDS.index(focused.id) - target_id = self.BUTTON_IDS[(idx + delta) % len(self.BUTTON_IDS)] - self.query_one(f"#{target_id}", Button).focus() + def action_cancel(self): + self._cancel() # -- dirty check shared by cancel and save -- diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/confirm.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/confirm.py index ffa7e997b..6c3119567 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/confirm.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/confirm.py @@ -7,38 +7,20 @@ """ from textual.app import ComposeResult -from textual.binding import Binding -from textual.containers import Horizontal, Vertical -from textual.screen import ModalScreen +from textual.containers import Vertical from textual.widgets import Button, Static +from .dialog import CANCEL_HINT, DialogButtons, DialogScreen, hinted_label -class ConfirmScreen(ModalScreen): - DEFAULT_CSS = """ - ConfirmScreen { - align: center middle; - } +class ConfirmScreen(DialogScreen): + DEFAULT_CSS = """ #confirm-dialog { width: 60; - height: auto; - border: thick $primary; - background: $surface; - padding: 1 2; - } - - #confirm-buttons { - height: auto; - align: right middle; - margin-top: 1; - } - - #confirm-buttons Button { - margin-left: 1; } """ - BINDINGS = [Binding("escape", "cancel", "Cancel")] + BUTTON_ROWS = [["confirm-confirm", "confirm-cancel"]] def __init__(self, message, *, confirm_label="Yes", cancel_label="No", confirm_variant="primary"): @@ -49,14 +31,16 @@ def __init__(self, message, *, confirm_label="Yes", cancel_label="No", self.confirm_variant = confirm_variant def compose(self) -> ComposeResult: - with Vertical(id="confirm-dialog"): + with Vertical(id="confirm-dialog", classes="dialog"): yield Static(self.message, id="confirm-message") - with Horizontal(id="confirm-buttons"): - yield Button(self.cancel_label, id="confirm-cancel") - yield Button(self.confirm_label, variant=self.confirm_variant, id="confirm-confirm") + with DialogButtons(): + yield Button(hinted_label(self.confirm_label), variant=self.confirm_variant, id="confirm-confirm") + yield Button(hinted_label(self.cancel_label, CANCEL_HINT), id="confirm-cancel") def on_mount(self): - self.query_one("#confirm-confirm", Button).focus() + # Destructive confirmations default focus to "cancel" rather than the risky action. + default_id = "confirm-cancel" if self.confirm_variant == "error" else "confirm-confirm" + self.query_one(f"#{default_id}", Button).focus() def on_button_pressed(self, event): if event.button.id == "confirm-confirm": diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/dialog.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/dialog.py new file mode 100644 index 000000000..cc07f56e1 --- /dev/null +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/dialog.py @@ -0,0 +1,105 @@ +"""DialogScreen: shared modal base -- a bordered panel, Esc to cancel, arrows between fields/buttons.""" + +from textual.binding import Binding +from textual.containers import Horizontal +from textual.screen import ModalScreen +from textual.widgets import Button + +SAVE_HINT = "^S" +CANCEL_HINT = "Esc" + + +def hinted_label(label, hint=None): + """Button label with its shortcut dimmed on a second line, blank if none, so row buttons stay level.""" + return f"{label}\n[dim]{hint or ' '}[/dim]" + + +class DialogButtons(Horizontal): + DEFAULT_CLASSES = "dialog-buttons" + + +class DialogScreen(ModalScreen): + DEFAULT_CSS = """ + DialogScreen { + align: center middle; + } + + .dialog { + height: auto; + border: thick $primary; + background: $surface; + padding: 1 2; + } + + .dialog-buttons { + height: auto; + align: right middle; + margin-top: 1; + } + + .dialog-buttons Button { + margin-left: 1; + } + """ + + BINDINGS = [ + Binding("escape", "cancel", "Cancel", show=False), + Binding("up", "focus_up", show=False), + Binding("down", "focus_down", show=False), + Binding("left", "focus_left", show=False), + Binding("right", "focus_right", show=False), + ] + + # Subclasses override: list of button-id rows, top row first. + BUTTON_ROWS = [] + + def action_cancel(self): + self.dismiss(None) + + # -- arrow-key nav across BUTTON_ROWS -- + + def _focused_button_row(self): + focused = self.focused + if not isinstance(focused, Button): + return None + for row in self.BUTTON_ROWS: + if focused.id in row: + return row + return None + + def action_focus_left(self): + self._cycle_button(-1) + + def action_focus_right(self): + self._cycle_button(1) + + def _cycle_button(self, delta): + row = self._focused_button_row() + if row is None: + return + pos = row.index(self.focused.id) + self.query_one(f"#{row[(pos + delta) % len(row)]}", Button).focus() + + def action_focus_down(self): + row = self._focused_button_row() + if row is None: + self.focus_next() + return + idx = self.BUTTON_ROWS.index(row) + if idx + 1 < len(self.BUTTON_ROWS): + self.query_one(f"#{self.BUTTON_ROWS[idx + 1][0]}", Button).focus() + + def action_focus_up(self): + row = self._focused_button_row() + if row is None: + self.focus_previous() + return + idx = self.BUTTON_ROWS.index(row) + if idx > 0: + self.query_one(f"#{self.BUTTON_ROWS[idx - 1][0]}", Button).focus() + return + chain = self.focus_chain + first_button = self.query_one(f"#{self.BUTTON_ROWS[0][0]}", Button) + pos = chain.index(first_button) + if pos > 0: + chain[pos - 1].focus() diff --git a/tools/developer_tools/bely-cli/test/test_tui_screens.py b/tools/developer_tools/bely-cli/test/test_tui_screens.py index f33dc66fd..027139ae7 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_screens.py +++ b/tools/developer_tools/bely-cli/test/test_tui_screens.py @@ -396,6 +396,55 @@ async def test_arrow_keys_in_textarea_move_the_cursor_not_focus(self): await pilot.pause() self.assertIs(screen.focused, area) + async def test_escape_without_changes_dismisses_with_none(self): + api = FakeLogbookApi() + doc = SimpleNamespace(id=1, name="Doc") + entry = SimpleNamespace(log_id=7, log_entry="existing text") + app = App() + async with app.run_test() as pilot: + task = app.run_worker( + app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False))) + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + result = await task.wait() + self.assertIsNone(result) + + async def test_escape_with_unsaved_changes_prompts_discard_confirmation(self): + api = FakeLogbookApi() + doc = SimpleNamespace(id=1, name="Doc") + entry = SimpleNamespace(log_id=7, log_entry="existing text") + app = App() + async with app.run_test() as pilot: + task = app.run_worker( + app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=False))) + await pilot.pause() + area = app.screen.query_one("#compose-area", TextArea) + area.text = "existing text, changed" + await pilot.press("escape") + await pilot.pause() + self.assertEqual(type(app.screen).__name__, "ConfirmScreen") + app.screen.query_one("#confirm-confirm", Button).press() # "Discard" + await pilot.pause() + result = await task.wait() + self.assertIsNone(result) + + async def test_ctrl_s_saves(self): + api = FakeLogbookApi() + doc = SimpleNamespace(id=1, name="Doc") + entry = SimpleNamespace(log_id=None, log_entry="") + app = App() + async with app.run_test() as pilot: + task = app.run_worker( + app.push_screen_wait(ComposeScreen(doc, entry, api, is_new=True))) + await pilot.pause() + app.screen.query_one("#compose-area", TextArea).text = "hello world" + await pilot.press("ctrl+s") + await pilot.pause() + await pilot.pause() + saved = await task.wait() + self.assertEqual(saved.log_entry, "hello world") + class NewDocScreenPrefillTests(unittest.IsolatedAsyncioTestCase): async def test_prefilled_logbook_type_skips_the_type_picker(self): @@ -707,6 +756,40 @@ async def test_escape_dismisses_false(self): result = await task.wait() self.assertFalse(result) + async def test_default_focus_is_confirm(self): + app = App() + async with app.run_test() as pilot: + app.run_worker(app.push_screen_wait(ConfirmScreen("Are you sure?"))) + await pilot.pause() + self.assertIs(app.screen.focused, app.screen.query_one("#confirm-confirm", Button)) + + async def test_error_variant_defaults_focus_to_cancel(self): + app = App() + async with app.run_test() as pilot: + app.run_worker(app.push_screen_wait( + ConfirmScreen("Discard unsaved changes?", confirm_variant="error"))) + await pilot.pause() + self.assertIs(app.screen.focused, app.screen.query_one("#confirm-cancel", Button)) + + async def test_left_and_right_arrows_cycle_between_buttons(self): + app = App() + async with app.run_test() as pilot: + app.run_worker(app.push_screen_wait(ConfirmScreen("Are you sure?"))) + await pilot.pause() + screen = app.screen + + await pilot.press("right") + await pilot.pause() + self.assertIs(screen.focused, screen.query_one("#confirm-cancel", Button)) + + await pilot.press("right") # wraps back + await pilot.pause() + self.assertIs(screen.focused, screen.query_one("#confirm-confirm", Button)) + + await pilot.press("left") # wraps the other way + await pilot.pause() + self.assertIs(screen.focused, screen.query_one("#confirm-cancel", Button)) + if __name__ == "__main__": unittest.main() From cb144d4995311831f9148143a72ae4d9ad9e5b32 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 20 Aug 2026 14:20:39 -0500 Subject: [PATCH 50/62] Apply to picker and login screen --- tools/developer_tools/bely-cli/README.md | 7 +- .../src/bely_cli/tui/screens/login.py | 37 +++++----- .../src/bely_cli/tui/screens/picker.py | 60 +++++++-------- .../bely-cli/test/test_tui_screens.py | 73 +++++++++++++++++++ 4 files changed, 125 insertions(+), 52 deletions(-) diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index 03c913d81..fa1aabad9 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -202,7 +202,7 @@ Mirrors `doc new`: a name field, plus pickers for type, systems, and template. | Key | Action | |-----|--------| | `ctrl+t` | Pick the logbook type. | -| `ctrl+y` | Pick systems (multi-select — `space` toggles, `Enter` confirms). | +| `ctrl+y` | Pick systems (multi-select — `space` toggles, `Enter`/Confirm button confirms). | | `ctrl+m` | Pick a template, or "(no template)" to skip. | | `ctrl+s` | Create the document. | | `Esc` | Cancel. | @@ -235,8 +235,9 @@ Browsing needs no login. The first time you add or update an entry, create a doc save a config change, the app looks for the token the CLI already caches (see [Authentication](#authentication) above) and reuses it silently if it's valid — so if you've already run an authenticated `bely-cli` command, or a previous `tui` session, you won't be -prompted again. Otherwise a login modal appears; a successful login is cached the same way -the CLI caches it, shared by later `bely-cli` commands and TUI sessions alike. +prompted again. Otherwise a login modal appears (username, password, and `Log in`/`Cancel` +buttons — `ctrl+s` also submits, `Esc` also cancels); a successful login is cached the same +way the CLI caches it, shared by later `bely-cli` commands and TUI sessions alike. #### `bely-cli tui lookup` diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/login.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/login.py index 338357cdd..2a064935b 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/login.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/login.py @@ -8,37 +8,34 @@ from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Vertical -from textual.screen import ModalScreen -from textual.widgets import Input, Static +from textual.widgets import Button, Input, Static +from .dialog import CANCEL_HINT, SAVE_HINT, DialogButtons, DialogScreen, hinted_label -class LoginScreen(ModalScreen): - DEFAULT_CSS = """ - LoginScreen { - align: center middle; - } +class LoginScreen(DialogScreen): + DEFAULT_CSS = """ #login-dialog { width: 60; - height: auto; - border: thick $primary; - background: $surface; - padding: 1 2; } """ - BINDINGS = [Binding("escape", "cancel", "Cancel")] + BINDINGS = [Binding("ctrl+s", "submit", "Log in", show=False)] + + BUTTON_ROWS = [["login-submit", "login-cancel"]] def __init__(self, prefill=""): super().__init__() self._prefill = prefill def compose(self) -> ComposeResult: - with Vertical(id="login-dialog"): + with Vertical(id="login-dialog", classes="dialog"): yield Static("Log in to BELY", id="login-title") yield Input(value=self._prefill, placeholder="username", id="login-username") yield Input(password=True, placeholder="password", id="login-password") - yield Static("[enter] submit [escape] cancel", id="login-hint") + with DialogButtons(): + yield Button(hinted_label("Log in", SAVE_HINT), variant="primary", id="login-submit") + yield Button(hinted_label("Cancel", CANCEL_HINT), id="login-cancel") def on_mount(self): field = self.query_one("#login-username", Input) @@ -51,6 +48,15 @@ def on_input_submitted(self, event): elif event.input.id == "login-password": self._submit() + def on_button_pressed(self, event): + if event.button.id == "login-submit": + self._submit() + elif event.button.id == "login-cancel": + self.dismiss(None) + + def action_submit(self): + self._submit() + def _submit(self): username = self.query_one("#login-username", Input).value.strip() password = self.query_one("#login-password", Input).value @@ -58,6 +64,3 @@ def _submit(self): self.notify("Username is required.", severity="warning") return self.dismiss((username, password)) - - def action_cancel(self): - self.dismiss(None) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/picker.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/picker.py index 403320996..e6fa28530 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/picker.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/picker.py @@ -2,37 +2,24 @@ Wraps an OptionList with a filter Input (reusing format.filter_items, the same filtering used by BrowseScreen). Single-select dismisses with the chosen -item on Enter. Multi-select toggles the highlighted item with `space` and -dismisses with the list of selected items on Enter -- so Enter always means -"confirm", whether that's one item or the current multi-selection. - -Also doubles as a lightweight yes/no confirm dialog: pass two plain strings -as `items` with an identity `label_fn` (see ComposeScreen's discard-changes -check and NewDocScreen's post-create prompts) rather than adding a separate -screen class just for that. +item on Enter or the Select button. Multi-select toggles the highlighted item +with `space` and dismisses with the list of selected items on Enter or the +Confirm button. """ from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Vertical -from textual.screen import ModalScreen -from textual.widgets import Input, OptionList, Static +from textual.widgets import Button, Input, OptionList, Static from ..format import filter_items +from .dialog import CANCEL_HINT, DialogButtons, DialogScreen, hinted_label -class PickerScreen(ModalScreen): +class PickerScreen(DialogScreen): DEFAULT_CSS = """ - PickerScreen { - align: center middle; - } - #picker-dialog { width: 60; - height: auto; - border: thick $primary; - background: $surface; - padding: 1 2; } #picker-list { @@ -41,10 +28,9 @@ class PickerScreen(ModalScreen): } """ - BINDINGS = [ - Binding("escape", "cancel", "Cancel"), - Binding("space", "toggle", "Toggle", show=False), - ] + BINDINGS = [Binding("space", "toggle", "Toggle", show=False)] + + BUTTON_ROWS = [["picker-select", "picker-cancel"]] def __init__(self, title, items, label_fn, *, multi=False): super().__init__() @@ -56,13 +42,14 @@ def __init__(self, title, items, label_fn, *, multi=False): self.shown = list(self.items) def compose(self) -> ComposeResult: - hint = ("[space] toggle [enter] confirm [escape] cancel" if self.multi - else "[enter] select [escape] cancel") - with Vertical(id="picker-dialog"): + with Vertical(id="picker-dialog", classes="dialog"): yield Static(self.title_text, id="picker-title") yield Input(placeholder="type to filter", id="picker-filter") yield OptionList(id="picker-list") - yield Static(hint, id="picker-hint") + with DialogButtons(): + select_label = "Confirm" if self.multi else "Select" + yield Button(hinted_label(select_label), variant="primary", id="picker-select") + yield Button(hinted_label("Cancel", CANCEL_HINT), id="picker-cancel") def on_mount(self): self._populate("") @@ -97,10 +84,22 @@ def on_input_submitted(self, event): def on_option_list_option_selected(self, event): if event.option_list.id != "picker-list": return + self._select_highlighted() + + def on_button_pressed(self, event): + if event.button.id == "picker-select": + self._select_highlighted() + elif event.button.id == "picker-cancel": + self.dismiss(None) + + def _select_highlighted(self): if self.multi: self._confirm_multi() - else: - self.dismiss(self.shown[event.option_index]) + return + lst = self.query_one("#picker-list", OptionList) + if lst.highlighted is None or not self.shown: + return + self.dismiss(self.shown[lst.highlighted]) def action_toggle(self): if not self.multi: @@ -119,6 +118,3 @@ def action_toggle(self): def _confirm_multi(self): self.dismiss([self.items[i] for i in sorted(self.selected)]) - - def action_cancel(self): - self.dismiss(None) diff --git a/tools/developer_tools/bely-cli/test/test_tui_screens.py b/tools/developer_tools/bely-cli/test/test_tui_screens.py index 027139ae7..9585fc17e 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_screens.py +++ b/tools/developer_tools/bely-cli/test/test_tui_screens.py @@ -130,6 +130,39 @@ async def test_empty_username_blocks_submit(self): result = await task.wait() self.assertIsNone(result) + async def test_submit_button_dismisses_with_username_and_password(self): + app = App() + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait(LoginScreen("alice"))) + await pilot.pause() + screen = app.screen + screen.query_one("#login-password", Input).value = "secret" + screen.query_one("#login-submit", Button).press() + await pilot.pause() + result = await task.wait() + self.assertEqual(result, ("alice", "secret")) + + async def test_cancel_button_dismisses_with_none(self): + app = App() + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait(LoginScreen())) + await pilot.pause() + app.screen.query_one("#login-cancel", Button).press() + await pilot.pause() + result = await task.wait() + self.assertIsNone(result) + + async def test_ctrl_s_submits(self): + app = App() + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait(LoginScreen("alice"))) + await pilot.pause() + app.screen.query_one("#login-password", Input).value = "secret" + await pilot.press("ctrl+s") + await pilot.pause() + result = await task.wait() + self.assertEqual(result, ("alice", "secret")) + class PickerScreenTests(unittest.IsolatedAsyncioTestCase): async def test_single_select_enter_dismisses_item(self): @@ -208,6 +241,46 @@ async def test_escape_cancels_with_none(self): result = await task.wait() self.assertIsNone(result) + async def test_select_button_dismisses_highlighted_item(self): + app = App() + items = [SimpleNamespace(name="Alpha"), SimpleNamespace(name="Beta")] + async with app.run_test() as pilot: + task = app.run_worker( + app.push_screen_wait(PickerScreen("Pick one", items, lambda i: i.name))) + await pilot.pause() + app.screen.query_one("#picker-select", Button).press() + await pilot.pause() + result = await task.wait() + self.assertEqual(result.name, "Alpha") + + async def test_cancel_button_cancels_with_none(self): + app = App() + items = [SimpleNamespace(name="Alpha")] + async with app.run_test() as pilot: + task = app.run_worker( + app.push_screen_wait(PickerScreen("Pick", items, lambda i: i.name))) + await pilot.pause() + app.screen.query_one("#picker-cancel", Button).press() + await pilot.pause() + result = await task.wait() + self.assertIsNone(result) + + async def test_confirm_button_dismisses_multi_selection(self): + app = App() + items = [SimpleNamespace(name="Alpha"), SimpleNamespace(name="Beta")] + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait( + PickerScreen("Pick many", items, lambda i: i.name, multi=True))) + await pilot.pause() + await pilot.press("enter") # filter -> list + await pilot.pause() + await pilot.press("space") # toggle Alpha + await pilot.pause() + app.screen.query_one("#picker-select", Button).press() + await pilot.pause() + result = await task.wait() + self.assertEqual([i.name for i in result], ["Alpha"]) + class ComposeScreenTests(unittest.IsolatedAsyncioTestCase): async def test_save_button_saves_and_dismisses_with_entry(self): From a7edb3688124a7e7413407d8fdc154094715971e Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 20 Aug 2026 14:40:13 -0500 Subject: [PATCH 51/62] Apply buttons for new document dialog --- tools/developer_tools/bely-cli/README.md | 17 ++-- .../src/bely_cli/tui/screens/newdoc.py | 81 +++++++++---------- .../bely-cli/test/test_tui_screens.py | 21 ++--- 3 files changed, 54 insertions(+), 65 deletions(-) diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index fa1aabad9..3017ce851 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -197,15 +197,16 @@ An empty new entry is skipped rather than saved, matching `entry add`'s behavior **New document** -Mirrors `doc new`: a name field, plus pickers for type, systems, and template. +Mirrors `doc new`: a name field, plus buttons that open pickers for type, systems, and +template: -| Key | Action | -|-----|--------| -| `ctrl+t` | Pick the logbook type. | -| `ctrl+y` | Pick systems (multi-select — `space` toggles, `Enter`/Confirm button confirms). | -| `ctrl+m` | Pick a template, or "(no template)" to skip. | -| `ctrl+s` | Create the document. | -| `Esc` | Cancel. | +| Button | Action | +|--------|--------| +| Type… | Pick the logbook type. | +| Systems… | Pick systems (multi-select — `space` toggles, `Enter`/Confirm button confirms). | +| Template… | Pick a template, or "(no template)" to skip. | +| Create `^S` | Create the document. Also `ctrl+s` from anywhere in the dialog. | +| Cancel `Esc` | Cancel. Also `Esc` from anywhere in the dialog. | After creating, it reproduces `doc new`'s post-create prompts: if the template already generated an entry it offers to edit it, otherwise it offers to create one — both open the diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/newdoc.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/newdoc.py index 1ff3734e8..1e9e93329 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/newdoc.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/newdoc.py @@ -18,38 +18,29 @@ from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Vertical -from textual.screen import ModalScreen -from textual.widgets import Input, Static +from textual.widgets import Button, Input, Static from ... import core from ...common import find_logdoc +from .dialog import CANCEL_HINT, SAVE_HINT, DialogButtons, DialogScreen, hinted_label # Sentinel for "explicitly skip the default template" -- distinct from # PickerScreen's own None-on-cancel so the two can't be confused. _NO_TEMPLATE = SimpleNamespace(id=None, name="(no template)") -class NewDocScreen(ModalScreen): +class NewDocScreen(DialogScreen): DEFAULT_CSS = """ - NewDocScreen { - align: center middle; - } - #newdoc-dialog { width: 70; - height: auto; - border: thick $primary; - background: $surface; - padding: 1 2; } """ - BINDINGS = [ - Binding("ctrl+t", "pick_type", "Type"), - Binding("ctrl+y", "pick_systems", "Systems"), - Binding("ctrl+m", "pick_template", "Template"), - Binding("ctrl+s", "submit", "Create"), - Binding("escape", "cancel", "Cancel"), + BINDINGS = [Binding("ctrl+s", "submit", "Create", show=False)] + + BUTTON_ROWS = [ + ["newdoc-pick-type", "newdoc-pick-systems", "newdoc-pick-template"], + ["newdoc-create", "newdoc-cancel"], ] def __init__(self, session, logbook_type=None): @@ -61,22 +52,36 @@ def __init__(self, session, logbook_type=None): self.skip_template = False def compose(self) -> ComposeResult: - with Vertical(id="newdoc-dialog"): + with Vertical(id="newdoc-dialog", classes="dialog"): yield Static("New document", id="newdoc-title") yield Input(placeholder="document name", id="newdoc-name") yield Static(id="newdoc-type") yield Static(id="newdoc-systems") yield Static(id="newdoc-template") - yield Static( - "[ctrl+t] type [ctrl+y] systems [ctrl+m] template " - "[ctrl+s] create [escape] cancel", - id="newdoc-hint", - ) + with DialogButtons(): + yield Button("Type…", id="newdoc-pick-type") + yield Button("Systems…", id="newdoc-pick-systems") + yield Button("Template…", id="newdoc-pick-template") + with DialogButtons(): + yield Button(hinted_label("Create", SAVE_HINT), variant="primary", id="newdoc-create") + yield Button(hinted_label("Cancel", CANCEL_HINT), id="newdoc-cancel") def on_mount(self): self._refresh_labels() self.query_one("#newdoc-name", Input).focus() + def on_button_pressed(self, event): + if event.button.id == "newdoc-pick-type": + self._pick_type() + elif event.button.id == "newdoc-pick-systems": + self._pick_systems() + elif event.button.id == "newdoc-pick-template": + self._pick_template() + elif event.button.id == "newdoc-create": + self._create() + elif event.button.id == "newdoc-cancel": + self.dismiss(None) + def _refresh_labels(self): type_label = self.logbook_type.name if self.logbook_type else "(none)" self.query_one("#newdoc-type", Static).update(f"Type: {type_label}") @@ -92,14 +97,8 @@ def _refresh_labels(self): template_label = "(none)" self.query_one("#newdoc-template", Static).update(f"Template: {template_label}") - def action_cancel(self): - self.dismiss(None) - # -- pickers -- - def action_pick_type(self): - self._pick_type() - @work async def _pick_type(self): from .picker import PickerScreen @@ -116,9 +115,6 @@ async def _pick_type(self): self.logbook_type = choice self._refresh_labels() - def action_pick_systems(self): - self._pick_systems() - @work async def _pick_systems(self): from .picker import PickerScreen @@ -135,9 +131,6 @@ async def _pick_systems(self): self.systems = choice self._refresh_labels() - def action_pick_template(self): - self._pick_template() - @work async def _pick_template(self): from .picker import PickerScreen @@ -173,7 +166,7 @@ async def _create(self): self.notify("Document name is required.", severity="warning") return if self.logbook_type is None: - self.notify("Pick a logbook type (ctrl+t) first.", severity="warning") + self.notify("Pick a logbook type first.", severity="warning") return try: @@ -206,7 +199,7 @@ async def _create(self): async def _post_create(self, api, doc): from .compose import open_composer - from .picker import PickerScreen + from .confirm import ConfirmScreen try: entries = await asyncio.to_thread(api.get_log_entries, log_document_id=doc.id) @@ -215,19 +208,19 @@ async def _post_create(self, api, doc): if entries: entry = entries[0] - choice = await self.app.push_screen_wait( - PickerScreen( + edit_now = await self.app.push_screen_wait( + ConfirmScreen( f"Template generated log entry #{entry.log_id}. Edit it now?", - ["Edit now", "Leave as-is"], lambda x: x, + confirm_label="Edit now", cancel_label="Leave as-is", ) ) - if choice == "Edit now": + if edit_now: await open_composer(self.app, doc, api, entry=entry) else: - choice = await self.app.push_screen_wait( - PickerScreen("Create a log entry now?", ["Create entry", "Skip"], lambda x: x) + create_entry = await self.app.push_screen_wait( + ConfirmScreen("Create a log entry now?", confirm_label="Create entry", cancel_label="Skip") ) - if choice == "Create entry": + if create_entry: await open_composer(self.app, doc, api) self.dismiss(doc) diff --git a/tools/developer_tools/bely-cli/test/test_tui_screens.py b/tools/developer_tools/bely-cli/test/test_tui_screens.py index 9585fc17e..4c79b67fc 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_screens.py +++ b/tools/developer_tools/bely-cli/test/test_tui_screens.py @@ -538,14 +538,12 @@ async def test_prefilled_logbook_type_skips_the_type_picker(self): app.screen.query_one("#newdoc-type", Static).content, "Type: ops") app.screen.query_one("#newdoc-name", Input).value = "New Doc" - await pilot.press("ctrl+s") # create without ever touching ctrl+t + await pilot.press("ctrl+s") # create without ever touching the type picker await pilot.pause() await pilot.pause() - await pilot.press("enter") # filter -> list ("Create a log entry now?") - await pilot.pause() - await pilot.press("down") # highlight "Skip" - await pilot.press("enter") + # no entries came back -> offered to create one; pick "Skip". + app.screen.query_one("#confirm-cancel", Button).press() await pilot.pause() doc = await task.wait() @@ -568,7 +566,7 @@ async def test_create_with_type_systems_and_no_template_then_skip_entry(self): await pilot.pause() app.screen.query_one("#newdoc-name", Input).value = "New Doc" - await pilot.press("ctrl+t") # -> type picker + app.screen.query_one("#newdoc-pick-type", Button).press() # -> type picker await pilot.pause() await pilot.pause() await pilot.press("enter") # filter -> list @@ -576,7 +574,7 @@ async def test_create_with_type_systems_and_no_template_then_skip_entry(self): await pilot.press("enter") # select "ops" await pilot.pause() - await pilot.press("ctrl+y") # -> systems picker (multi) + app.screen.query_one("#newdoc-pick-systems", Button).press() # -> systems picker (multi) await pilot.pause() await pilot.pause() await pilot.press("enter") # filter -> list @@ -586,7 +584,7 @@ async def test_create_with_type_systems_and_no_template_then_skip_entry(self): await pilot.press("enter") # confirm selection await pilot.pause() - await pilot.press("ctrl+m") # -> template picker + app.screen.query_one("#newdoc-pick-template", Button).press() # -> template picker await pilot.pause() await pilot.pause() await pilot.press("enter") # filter -> list, "(no template)" highlighted @@ -601,10 +599,7 @@ async def test_create_with_type_systems_and_no_template_then_skip_entry(self): # no entries came back from the (empty) template -> offered to # create one; pick "Skip". - await pilot.press("enter") # filter -> list - await pilot.pause() - await pilot.press("down") # highlight "Skip" - await pilot.press("enter") + app.screen.query_one("#confirm-cancel", Button).press() await pilot.pause() doc = await task.wait() @@ -627,7 +622,7 @@ async def test_duplicate_name_is_rejected_without_creating(self): await pilot.pause() app.screen.query_one("#newdoc-name", Input).value = "Dup" - await pilot.press("ctrl+t") + app.screen.query_one("#newdoc-pick-type", Button).press() await pilot.pause() await pilot.pause() await pilot.press("enter") From 6a8b24dc8d9ec674c5deb38d84143bec70b96435 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 20 Aug 2026 14:51:01 -0500 Subject: [PATCH 52/62] Integrate the configuration screen with the buttons --- tools/developer_tools/bely-cli/README.md | 12 +-- .../src/bely_cli/tui/screens/configscreen.py | 56 +++++------ .../bely-cli/test/test_tui_screens.py | 94 +++++++++++++++++++ 3 files changed, 124 insertions(+), 38 deletions(-) diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index 3017ce851..29233b015 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -219,12 +219,12 @@ field per setting (a dropdown for `images`, a text input for everything else), p from `settings.yaml`, alongside a summary of the current settings and any environment-variable overrides. -| Key | Action | -|-----|--------| -| `ctrl+s` | Save changed fields (same effect as `config set FIELD VALUE`). | -| `ctrl+e` | Suspend the TUI and open the settings file in `$EDITOR`, then reload. | -| `r` | Reload from disk, discarding unsaved edits in the form. | -| `Esc` | Close the dialog. | +| Button | Action | +|--------|--------| +| Save `^S` | Save changed fields (same effect as `config set FIELD VALUE`). Also `ctrl+s` from anywhere in the dialog. | +| Edit file | Suspend the TUI and open the settings file in `$EDITOR`, then reload. | +| Reload | Reload from disk, discarding unsaved edits in the form. | +| Close `Esc` | Close the dialog. Also `Esc` from anywhere in the dialog. | A field whose effective value comes from an environment variable (`BELY_HOST`, `BELY_USER`, `EDITOR`) shows that in its placeholder, and saving it warns that the env var will keep diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py index 415b2203a..38868833a 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/configscreen.py @@ -13,34 +13,23 @@ from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Vertical -from textual.screen import ModalScreen -from textual.widgets import Input, Select, Static +from textual.widgets import Button, Input, Select, Static from ... import config, core from ..images import IMAGE_MODE_HELP, IMAGE_MODES +from .dialog import CANCEL_HINT, SAVE_HINT, DialogButtons, DialogScreen, hinted_label -class ConfigScreen(ModalScreen): +class ConfigScreen(DialogScreen): DEFAULT_CSS = """ - ConfigScreen { - align: center middle; - } - #config-dialog { width: 70; - height: auto; - border: thick $primary; - background: $surface; - padding: 1 2; } """ - BINDINGS = [ - Binding("escape", "back", "Back"), - Binding("ctrl+s", "save", "Save"), - Binding("ctrl+e", "open_editor", "Edit file"), - Binding("r", "reload", "Reload"), - ] + BINDINGS = [Binding("ctrl+s", "submit", "Save", show=False)] + + BUTTON_ROWS = [["config-save", "config-edit", "config-reload", "config-close"]] # Fields whose effective value can be overridden by an env var the CLI # also honors (see auth.py's precedence) -- token_path has none. @@ -50,7 +39,7 @@ class ConfigScreen(ModalScreen): FIELD_DEFAULTS = {"images": "auto"} def compose(self) -> ComposeResult: - with Vertical(id="config-dialog"): + with Vertical(id="config-dialog", classes="dialog"): yield Static(id="config-breadcrumb") yield Static(id="config-summary") for field in config.VALID_FIELDS: @@ -63,19 +52,25 @@ def compose(self) -> ComposeResult: ) else: yield Input(placeholder=field, id=f"config-{field}") - yield Static( - "[ctrl+s] save [ctrl+e] edit file [r] reload [escape] back", - id="config-hint", - ) + with DialogButtons(): + yield Button(hinted_label("Save", SAVE_HINT), variant="primary", id="config-save") + yield Button(hinted_label("Edit file"), id="config-edit") + yield Button(hinted_label("Reload"), id="config-reload") + yield Button(hinted_label("Close", CANCEL_HINT), id="config-close") def on_mount(self): self._load() - - def action_back(self): - self.dismiss(None) - - def action_reload(self): - self._load() + self.query_one(f"#config-{config.VALID_FIELDS[0]}").focus() + + def on_button_pressed(self, event): + if event.button.id == "config-save": + self._save() + elif event.button.id == "config-edit": + self._open_editor() + elif event.button.id == "config-reload": + self._load() + elif event.button.id == "config-close": + self.dismiss(None) def _load(self): data = core.collect_config() @@ -110,7 +105,7 @@ def _load(self): f"{field} (overridden by {env_var})" if env_var and env_var in env else field ) - def action_save(self): + def action_submit(self): self._save() @work @@ -150,9 +145,6 @@ async def _save(self): self._load() - def action_open_editor(self): - self._open_editor() - @work async def _open_editor(self): settings_file = await asyncio.to_thread(core.ensure_settings_file) diff --git a/tools/developer_tools/bely-cli/test/test_tui_screens.py b/tools/developer_tools/bely-cli/test/test_tui_screens.py index 4c79b67fc..ab1fcb54a 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_screens.py +++ b/tools/developer_tools/bely-cli/test/test_tui_screens.py @@ -658,6 +658,30 @@ async def test_escape_dismisses_the_modal(self): result = await task.wait() self.assertIsNone(result) + async def test_close_button_dismisses_the_modal(self): + state = {"settings_file": "/tmp/settings.yaml", "settings": {}, "environment": {}} + app = App() + with patch.object(configscreen.core, "collect_config", side_effect=lambda: dict(state)), \ + patch.object(configscreen.config, "get_setting", side_effect=lambda k: None): + async with app.run_test() as pilot: + task = app.run_worker(app.push_screen_wait(ConfigScreen())) + await pilot.pause() + app.screen.query_one("#config-close", Button).press() + await pilot.pause() + result = await task.wait() + self.assertIsNone(result) + + async def test_initial_focus_is_the_first_field(self): + state = {"settings_file": "/tmp/settings.yaml", "settings": {}, "environment": {}} + app = App() + with patch.object(configscreen.core, "collect_config", side_effect=lambda: dict(state)), \ + patch.object(configscreen.config, "get_setting", side_effect=lambda k: None): + async with app.run_test() as pilot: + app.push_screen(ConfigScreen()) + await pilot.pause() + screen = app.screen + self.assertIs(screen.focused, screen.query_one("#config-host", Input)) + async def test_load_prefills_inputs_and_flags_env_overrides(self): state = { "settings_file": "/tmp/settings.yaml", @@ -705,6 +729,76 @@ def fake_set_setting(key, value): self.assertEqual(saved, [("host", "https://new")]) + async def test_save_button_writes_changed_fields(self): + state = { + "settings_file": "/tmp/settings.yaml", + "settings": {"host": "https://old"}, + "environment": {}, + } + saved = [] + + def fake_set_setting(key, value): + saved.append((key, value)) + state["settings"][key] = value + + app = App() + with patch.object(configscreen.core, "collect_config", side_effect=lambda: dict(state)), \ + patch.object(configscreen.config, "get_setting", + side_effect=lambda k: state["settings"].get(k)), \ + patch.object(configscreen.config, "set_setting", side_effect=fake_set_setting): + async with app.run_test() as pilot: + app.push_screen(ConfigScreen()) + await pilot.pause() + screen = app.screen + screen.query_one("#config-host", Input).value = "https://new" + screen.query_one("#config-save", Button).press() + await pilot.pause() + await pilot.pause() + + self.assertEqual(saved, [("host", "https://new")]) + + async def test_reload_button_reloads_from_disk(self): + state = { + "settings_file": "/tmp/settings.yaml", + "settings": {"host": "https://original"}, + "environment": {}, + } + app = App() + with patch.object(configscreen.core, "collect_config", side_effect=lambda: dict(state)), \ + patch.object(configscreen.config, "get_setting", + side_effect=lambda k: state["settings"].get(k)): + async with app.run_test() as pilot: + app.push_screen(ConfigScreen()) + await pilot.pause() + screen = app.screen + host_input = screen.query_one("#config-host", Input) + host_input.value = "https://unsaved-edit" + screen.query_one("#config-reload", Button).press() + await pilot.pause() + + self.assertEqual(host_input.value, "https://original") + + async def test_edit_button_opens_editor_and_reloads(self): + from contextlib import nullcontext + + state = {"settings_file": "/tmp/settings.yaml", "settings": {}, "environment": {}} + app = App() + with patch.object(configscreen.core, "collect_config", side_effect=lambda: dict(state)), \ + patch.object(configscreen.config, "get_setting", side_effect=lambda k: None), \ + patch.object(configscreen.core, "ensure_settings_file", + return_value="/tmp/settings.yaml"), \ + patch.object(configscreen.config, "get_editor", return_value="nano"), \ + patch.object(configscreen, "subprocess") as fake_subprocess, \ + patch.object(app, "suspend", return_value=nullcontext()): + async with app.run_test() as pilot: + app.push_screen(ConfigScreen()) + await pilot.pause() + app.screen.query_one("#config-edit", Button).press() + await pilot.pause() + await pilot.pause() + + fake_subprocess.call.assert_called_once_with(["nano", "/tmp/settings.yaml"]) + async def test_images_field_is_a_select_defaulting_to_auto(self): state = {"settings_file": "/tmp/settings.yaml", "settings": {}, "environment": {}} app = App() From 89eb1e22b540f88c7b778e86268517b58ff76135 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 20 Aug 2026 15:24:38 -0500 Subject: [PATCH 53/62] Prepare tree format for displaying replies. --- .../bely-cli/src/bely_cli/tui/format.py | 75 +++++++++++- .../developer_tools/bely-cli/test/test_tui.py | 108 ++++++++++++++++++ 2 files changed, 178 insertions(+), 5 deletions(-) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/format.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/format.py index 5944087ee..1947c3cdb 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/format.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/format.py @@ -5,6 +5,8 @@ test/test_tui.py) without touching a terminal. """ +from typing import NamedTuple, Optional + # -- list-row formatting (used by both the old curses UI and the new # Textual OptionList rows) -- @@ -109,6 +111,69 @@ def entry_row(e): return (date, author, _entry_snippet(e)) +# -- reply tree -- + +class EntryNode(NamedTuple): + """One flattened row of the entries tree: an entry plus its position in the reply thread.""" + + entry: object + depth: int + parent: Optional[object] + branch: str + reply_count: int + expanded: bool + + +def entry_replies(e): + """The direct replies of an entry, or [] if none.""" + return getattr(e, "log_replies", None) or [] + + +def _branch_prefix(prefix_lasts, is_last): + """Box-drawing prefix for a reply row: continuation bars for ancestors, then a connector.""" + parts = [" " if last else "│ " for last in prefix_lasts] + parts.append("└─ " if is_last else "├─ ") + return " " + "".join(parts) + + +def flatten_entries(entries, collapsed=()): + """Depth-first [EntryNode] over entries and their replies, skipping children of collapsed ids.""" + collapsed = set(collapsed) + nodes = [] + + def walk(items, depth, parent, prefix_lasts): + for i, e in enumerate(items): + is_last = i == len(items) - 1 + branch = _branch_prefix(prefix_lasts, is_last) if depth > 0 else "" + replies = entry_replies(e) + expanded = getattr(e, "log_id", None) not in collapsed + nodes.append(EntryNode( + entry=e, depth=depth, parent=parent, branch=branch, + reply_count=len(replies), expanded=expanded, + )) + if replies and expanded: + next_prefix = prefix_lasts + [is_last] if depth > 0 else [] + walk(replies, depth + 1, e, next_prefix) + + walk(entries, 0, None, []) + return nodes + + +def entry_node_row(node): + """DataTable row cells for an EntryNode: entry_row's cells with a tree glyph on the entry column.""" + date, author, snippet = entry_row(node.entry) + if node.depth > 0: + entry_cell = node.branch + snippet + elif node.reply_count == 0: + entry_cell = " " + snippet + elif node.expanded: + entry_cell = "▾ " + snippet + else: + noun = "reply" if node.reply_count == 1 else "replies" + entry_cell = f"▸ {snippet} ({node.reply_count} {noun})" + return (date, author, entry_cell) + + # -- filtering / navigation -- def filter_items(items, query, render_fn): @@ -152,12 +217,12 @@ def summarize_reactions(reactions): return " ".join(f"{label} {counts[label]}" for label in order) -def entry_metadata_rows(entry, doc): +def entry_metadata_rows(entry, doc, parent=None): """[(label, value)] metadata rows for the entry preview header.""" - rows = [ - ("log_id", str(getattr(entry, "log_id", "") or "")), - ("doc", getattr(doc, "name", None) or ""), - ] + rows = [("log_id", str(getattr(entry, "log_id", "") or ""))] + if parent is not None: + rows.append(("reply to", str(getattr(parent, "log_id", "") or ""))) + rows.append(("doc", getattr(doc, "name", None) or "")) entered_by = getattr(entry, "entered_by_username", None) or "" entered_at = _fmt_dt(getattr(entry, "entered_on_date_time", None)) diff --git a/tools/developer_tools/bely-cli/test/test_tui.py b/tools/developer_tools/bely-cli/test/test_tui.py index 0731030f4..48ed30a5a 100644 --- a/tools/developer_tools/bely-cli/test/test_tui.py +++ b/tools/developer_tools/bely-cli/test/test_tui.py @@ -110,6 +110,100 @@ def test_skips_blank_leading_lines(self): self.assertEqual(snippet, "Real content") +class FlattenEntriesTests(unittest.TestCase): + def _entry(self, log_id, replies=None): + return SimpleNamespace( + log_id=log_id, entered_on_date_time=None, entered_by_username=None, + log_entry=f"entry {log_id}", log_replies=replies, + ) + + def test_flat_list_when_no_replies(self): + entries = [self._entry(1), self._entry(2)] + nodes = fmt.flatten_entries(entries) + self.assertEqual([n.entry.log_id for n in nodes], [1, 2]) + self.assertTrue(all(n.depth == 0 for n in nodes)) + self.assertTrue(all(n.parent is None for n in nodes)) + self.assertTrue(all(n.reply_count == 0 for n in nodes)) + + def test_none_and_empty_log_replies_are_no_replies(self): + entries = [self._entry(1, replies=None), self._entry(2, replies=[])] + nodes = fmt.flatten_entries(entries) + self.assertEqual(len(nodes), 2) + self.assertTrue(all(n.reply_count == 0 for n in nodes)) + + def test_replies_are_depth_first_after_parent(self): + r1, r2 = self._entry(11), self._entry(12) + parent = self._entry(1, replies=[r1, r2]) + nodes = fmt.flatten_entries([parent, self._entry(2)]) + self.assertEqual([n.entry.log_id for n in nodes], [1, 11, 12, 2]) + self.assertEqual([n.depth for n in nodes], [0, 1, 1, 0]) + self.assertIs(nodes[1].parent, parent) + self.assertIs(nodes[2].parent, parent) + + def test_collapsed_id_skips_its_replies(self): + r1 = self._entry(11) + parent = self._entry(1, replies=[r1]) + nodes = fmt.flatten_entries([parent], collapsed={1}) + self.assertEqual([n.entry.log_id for n in nodes], [1]) + self.assertFalse(nodes[0].expanded) + + def test_uncollapsed_parent_is_expanded(self): + parent = self._entry(1, replies=[self._entry(11)]) + nodes = fmt.flatten_entries([parent], collapsed=set()) + self.assertTrue(nodes[0].expanded) + + def test_nested_replies_use_connectors_and_continuation_bars(self): + grandchild_a, grandchild_b = self._entry(111), self._entry(112) + child = self._entry(11, replies=[grandchild_a, grandchild_b]) + other_child = self._entry(12) + parent = self._entry(1, replies=[child, other_child]) + nodes = fmt.flatten_entries([parent]) + by_id = {n.entry.log_id: n for n in nodes} + self.assertEqual(by_id[11].branch, " ├─ ") + self.assertEqual(by_id[12].branch, " └─ ") + self.assertEqual(by_id[111].branch, " │ ├─ ") + self.assertEqual(by_id[112].branch, " │ └─ ") + + +class EntryNodeRowTests(unittest.TestCase): + def _node(self, log_id, depth=0, branch="", reply_count=0, expanded=True, parent=None): + entry = SimpleNamespace( + log_id=log_id, entered_on_date_time=None, entered_by_username="alice", + log_entry="Reactor status nominal", + ) + return fmt.EntryNode( + entry=entry, depth=depth, parent=parent, branch=branch, + reply_count=reply_count, expanded=expanded, + ) + + def test_top_level_no_replies_pads_to_align(self): + _, _, cell = fmt.entry_node_row(self._node(1)) + self.assertEqual(cell, " Reactor status nominal") + + def test_top_level_expanded_with_replies_shows_open_glyph(self): + node = self._node(1, reply_count=2, expanded=True) + _, _, cell = fmt.entry_node_row(node) + self.assertEqual(cell, "▾ Reactor status nominal") + + def test_top_level_collapsed_shows_closed_glyph_and_count(self): + node = self._node(1, reply_count=2, expanded=False) + _, _, cell = fmt.entry_node_row(node) + self.assertEqual(cell, "▸ Reactor status nominal (2 replies)") + + def test_collapsed_singular_reply_count(self): + node = self._node(1, reply_count=1, expanded=False) + _, _, cell = fmt.entry_node_row(node) + self.assertEqual(cell, "▸ Reactor status nominal (1 reply)") + + def test_reply_row_uses_its_branch_prefix(self): + node = self._node(11, depth=1, branch=" └─ ") + _, _, cell = fmt.entry_node_row(node) + self.assertEqual(cell, " └─ Reactor status nominal") + + def test_row_arity_matches_entry_columns(self): + self.assertEqual(len(fmt.entry_node_row(self._node(1))), len(fmt.ENTRY_COLUMNS)) + + class EntryReferenceTests(unittest.TestCase): def test_reference_fields(self): doc = SimpleNamespace(id=42, name="My Doc") @@ -179,6 +273,20 @@ def test_replies_and_reactions_shown_when_present(self): self.assertEqual(rows["replies"], "2") self.assertEqual(rows["reactions"], "👍 1") + def test_parent_adds_reply_to_row_right_after_log_id(self): + doc = SimpleNamespace(id=1, name="Ops") + parent = SimpleNamespace(log_id=100) + rows = fmt.entry_metadata_rows(self._entry(), doc, parent=parent) + labels = [label for label, _ in rows] + self.assertEqual(labels[0], "log_id") + self.assertEqual(labels[1], "reply to") + self.assertEqual(dict(rows)["reply to"], "100") + + def test_no_parent_omits_reply_to_row(self): + doc = SimpleNamespace(id=1, name="Ops") + rows = fmt.entry_metadata_rows(self._entry(), doc) + self.assertNotIn("reply to", dict(rows)) + class DocMetadataRowsTests(unittest.TestCase): def test_more_info_none_does_not_raise(self): From b1a4fba22458b43e45f32db54a6879b7118f90ec Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 20 Aug 2026 15:37:30 -0500 Subject: [PATCH 54/62] Integrate replies into the browse screen. --- .../src/bely_cli/tui/screens/browse.py | 47 +++++++---- .../bely-cli/test/test_tui_app.py | 77 ++++++++++++++++++- 2 files changed, 107 insertions(+), 17 deletions(-) diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py index 2bd6ca5b3..ad58455a2 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py @@ -39,8 +39,10 @@ doc_metadata_rows, doc_row, entry_metadata_rows, + entry_node_row, entry_row, filter_items, + flatten_entries, format_attachment, format_doc, format_type, @@ -70,7 +72,9 @@ class BrowseScreen(Screen): LEVEL_TYPES, LEVEL_DOCS, LEVEL_ENTRIES = range(3) LEVEL_COLUMNS = {LEVEL_TYPES: TYPE_COLUMNS, LEVEL_DOCS: DOC_COLUMNS, LEVEL_ENTRIES: ENTRY_COLUMNS} - LEVEL_ROW_FN = {LEVEL_TYPES: type_row, LEVEL_DOCS: doc_row, LEVEL_ENTRIES: entry_row} + LEVEL_ROW_FN = {LEVEL_TYPES: type_row, LEVEL_DOCS: doc_row, LEVEL_ENTRIES: entry_node_row} + # Entries render via entry_node_row (tree glyphs) but filter on the plain entry cells. + LEVEL_SEARCH_FN = {LEVEL_ENTRIES: lambda node: entry_row(node.entry)} # Per-level nav pane width (%), used whenever a preview/info panel is visible. LEVEL_WIDTH = {LEVEL_TYPES: 42, LEVEL_DOCS: 60, LEVEL_ENTRIES: 42} @@ -118,6 +122,8 @@ def __init__(self, session, limit, *, select_mode=True, source="types", root=Fal self.sel_doc = None self.all_items = [] self.shown_items = [] + self.entry_tree = [] + self._collapsed = set() self._entry_key = None self._render_token = 0 self._nav_hidden = False @@ -182,9 +188,11 @@ def _ensure_columns(self): def show_level(self, level, *, preserve_filter=False): self.level = level - # "f" full-screen only applies at the entries level; reset it when leaving. + # "f" full-screen and the reply tree only apply at the entries level; reset when leaving. if level != self.LEVEL_ENTRIES: self._nav_hidden = False + self.entry_tree = [] + self._collapsed = set() # cancel any in-flight preview/image workers so a stale, now-mistyped item can't reach _show_preview self.app.workers.cancel_group(self, "preview") self.app.workers.cancel_group(self, "images") @@ -265,6 +273,9 @@ def _fetch_failed(self, message): def _populate(self, items): nav = self._nav() nav.set_loading(False) + if self.level == self.LEVEL_ENTRIES: + self.entry_tree = items + items = flatten_entries(items, self._collapsed) self.all_items = items self._apply_filter("") self._update_header() @@ -272,9 +283,10 @@ def _populate(self, items): def _apply_filter(self, query): row_fn = self.LEVEL_ROW_FN[self.level] + search_fn = self.LEVEL_SEARCH_FN.get(self.level, row_fn) self.shown_items = filter_items( self.all_items, query, - lambda it: " ".join(str(c) for c in row_fn(it)), + lambda it: " ".join(str(c) for c in search_fn(it)), ) self._ensure_columns() table = self._nav() @@ -335,7 +347,7 @@ def _render_meta(self, item): elif self.level == self.LEVEL_DOCS: meta.update(rows_table(doc_metadata_rows(item))) else: - meta.update(rows_table(entry_metadata_rows(item, self.sel_doc))) + meta.update(rows_table(entry_metadata_rows(item.entry, self.sel_doc, parent=item.parent))) async def _show_preview(self, item): self._render_meta(item) @@ -346,11 +358,12 @@ async def _show_preview(self, item): body_blocks.display = False return - key = (self.sel_doc.id, item.log_id) + entry = item.entry + key = (self.sel_doc.id, entry.log_id) self._entry_key = key self._load_attachments(item) - segments = split_entry_markdown(item.log_entry or "") + segments = split_entry_markdown(entry.log_entry or "") widget_cls = getattr(self.app, "image_widget", None) image_segments_present = any(seg[0] == "image" for seg in segments) @@ -359,7 +372,7 @@ async def _show_preview(self, item): self._maybe_hint_images_unavailable() body_blocks.display = False body_md.display = True - await body_md.update(item.log_entry or "") + await body_md.update(entry.log_entry or "") return body_md.display = False @@ -436,22 +449,22 @@ async def _image_failed(self, key, token, placeholder, stored_name, message): placeholder.remove_class("img-loading") placeholder.add_class("img-error") - def _load_attachments(self, entry): - self._fetch_attachments(self.sel_doc.id, entry.log_id, entry, self._entry_key) + def _load_attachments(self, node): + self._fetch_attachments(self.sel_doc.id, node.entry.log_id, node, self._entry_key) @work(thread=True, exclusive=True, group="attachments") - def _fetch_attachments(self, doc_id, log_id, entry, key): + def _fetch_attachments(self, doc_id, log_id, node, key): try: attachments = self.data.attachments(doc_id, log_id) except Exception: attachments = [] - self.app.call_from_thread(self._apply_attachments, key, entry, attachments) + self.app.call_from_thread(self._apply_attachments, key, node, attachments) - def _apply_attachments(self, key, entry, attachments): + def _apply_attachments(self, key, node, attachments): if key != self._entry_key or not attachments: return meta = self.query_one("#meta", Static) - rows = entry_metadata_rows(entry, self.sel_doc) + rows = entry_metadata_rows(node.entry, self.sel_doc, parent=node.parent) rows.append(("attachments", "; ".join(format_attachment(a) for a in attachments))) meta.update(rows_table(rows)) @@ -481,7 +494,7 @@ def on_data_table_row_selected(self, event): self.sel_doc = item self.show_level(self.LEVEL_ENTRIES) elif self.select_mode: - self.app.exit((self.sel_doc, item)) + self.app.exit((self.sel_doc, item.entry)) # else: entry already selected is just the live preview; Enter is a no-op. def action_back(self): @@ -540,7 +553,7 @@ def action_refresh_level(self): # -- current-selection helpers -- - def _current_entry(self): + def _current_node(self): if self.level != self.LEVEL_ENTRIES: return None table = self._nav() @@ -548,6 +561,10 @@ def _current_entry(self): return None return self.shown_items[table.cursor_row] + def _current_entry(self): + node = self._current_node() + return node.entry if node else None + def _current_doc(self): """The document the 'n'/'u' actions apply to: the drilled-into doc at the entry level, or the highlighted row at the doc level.""" diff --git a/tools/developer_tools/bely-cli/test/test_tui_app.py b/tools/developer_tools/bely-cli/test/test_tui_app.py index e849480a5..1ae42fb2f 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_app.py +++ b/tools/developer_tools/bely-cli/test/test_tui_app.py @@ -64,6 +64,29 @@ def add_update_log_entry(self, log_entry): return log_entry +class FakeLogbookApiWithReplies(FakeLogbookApi): + """One entry with two direct replies.""" + + def get_log_entries(self, log_document_id, load_replies, load_reactions): + replies = [ + SimpleNamespace( + log_id=101, entered_by_username="bob", entered_on_date_time=None, + last_modified_by_username=None, last_modified_on_date_time=None, + log_replies=None, log_reactions=None, log_entry="First reply.", + ), + SimpleNamespace( + log_id=102, entered_by_username="alice", entered_on_date_time=None, + last_modified_by_username=None, last_modified_on_date_time=None, + log_replies=None, log_reactions=None, log_entry="Second reply.", + ), + ] + return [SimpleNamespace( + log_id=100, entered_by_username="alice", entered_on_date_time=None, + last_modified_by_username=None, last_modified_on_date_time=None, + log_replies=replies, log_reactions=None, log_entry="Parent entry.", + )] + + class FakeLogbookApiWithImage(FakeLogbookApi): """Entry body is a single image-only paragraph (as the server appends after upload).""" @@ -163,7 +186,7 @@ async def test_browse_populates_list_and_drives_preview(self): await pilot.pause() self.assertEqual(screen.level, screen.LEVEL_ENTRIES) self.assertEqual(table.row_count, 1) - self.assertEqual(screen.shown_items[0].log_id, 100) + self.assertEqual(screen.shown_items[0].entry.log_id, 100) self.assertTrue(screen.query_one("#body-md", Markdown).display) # Entries always show the preview, even though 'i' was never pressed. self.assertTrue(screen.query_one("#preview").display) @@ -276,7 +299,7 @@ async def test_show_level_clears_stale_items_so_a_filter_race_cant_preview_them( await pilot.pause() # let the real entries fetch complete and repopulate self.assertEqual(screen.level, screen.LEVEL_ENTRIES) - self.assertEqual(screen.shown_items[0].log_id, 100) + self.assertEqual(screen.shown_items[0].entry.log_id, 100) async def test_filter_narrows_table_row_count(self): data = LogbookData(FakeLogbookApi()) @@ -725,5 +748,55 @@ def slow_decode(data): self.assertEqual(len(list(screen.query(FakeImageWidget))), 0) +class ReplyTreeTests(unittest.IsolatedAsyncioTestCase): + """BrowseScreen renders an entry's replies as indented tree rows.""" + + async def _open_entries(self, pilot): + await pilot.pause() + await pilot.press("enter") # type -> docs + await pilot.pause() + await pilot.press("enter") # docs -> entries + await pilot.pause() + await pilot.pause() + + async def test_replies_render_as_indented_rows_under_the_parent(self): + data = LogbookData(FakeLogbookApiWithReplies()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + async with app.run_test() as pilot: + await self._open_entries(pilot) + screen = app.screen + table = screen.query_one("#nav-table", DataTable) + + self.assertEqual(table.row_count, 3) + self.assertEqual( + [n.entry.log_id for n in screen.shown_items], [100, 101, 102]) + self.assertIn("▾", table.get_row_at(0)[2]) + self.assertTrue(table.get_row_at(1)[2].startswith(" ├─ ")) + self.assertTrue(table.get_row_at(2)[2].startswith(" └─ ")) + + async def test_current_entry_on_a_reply_row_returns_the_reply(self): + data = LogbookData(FakeLogbookApiWithReplies()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + async with app.run_test() as pilot: + await self._open_entries(pilot) + screen = app.screen + + await pilot.press("down") + await pilot.pause() + self.assertEqual(screen._current_entry().log_id, 101) + self.assertEqual(screen._current_node().parent.log_id, 100) + + async def test_filtering_matches_entry_text_not_glyphs(self): + data = LogbookData(FakeLogbookApiWithReplies()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + async with app.run_test() as pilot: + await self._open_entries(pilot) + screen = app.screen + + screen._apply_filter("second") + self.assertEqual(len(screen.shown_items), 1) + self.assertEqual(screen.shown_items[0].entry.log_id, 102) + + if __name__ == "__main__": unittest.main() From 3c08899a44c19b57b0f54785de15f9055c20c0ae Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Thu, 20 Aug 2026 15:48:53 -0500 Subject: [PATCH 55/62] Add ability to toggle replies --- tools/developer_tools/bely-cli/README.md | 17 +++-- .../src/bely_cli/tui/screens/browse.py | 28 ++++++++ .../bely-cli/test/test_tui_app.py | 64 +++++++++++++++++++ 3 files changed, 103 insertions(+), 6 deletions(-) diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index 29233b015..eb46ff8ff 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -252,7 +252,7 @@ All three levels render as full-width, aligned tables (rows stay in API order, n |-------|---------| | Logbook | Name, Display, Description | | Document | Name, Description, Systems, Owner, Modified | -| Entry | Date, Author, Entry (a snippet of the first line) | +| Entry | Date, Author, Entry (a snippet of the first line) — replies render as indented rows beneath their parent, expanded by default | Press `i` at the logbook/document levels to open a side info panel with a few extra fields for the highlighted row (it splits the table's width; `i` again closes it). Entries always @@ -272,29 +272,34 @@ bely-cli tui lookup --limit 50 The info panel (`i`, logbook/document levels only) shows, depending on the level: for a logbook, its name, display name(s), and description; for a document, its description, logbook types, systems, owner, and creation/modification info. The entry preview (always shown at that level) -has author and modification info, reply/reaction counts, and attachments (fetched lazily as you -highlight each entry). +has author and modification info, reply/reaction counts, and attachments (fetched lazily as +you highlight each entry) — and, for a reply, which entry it's a reply to. Keys: The footer at the bottom of the screen only ever shows the keys that apply to the level you're -on — `i` disappears once you drill into entries, and `s` / `y` / `e` / `f` only appear there. +on — `i` disappears once you drill into entries, and `s` / `y` / `e` / `f` / `t` only appear there. | Key | Action | |-----|--------| | `Up` / `Down`, `PgUp` / `PgDn` | Move the highlight; the preview/info panel follows. | -| `/` | Reveal and focus the filter box; incrementally filters the current table (case-insensitive substring). It hides itself again once it loses focus with nothing typed. | +| `/` | Reveal and focus the filter box; incrementally filters the current table (case-insensitive substring, matched against the entry text — not the reply tree's glyphs or reply count). | | `Enter` | In the filter box, return focus to the table. Elsewhere, drill into the highlighted row, or select the entry at the entries level. | | `Esc` / `Backspace` | Go back one level (from the table); does nothing at the logbook list — press `q` to quit. In the filter box, `Esc` returns focus to the table. | | `d` | Logbook/document levels only: create a new document (see `bely-cli tui`'s "New document" above) — a mutation, so this is where the app authenticates if it hasn't already. | | `s` | Entries level only: save the highlighted entry's markdown to a file in the current directory. | | `y` | Entries level only: copy a `bely-cli entry get` reference for the highlighted entry to the clipboard. | | `e` | Entries level only: open the highlighted entry in `$EDITOR`; if you change it, offers to save the result back to the server (a mutation, so this is where the app authenticates if it hasn't already). | +| `t` | Entries level only: collapse/expand the reply thread under the highlighted entry (or its parent, if the highlight is on a reply). Replies start expanded. | | `i` | Logbook/document levels only: toggle the side info panel. | | `f` | Entries level only: toggle the table to widen the preview pane. | -| `r` | Refresh the current level, bypassing the in-session cache. | +| `r` | Refresh the current level, bypassing the in-session cache. Collapsed threads stay collapsed. | | `q` | Quit without selecting. | +Replies only ever nest one level deep — the server doesn't return replies-to-replies — and +`n` on a highlighted reply adds a new top-level entry, not a reply to that reply (there's no +API for that yet). + On selecting an entry the TUI exits and prints its `doc-id` / `log-id`, plus a ready-to-run `bely-cli entry get` command so you can fetch it: diff --git a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py index ad58455a2..559ceeade 100644 --- a/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py +++ b/tools/developer_tools/bely-cli/src/bely_cli/tui/screens/browse.py @@ -84,6 +84,7 @@ class BrowseScreen(Screen): # (rather than showing it disabled) so only relevant keys ever appear. ACTION_LEVELS = { "toggle_full": (LEVEL_ENTRIES,), + "toggle_replies": (LEVEL_ENTRIES,), "save_entry": (LEVEL_ENTRIES,), "copy_reference": (LEVEL_ENTRIES,), "open_editor": (LEVEL_ENTRIES,), @@ -99,6 +100,7 @@ class BrowseScreen(Screen): Binding("q", "quit_app", "Quit"), Binding("slash", "focus_filter", "Filter"), Binding("f", "toggle_full", "Full"), + Binding("t", "toggle_replies", "Replies"), Binding("s", "save_entry", "Save"), Binding("y", "copy_reference", "Copy ref"), Binding("e", "open_editor", "Edit in editor"), @@ -592,6 +594,32 @@ def _current_type(self): # -- entry actions -- + def action_toggle_replies(self): + node = self._current_node() + if node is None: + self.notify("Select an entry first.", severity="warning") + return + target = node.parent if node.depth > 0 else node.entry + if node.depth == 0 and node.reply_count == 0: + return + log_id = target.log_id + if log_id in self._collapsed: + self._collapsed.discard(log_id) + else: + self._collapsed.add(log_id) + self._reflatten(focus_log_id=log_id) + + def _reflatten(self, focus_log_id=None): + """Rebuild shown_items from entry_tree/_collapsed, keeping the filter and cursor.""" + self.all_items = flatten_entries(self.entry_tree, self._collapsed) + self._apply_filter(self.query_one("#filter", Input).value) + if focus_log_id is None: + return + for i, node in enumerate(self.shown_items): + if node.entry.log_id == focus_log_id: + self._nav().move_cursor(row=i) + break + def action_save_entry(self): from ...common import write_entry_to_file diff --git a/tools/developer_tools/bely-cli/test/test_tui_app.py b/tools/developer_tools/bely-cli/test/test_tui_app.py index 1ae42fb2f..f049b4997 100644 --- a/tools/developer_tools/bely-cli/test/test_tui_app.py +++ b/tools/developer_tools/bely-cli/test/test_tui_app.py @@ -786,6 +786,70 @@ async def test_current_entry_on_a_reply_row_returns_the_reply(self): self.assertEqual(screen._current_entry().log_id, 101) self.assertEqual(screen._current_node().parent.log_id, 100) + async def test_toggle_collapses_and_restores_a_thread(self): + data = LogbookData(FakeLogbookApiWithReplies()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + async with app.run_test() as pilot: + await self._open_entries(pilot) + screen = app.screen + table = screen.query_one("#nav-table", DataTable) + + await pilot.press("t") + await pilot.pause() + self.assertEqual(table.row_count, 1) + self.assertIn("(2 replies)", table.get_row_at(0)[2]) + self.assertEqual(table.cursor_row, 0) # cursor stays on the thread root + + await pilot.press("t") + await pilot.pause() + self.assertEqual(table.row_count, 3) + self.assertIn("▾", table.get_row_at(0)[2]) + + async def test_toggle_from_a_reply_row_collapses_its_parent(self): + data = LogbookData(FakeLogbookApiWithReplies()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + async with app.run_test() as pilot: + await self._open_entries(pilot) + screen = app.screen + table = screen.query_one("#nav-table", DataTable) + + await pilot.press("down") # onto the first reply + await pilot.pause() + await pilot.press("t") + await pilot.pause() + + self.assertEqual(table.row_count, 1) + self.assertEqual(screen.shown_items[0].entry.log_id, 100) + + async def test_toggle_on_entry_without_replies_is_a_noop(self): + data = LogbookData(FakeLogbookApi()) # single entry, no replies + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + async with app.run_test() as pilot: + await self._open_entries(pilot) + screen = app.screen + table = screen.query_one("#nav-table", DataTable) + + await pilot.press("t") + await pilot.pause() + self.assertEqual(table.row_count, 1) + + async def test_collapsed_state_survives_refresh(self): + data = LogbookData(FakeLogbookApiWithReplies()) + app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") + async with app.run_test() as pilot: + await self._open_entries(pilot) + screen = app.screen + table = screen.query_one("#nav-table", DataTable) + + await pilot.press("t") + await pilot.pause() + self.assertEqual(table.row_count, 1) + + await pilot.press("r") # refresh_level + await pilot.pause() + await pilot.pause() + self.assertEqual(table.row_count, 1) + async def test_filtering_matches_entry_text_not_glyphs(self): data = LogbookData(FakeLogbookApiWithReplies()) app = BelyTuiApp(FakeSession(data), limit=10, mode="lookup") From 51cb4507b28490a566329b1c0e047affff2c3954 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 21 Aug 2026 14:27:03 -0500 Subject: [PATCH 56/62] Restructure bely-api into uv packaging and clean up the cdb-cli stuff. --- .github/workflows/test-bely-cli.yml | 8 +- .gitignore | 15 +- setup.sh | 2 +- tools/developer_tools/python-client/README.md | 78 ++++ .../python-client/cdbCli/__init__.py | 0 .../python-client/cdbCli/common/__init__.py | 0 .../cdbCli/common/cli/__init__.py | 0 .../cdbCli/common/cli/cliBase.py | 264 ----------- .../cdbCli/common/utility/__init__.py | 0 .../common/utility/configurationManager.py | 134 ------ .../python-client/cdbCli/service/__init__.py | 0 .../cdbCli/service/cli/__init__.py | 0 .../service/cli/cdbCliCmnds/__init__.py | 0 .../cli/cdbCliCmnds/addDocumentFile.py | 113 ----- .../cli/cdbCliCmnds/addDocumentProperty.py | 172 ------- .../service/cli/cdbCliCmnds/addProperty.py | 166 ------- .../cli/cdbCliCmnds/cdb_log_to_mqtt.py | 284 ------------ .../service/cli/cdbCliCmnds/createLocation.py | 107 ----- .../cli/cdbCliCmnds/getCatalogItemsByName.py | 328 ------------- .../cli/cdbCliCmnds/getLocationIdByName.py | 95 ---- .../service/cli/cdbCliCmnds/getProperties.py | 202 -------- .../cdbCli/service/cli/cdbCliCmnds/help.py | 58 --- .../cdbCli/service/cli/cdbCliCmnds/info.py | 436 ------------------ .../cdbCli/service/cli/cdbCliCmnds/search.py | 302 ------------ .../service/cli/cdbCliCmnds/setItemDetails.py | 193 -------- .../cli/cdbCliCmnds/setItemLocation.py | 153 ------ .../service/cli/cdbCliCmnds/setItemLogById.py | 100 ---- .../cli/cdbCliCmnds/setItemStatusById.py | 118 ----- .../cli/cdbCliCmnds/setMachineInstallState.py | 141 ------ .../cli/cdbCliCmnds/setParentLocation.py | 112 ----- .../cdbCliCmnds/setPropertiesAndMetadata.py | 122 ----- .../service/cli/cdbCliCmnds/setQrIdById.py | 112 ----- .../cli/cdbCliCmnds/updateHierarchy.py | 142 ------ .../python-client/cdbCli/service/cli/cli.py | 73 --- .../{conda-recipe/API => }/conda-build.sh | 39 +- .../python-client/conda-recipe/API/build.sh | 3 - .../python-client/conda-recipe/API/meta.yaml | 20 +- .../python-client/conda-recipe/CLI/meta.yaml | 40 -- .../conda-recipe/InquirerPy-dep/meta.yaml | 27 -- .../conda-recipe/pfzy-dep/meta.yaml | 24 - .../python-client/generatePyClient.sh | 4 +- .../{ => packages/api}/BelyApiFactory.py | 0 .../python-client/packages/api/pyproject.toml | 36 ++ .../python-client/pyproject.toml | 15 + .../python-client/setup-api.py | 29 -- .../python-client/setup-cli.py | 48 -- tools/developer_tools/python-client/uv.lock | 359 ++++++++++++++ 47 files changed, 534 insertions(+), 4140 deletions(-) create mode 100644 tools/developer_tools/python-client/README.md delete mode 100644 tools/developer_tools/python-client/cdbCli/__init__.py delete mode 100644 tools/developer_tools/python-client/cdbCli/common/__init__.py delete mode 100644 tools/developer_tools/python-client/cdbCli/common/cli/__init__.py delete mode 100755 tools/developer_tools/python-client/cdbCli/common/cli/cliBase.py delete mode 100644 tools/developer_tools/python-client/cdbCli/common/utility/__init__.py delete mode 100755 tools/developer_tools/python-client/cdbCli/common/utility/configurationManager.py delete mode 100644 tools/developer_tools/python-client/cdbCli/service/__init__.py delete mode 100644 tools/developer_tools/python-client/cdbCli/service/cli/__init__.py delete mode 100644 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/__init__.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/addDocumentFile.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/addDocumentProperty.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/addProperty.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/cdb_log_to_mqtt.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/createLocation.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/getCatalogItemsByName.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/getLocationIdByName.py delete mode 100644 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/getProperties.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/help.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/info.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/search.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemDetails.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemLocation.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemLogById.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemStatusById.py delete mode 100644 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setMachineInstallState.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setParentLocation.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setPropertiesAndMetadata.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setQrIdById.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/updateHierarchy.py delete mode 100755 tools/developer_tools/python-client/cdbCli/service/cli/cli.py rename tools/developer_tools/python-client/{conda-recipe/API => }/conda-build.sh (50%) delete mode 100644 tools/developer_tools/python-client/conda-recipe/API/build.sh delete mode 100644 tools/developer_tools/python-client/conda-recipe/CLI/meta.yaml delete mode 100644 tools/developer_tools/python-client/conda-recipe/InquirerPy-dep/meta.yaml delete mode 100644 tools/developer_tools/python-client/conda-recipe/pfzy-dep/meta.yaml rename tools/developer_tools/python-client/{ => packages/api}/BelyApiFactory.py (100%) create mode 100644 tools/developer_tools/python-client/packages/api/pyproject.toml create mode 100644 tools/developer_tools/python-client/pyproject.toml delete mode 100644 tools/developer_tools/python-client/setup-api.py delete mode 100644 tools/developer_tools/python-client/setup-cli.py create mode 100644 tools/developer_tools/python-client/uv.lock diff --git a/.github/workflows/test-bely-cli.yml b/.github/workflows/test-bely-cli.yml index b2e20177b..37bd514ac 100644 --- a/.github/workflows/test-bely-cli.yml +++ b/.github/workflows/test-bely-cli.yml @@ -23,10 +23,10 @@ jobs: env: UV_PYTHON: ${{ matrix.python-version }} - # pyproject.toml pins bely-api to a local sdist under ../python-client/dist, - # which is gitignored and so absent on a fresh checkout. The identical - # 2026.3.0 sdist is on PyPI (same sha256), so resolve just that one package - # from the registry. Job-level so `uv run` inside run_test.sh honours it too. + # pyproject.toml pins bely-api to the ../python-client/packages/api directory, + # whose generated belyApi/ subpackage is gitignored and so absent on a fresh + # checkout. Resolve just that one package from PyPI instead. Job-level so + # `uv run` inside run_test.sh honours it too. UV_NO_SOURCES_PACKAGE: bely-api steps: diff --git a/.gitignore b/.gitignore index 897eb3eee..44b097f98 100644 --- a/.gitignore +++ b/.gitignore @@ -17,17 +17,19 @@ tools/developer_tools/portal_testing/CdbFunctionalTester/nbproject/private/ /tools/developer_tools/python-client/* /tools/developer_tools/python-client/**/.DS_Store !/tools/developer_tools/python-client/generatePyClient.sh -!/tools/developer_tools/python-client/BelyApiFactory.py !/tools/developer_tools/python-client/ClientApiConfig.yml -!/tools/developer_tools/python-client/cdbCli -!/tools/developer_tools/python-client/setup-cli.py -!/tools/developer_tools/python-client/setup-api.py !/tools/developer_tools/python-client/test !/tools/developer_tools/python-client/conda-recipe +!/tools/developer_tools/python-client/conda-build.sh !/tools/developer_tools/python-client/data -## Conda API build +!/tools/developer_tools/python-client/pyproject.toml +!/tools/developer_tools/python-client/uv.lock +!/tools/developer_tools/python-client/README.md +!/tools/developer_tools/python-client/packages +/tools/developer_tools/python-client/packages/api/belyApi/ +/tools/developer_tools/python-client/packages/**/__pycache__/ +/tools/developer_tools/python-client/packages/**/dist/ /tools/developer_tools/python-client/conda-recipe/API/build -/tools/developer_tools/python-client/conda-recipe/API/src /tools/developer_tools/python-client/conda-recipe/API/bely-api-env.txt # Generated deployment specific config files @@ -55,7 +57,6 @@ docs/python/_build/ /src/python/dist # Temporary testing csv files -tools/developer_tools/python-client/cdbCli/service/cli/Spreadsheets/ .env tools/developer_tools/bely-mqtt-message-broker/conda-bld tools/developer_tools/bely-mqtt-message-broker/dev-config diff --git a/setup.sh b/setup.sh index 113a9680e..48ab538bf 100755 --- a/setup.sh +++ b/setup.sh @@ -117,7 +117,7 @@ if [ -z $PYTHONPATH ]; then else PYTHONPATH=$LOGR_ROOT_DIR/src/python:$PYTHONPATH fi -PYTHONPATH=$LOGR_ROOT_DIR/tools/developer_tools/python-client:$PYTHONPATH +PYTHONPATH=$LOGR_ROOT_DIR/tools/developer_tools/python-client/packages/api:$PYTHONPATH export PYTHONPATH # Done diff --git a/tools/developer_tools/python-client/README.md b/tools/developer_tools/python-client/README.md new file mode 100644 index 000000000..672bd5a75 --- /dev/null +++ b/tools/developer_tools/python-client/README.md @@ -0,0 +1,78 @@ +# BELY python client + +A [uv workspace](https://docs.astral.sh/uv/concepts/projects/workspaces/) with one +publishable package: + +- `packages/api/` — **bely-api**: the generated `belyApi` REST client plus the + hand-written `BelyApiFactory` convenience wrapper. + +`belyApi/` is **generated** by `generatePyClient.sh` from the portal's OpenAPI spec and is +gitignored — regenerate it before building, testing, or running anything that imports it. + +## Dev setup + +```sh +source setup.sh # from the repo root; puts packages/api on PYTHONPATH +cd tools/developer_tools/python-client +./generatePyClient.sh http://localhost:8080/bely # requires the portal running locally +``` + +At this point `import belyApi` and `import BelyApiFactory` resolve directly against this +checkout via `PYTHONPATH` — no install required. + +If you'd rather use uv directly (editable install into a real virtualenv): + +```sh +uv sync # installs bely-api editable + dev deps (pytest) +uv run pytest test/ +``` + +## Regenerating the client + +```sh +./generatePyClient.sh # e.g. http://localhost:8080/bely +``` + +Downloads `openapi-generator-cli` (once — cached for subsequent runs), generates a client +from `/api/openapi.yaml`, and overwrites `packages/api/belyApi/`. Run this +any time REST routes or `openapi.yaml` change. + +## Building & publishing to PyPI + +```sh +uv build --package bely-api --out-dir dist # sdist + wheel +uv publish dist/* # needs UV_PUBLISH_TOKEN or ~/.pypirc +``` + +Or use the wrapper script, which also regenerates `belyApi` first and prompts before uploading: + +```sh +./sbin/bely_release_pip.py api # from repo root +./sbin/bely_release_pip.py api --dry-run # build only +./sbin/bely_release_pip.py api --publish-url https://test.pypi.org/legacy/ # TestPyPI +``` + +or `make release-python-client` from the repo root (publishes both `bely-api` and +`bely-cli`). + +`make prepare-release` (`sbin/bely_prepare_release.py`) bumps the version in +`packages/api/pyproject.toml` (and everywhere else version strings live) and refreshes +`uv.lock`. + +## Building conda packages + +```sh +./conda-build.sh http://localhost:8080/bely +``` + +Regenerates the client, builds `conda-recipe/API`, smoke-tests it in a throwaway env, and +prints a reminder to upload the resulting `bely-api-env.txt` with the c2 tool. + +## Tests + +```sh +uv run pytest test/ # requires the portal running on localhost:8080 +``` + +`sbin/cdb_test.sh` (invoked by `make test`) regenerates the client and runs this as part of +the full suite. diff --git a/tools/developer_tools/python-client/cdbCli/__init__.py b/tools/developer_tools/python-client/cdbCli/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tools/developer_tools/python-client/cdbCli/common/__init__.py b/tools/developer_tools/python-client/cdbCli/common/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tools/developer_tools/python-client/cdbCli/common/cli/__init__.py b/tools/developer_tools/python-client/cdbCli/common/cli/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tools/developer_tools/python-client/cdbCli/common/cli/cliBase.py b/tools/developer_tools/python-client/cdbCli/common/cli/cliBase.py deleted file mode 100755 index e282b9576..000000000 --- a/tools/developer_tools/python-client/cdbCli/common/cli/cliBase.py +++ /dev/null @@ -1,264 +0,0 @@ -#!/usr/bin/env python -import csv -from email.policy import default -import os -import sys - -from click import prompt,echo - -from getpass import getpass -from urllib3.exceptions import MaxRetryError - -import click - -from CdbApiFactory import CdbApiFactory -from cdbApi import ApiException -from cdbCli.common.utility.configurationManager import ConfigurationManager - -from rich import print -from rich.panel import Panel -from rich.table import Table -from rich.json import JSON - -FORMAT_RICH_OPT = 'Rich' -FORMAT_JSON_OPT = "JSON" -PYTHON_OBJ_FORMAT = "Dict" -FORMAT_OPTS = [FORMAT_RICH_OPT, FORMAT_JSON_OPT, PYTHON_OBJ_FORMAT] - -class CliBase: - - api_factory = None - - PANEL_LINE_FORMAT = "[blue]%-25s[/blue] %s\n" - - def __init__(self,portal_key=None): - self.config = ConfigurationManager.get_instance() - if portal_key: - self._get_api_factory(portal_key) - - def _get_api_factory(self,portal_key=None): - if CliBase.api_factory is None: - if portal_key: - portal_URL = self.config.get_portal_address(portal_key) - else: - portal_URL = self.config.get_portal_address() - CliBase.api_factory = CdbApiFactory(portal_URL) - return CliBase.api_factory - - def get_session_token(self): - session_file_path = self.config.get_session_file_path() - if os.path.exists(session_file_path): - session_file = open(session_file_path, 'r') - return session_file.read() - return None - - def set_session_token(self, session_token): - session_file_path = self.config.get_session_file_path() - directory = os.path.dirname(session_file_path) - if not os.path.exists(directory): - os.makedirs(directory) - - session_file = open(session_file_path, 'w') - session_file.write(session_token) - - def require_api(self,portal_key=None): - apiFactory = self._get_api_factory(portal_key) - return apiFactory - - def require_authenticated_api(self, - prompt_string="The command requires authentication.", - portal_key=None): - factory = self.require_api(portal_key) - factory.getItemApi() - - try: - token = self.get_session_token() - if token is not None: - factory.setAuthenticateToken(token) - factory.testAuthenticated() - return(factory) - except ApiException as ex: - pass - # The session key doesn't work. We now - # See if the username and password are in the Configuration data - username,password = self.config.get_session_credentials() - if (username != None) and (password != None): - try: - factory.authenticateUser(username, password) - token = factory.getAuthenticateToken() - self.set_session_token(token) - return(factory) - except ApiException as ex: - exObj = factory.parseApiException(ex) - echo("Local configured password failed: %s" % exObj.simple_name) - - # Need to prompt for credentials - echo(prompt_string) - username = prompt("Username") - password = getpass("Password: ") - try: - factory.authenticateUser(username, password) - token = factory.getAuthenticateToken() - self.set_session_token(token) - return factory - except ApiException as ex: - exObj = factory.parseApiException(ex) - raise Exception("%s - %s" % (exObj.simple_name, exObj.message)) - - @staticmethod - def print_cdb_obj(cdb_object): - cdb_object = str(cdb_object) + '\n' - echo(cdb_object) - - # TODO Add a print list cdb of cdb object - - def prepare_cli_input_csv_reader(self, input_file, stdin_prompt): - reader = csv.reader(input_file) - stdin_tty_mode = (input_file == sys.stdin) and sys.stdin.isatty() - - if stdin_tty_mode: - print(stdin_prompt) - else: - # Removes header located in first row - next(reader) - - return reader, stdin_tty_mode - -def simple_obj_list_to_str(list): - result = "" - for obj in list: - result += obj.name + ", " - - return result[:-2] - -def print_results(console, result_obj, format=FORMAT_RICH_OPT, pager=False, table_style = [], header_style={}, **kwargs): - if format == FORMAT_RICH_OPT: - printables = create_rich_result_obj(result_obj, table_style, header_style) - else: - if format == FORMAT_JSON_OPT: - printables = [JSON.from_data(result_obj)] - elif format == PYTHON_OBJ_FORMAT: - printables = [result_obj] - - if pager: - with console.pager(): - print_printables(console, printables) - else: - print_printables(console, printables) - -def create_rich_result_obj(result_obj, table_style=[], header_style={}): - printables = [] - - for section in result_obj.keys(): - if section in header_style.keys(): - title = "[%s]%s" % (header_style[section], section) - else: - title = section - - section_contents = result_obj[section] - - if section_contents.__len__() > 0: - key_length = section_contents[0].keys().__len__() - if key_length == 1: - value = "" - for content in section_contents: - data_keys = content.keys() - - for data_key in data_keys: - data_val = content[data_key] - if not data_val and data_val != 0: - data_val = '' - value += CliBase.PANEL_LINE_FORMAT % (data_key, data_val) - - panel = Panel(value[:-1], title=title) - - printables.append(panel) - elif key_length > 1: - # Table - headers = section_contents[0].keys() - table = Table(title=title, expand=True, show_lines=True) - - for i, header in enumerate(headers): - if i < len(table_style): - style = table_style[i] - table.add_column(header, style=style) - else: - table.add_column(header) - - for content in section_contents: - row_list = [] - for value in content.values(): - row_list.append(str(value)) - row_contents = tuple(row_list) - - table.add_row(*row_contents) - - printables.append(table) - - return printables - -def print_printables(console, printables): - for printable in printables: - console.print(printable) - -def wrap_print_format_cli_click_options(function): - function = click.option( - "--format", - - default=FORMAT_RICH_OPT, - type=click.Choice(FORMAT_OPTS, case_sensitive=False), - help="How the results will be displayed." - )(function) - return function - -def wrap_common_cli_click_options(function): - function = click.option( - "--add-log-to-item", - is_flag=True, - help="Add a log entry to the machine item after the change is made." - )(function) - return function - -def cli_command_api_exception_handler(func): - ''' - Exception decorator prints the exception in the expected format for the user. - - Decorator to be applied on the the cli_helper function and not on the 'click' function. - ''' - def Inner_Function(*args, **kwargs): - if 'cli' not in kwargs and 'factory' not in kwargs: - raise Exception("'cli' or 'factory' must be a kwargs parameter to use decorator. Other recommended params are 'console' and 'format'") - try: - return func(*args, **kwargs) - except ApiException as ex: - if 'cli' in kwargs.keys(): - cli : CliBase = kwargs['cli'] - factory = cli.api_factory - else: - factory = kwargs['factory'] - - exObj = factory.parseApiException(ex) - if 'console' in kwargs: - exceptionDictList = [] - printDict = {'Exception': exceptionDictList} - header_style = {'Exception': 'red'} - - exceptionDictList.append({"HTTP Status": exObj.status}) - exceptionDictList.append({"Name": exObj.simple_name}) - exceptionDictList.append({"Message": exObj.message}) - - print_results(result_obj=printDict, header_style=header_style, **kwargs) - else: - msg = "%s(%s) - %s" % (exObj.simple_name, exObj.status, exObj.message) - print(msg) - return ex - except MaxRetryError as ex: - # Connection issue - print(ex) - return ex - - return Inner_Function - -if __name__ == "__main__": - cli = CliBase() - factory = cli.require_authenticated_api() diff --git a/tools/developer_tools/python-client/cdbCli/common/utility/__init__.py b/tools/developer_tools/python-client/cdbCli/common/utility/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tools/developer_tools/python-client/cdbCli/common/utility/configurationManager.py b/tools/developer_tools/python-client/cdbCli/common/utility/configurationManager.py deleted file mode 100755 index dfca697d0..000000000 --- a/tools/developer_tools/python-client/cdbCli/common/utility/configurationManager.py +++ /dev/null @@ -1,134 +0,0 @@ -#!/usr/bin/env python - -from multiprocessing.spawn import prepare -import os -from configparser import ConfigParser - - -class ConfigurationManager: - CDB_INSTALL_DIR_ENV_NAME = 'CDB_INSTALL_DIR' - CDB_CLI_CONFIG_FILE_ENV_NAME = 'CDB_CLI_CONFIG_FILE' - CDB_PROD_CONFIG_SUBDIR = '%s/etc/cdb.conf' - CONFIG_WEB_PORTAL_SECTION_NAME = 'WebPortal' - CONFIG_PORTAL_VALUE_NAME = 'portalWebAddress' - - SESSION_COOKIE_SECTION_NAME = 'SessionInfo' - SESSION_COOKIE_VALUE_NAME = 'sessionCookieFilePath' - SESSION_USERNAME = 'sessionUsername' - SESSION_PASSWORD = 'sessionPassword' - - # Singleton. - __instance = None - - @classmethod - def get_instance(cls): - """ Get configuration manager singleton instance. """ - if ConfigurationManager.__instance is not None: - return ConfigurationManager.__instance - else: - return ConfigurationManager() - - def __init__(self): - if ConfigurationManager.__instance: - raise ConfigurationManager.__instance - ConfigurationManager.__instance = self - - if self.CDB_CLI_CONFIG_FILE_ENV_NAME in os.environ: - config_path = os.environ[self.CDB_CLI_CONFIG_FILE_ENV_NAME] - if os.path.exists(config_path): - self.configuration = ConfigParser() - self.configuration.read(config_path) - else: - raise Exception("Path (%s) was not found using the enviornment variable '%s'. Please clear (`unset %s`) variable or specify an appropriate path." - % (config_path, self.CDB_CLI_CONFIG_FILE_ENV_NAME, self.CDB_CLI_CONFIG_FILE_ENV_NAME)) - else: - home_dot_dir_config = os.path.expanduser("~/.cdb/cdb.conf") - current_dir_config = os.path.expanduser("./cdb.conf") - - config_paths = [home_dot_dir_config, - current_dir_config] - - try: - install_path = os.environ[self.CDB_INSTALL_DIR_ENV_NAME] - configuration_file_path = self.CDB_PROD_CONFIG_SUBDIR % install_path - config_paths.append(configuration_file_path) - except: - pass - - self.prepare_configuration(config_paths) - - def prepare_configuration(self, config_paths): - ''' - Read or generate configuration - ''' - - config_file_exists = [os.path.exists(config_file) for config_file in config_paths] - - if any(config_file_exists): - self.configuration = ConfigParser() - self.configuration.read(config_paths) - else: - print("Greetings!!!, you need a minimal cdb.conf file to run these scripts.") - print("The file can be in one of the following locations and it helps specify ") - print("CDB Server locations and the location of the session key file that caches") - print("access credentials for CDB") - print("") - - for i, path in enumerate(config_paths): - print("%i: %s" % (i, os.path.dirname(path))) - - config_file_dir_selection = input("Enter the directory to store cdb.conf & other required files [0]: ") or 0 - config_filename = config_paths[int(config_file_dir_selection)] - - print("To get you started, you will need to enter the address of the default ") - print("CDB Server. You can add more server definitions by editing the cdb.conf file ") - print("and following the = syntax ") - print("Hit [Return] to accept defaults ") - prompt_string = "Enter the default CDB Server Address [https://cdb.aps.anl.gov/cdb]: " - cdb_server = input(prompt_string) or "https://cdb.aps.anl.gov/cdb" - session_file = os.path.dirname(config_filename) + "/cdb_api_session" - - self.configuration = ConfigParser() - self.configuration.optionxform = str - self.configuration[self.CONFIG_WEB_PORTAL_SECTION_NAME] = {self.CONFIG_PORTAL_VALUE_NAME: cdb_server} - self.configuration[self.SESSION_COOKIE_SECTION_NAME] = {self.SESSION_COOKIE_VALUE_NAME: session_file} - try: - config_directory = os.path.dirname(config_filename) - if not os.path.exists(config_directory): - os.makedirs(config_directory) - with open(config_filename, "w") as configfile: - self.configuration.write(configfile) - except Exception as e: - print(" Sorry, can't save your cdb.conf file, try again") - print(str(e)) - - def get_portal_address(self, portal_key=CONFIG_PORTAL_VALUE_NAME): - web_portal_section = self.configuration[self.CONFIG_WEB_PORTAL_SECTION_NAME] - try: - portal_value = web_portal_section[portal_key] - except: - print("Sorry ", portal_key, " wasn't provided in your cdb.conf ") - return (None) - return (portal_value) - - def get_session_file_path(self): - session_cookie_section = self.configuration[self.SESSION_COOKIE_SECTION_NAME] - session_cookie_file = os.path.expanduser(session_cookie_section[self.SESSION_COOKIE_VALUE_NAME]) - return session_cookie_file - - def get_session_credentials(self): - try: - session_cookie_section = self.configuration[self.SESSION_COOKIE_SECTION_NAME] - session_username = session_cookie_section[self.SESSION_USERNAME] - session_password = session_cookie_section[self.SESSION_PASSWORD] - return (session_username, session_password) - except Exception as e: - return (None, None) - -if __name__ == "__main__": - configMan = ConfigurationManager() - print("Default Portal Address Is:", configMan.get_portal_address()) - print("Session File Path Is:", configMan.get_session_file_path()) - print("Default Portal Address Is:", configMan.get_portal_address("NotListed")) - print("Session Password is ", configMan.get_session_credentials()) - print("Outstanding, it all works") diff --git a/tools/developer_tools/python-client/cdbCli/service/__init__.py b/tools/developer_tools/python-client/cdbCli/service/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/__init__.py b/tools/developer_tools/python-client/cdbCli/service/cli/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/__init__.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/addDocumentFile.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/addDocumentFile.py deleted file mode 100755 index 337511315..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/addDocumentFile.py +++ /dev/null @@ -1,113 +0,0 @@ -#!/usr/bin/env python -import sys -import csv -import click - -from rich import print -from rich.traceback import install - -from cdbApi import ApiException - -from CdbApiFactory import CdbApiFactory -from cdbCli.common.cli import cliBase - -from cdbCli.common.cli.cliBase import CliBase -from cdbCli.service.cli.cdbCliCmnds.setItemLogById import set_item_log_by_id_helper - - -def add_document_file_helper( - item_api, item_id, property_tag, property_description, upload_filename, add_item_log=False -): - """Helper function to upload a file to a given item id - - Args: - item_api (object): Item Api object - item_id (string): Item ID to have file uploaded to - property_tag (string): Tag of the given document - property_description (string): Description of the given document - upload_filename (string): Path to file including filename - """ - - try: - - current_prop_ids = [ - prop.id for prop in item_api.get_properties_for_item(item_id) - ] - fileObject = CdbApiFactory.createFileUploadObject(upload_filename) - item_api.upload_document_for_item(item_id, file_upload_object=fileObject) - document_property = [ - prop - for prop in item_api.get_properties_for_item(item_id) - if prop.id not in current_prop_ids - ][0] - except Exception as e: - print("File uploaded to Item ID :" + str(item_id) + " unsuccessfully") - else: - document_property.tag = property_tag - document_property.description = property_description - item_api.update_item_property_value(item_id, property_value=document_property) - if add_item_log: - log = "File uploaded to Item ID :" + str(item_id) + " successfully" - set_item_log_by_id_helper(item_api=item_api, item_id=item_id, log_entry=log) - - -@click.command() -@click.option( - "--input-file", - help="Input csv file with id, tag, description, upload filename, default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@click.option( - "--item-id-type", - default="id", - type=click.Choice(["id", "qr_id"], case_sensitive=False), - help="Allowed values are 'id'(default) or 'qr_id'", -) -@cliBase.wrap_common_cli_click_options -@click.pass_obj -def add_document_file(cli, input_file, item_id_type, add_log_to_item): - """Uploads a document to a Document Property of the item. - - \b - Example (file): add-document-file --input-file filename.csv --item-id-type=qr_id - Example (pipe): cat filename.csv | add-document-file - Example (terminal): add-document-file - header - ,,, - - Input is either through a named file or through STDIN. Default is STDIN - The format of the input data is - ,,,""" - - try: - factory = cli.require_authenticated_api() - except ApiException: - click.echo("Unauthorized User/ Wrong Username or Password. Try again.") - return - - item_api = factory.getItemApi() - - stdin_msg = "Entry per line: ,,," % item_id_type - reader, stdin_tty_mode = cli.prepare_cli_input_csv_reader(input_file, stdin_msg) - - # Parse lines of csv - for row in reader: - if row.__len__() == 0 and stdin_tty_mode: - break - - item_id = row[0] - property_tag = row[1] - property_description = row[2] - upload_filename = row[3] - - if item_id_type == "qr_id": - item_id = str(item_api.get_item_by_qr_id(int(item_id)).id) - - add_document_file_helper( - item_api, item_id, property_tag, property_description, upload_filename, add_log_to_item - ) - - -if __name__ == "__main__": - add_document_file() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/addDocumentProperty.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/addDocumentProperty.py deleted file mode 100755 index a97bc43d2..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/addDocumentProperty.py +++ /dev/null @@ -1,172 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import re -import csv -import click - -from rich import print - -from cdbApi import ApiException -from cdbCli.common.cli import cliBase - -from cdbCli.service.cli.cdbCliCmnds.setItemLogById import set_item_log_by_id_helper -from cdbApi.models.property_value import PropertyValue - - -################################################################################################ -# # -# Add http handler property to item # -# # -################################################################################################ -def add_document_property_helper( - item_api, - prop_type_api, - item_id, - prop_name, - unique_flag, - tag, - prop_value, - display_value, - description, - add_log_to_item = False -): - """This function adds a http property to a given item - - Args: - item_api (object): Item Api object - prop_type_api (object): Property Type Api object - item_id (string): Item Id - prop_name (string): Name of the property to add - unique_flag (boolean): Whether or not to prevent duplicate tags - tag (string): Tag for given property - prop_value (string): Value of the property to add - display_value (string): Value to be displated - description (string): Description of the property - """ - - try: - http_property_type = prop_type_api.get_property_type_by_name(prop_name) - - # Check to see if property exists and must be unique - if unique_flag: - properties = item_api.get_properties_for_item(item_id) - for prop in properties: - if prop.property_type == http_property_type and prop.tag == tag: - print( - "Item Id: " + item_id + ", Tag " + prop.tag + " is already used" - ) - return - # Go ahead and add the property - property_value = PropertyValue( - property_type=http_property_type, - tag=tag, - value=prop_value, - display_value=display_value, - description=description, - ) - item_api.add_item_property_value(item_id, property_value=property_value) - except ApiException as e: - p = r'"localizedMessage.*' - matches = re.findall(p, e.body) - if matches: - error = ( - "ItemId: " - + item_id - + "Error: Unable to creating property: " - + matches[0][:-2] - ) - print(error) - else: - if add_log_to_item: - log = "Item Id: " + item_id + "successfully uploaded with properties" - set_item_log_by_id_helper(item_api=item_api, item_id=item_id, log_entry=log) - - -@click.command() -@click.option( - "--input-file", - help="Input csv file with id,new detail value, default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@click.option( - "--item-id-type", - default="id", - type=click.Choice(["id", "qr_id"], case_sensitive=False), - help="Allowed values are 'id'(default) or 'qr_id'", -) -@click.option( - "--prop", - default="web_documentation", - type=click.Choice(["web_documentation", "related_cdb_item"], case_sensitive=False), - help="Allowed values are web_documentation(default) or 'related_cdb_item' ", -) -@click.option( - "--unique-flag/--no-unique-flag", default=True, help="Prevent duplicate tags [True]" -) -@cliBase.wrap_common_cli_click_options -@click.pass_obj -def add_document_property(cli, input_file, item_id_type, prop, unique_flag, add_log_to_item): - """Adds a Property with an http link handler to a CDB Item. Property Type - is selected via the doc_type flag. If the unique flag is true, - then the property is not added if there is already a document propety - with the same tag. - - \b - Example (file): add-document-property --input-file filename.csv --item-id-type=qr_id - Example (pipe): cat filename.csv | add-document-property - Example (terminal): add-document-property - header - ,,,, - - Input is either through a named file or through STDIN. Default is STDIN - The format of the input data is - .,,, - where the ID is by the type specified on the commandline.""" - try: - factory = cli.require_authenticated_api() - except ApiException: - click.echo("Unauthorized User/ Wrong Username or Password. Try again.") - return - - item_api = factory.getItemApi() - prop_type_api = factory.getPropertyTypeApi() - - if prop == "web_documentation": - prop_name = "Documentation (Web)" - elif prop == "related_cdb_item": - prop_name = "Related CDB Item" - - stdin_msg = "Entry per line: .,,," % item_id_type - reader, stdin_tty_mode = cli.prepare_cli_input_csv_reader(input_file, stdin_msg) - - # Parse lines of csv - for row in reader: - if row.__len__() == 0 and stdin_tty_mode: - break - item_id = row[0] - tag = row[1] - url = row[2] - display_value = row[3] - description = row[4] - - if item_id_type == "qr_id": - item_id = str(item_api.get_item_by_qr_id(int(item_id)).id) - - add_document_property_helper( - item_api, - prop_type_api, - item_id, - prop_name, - unique_flag, - tag, - url, - display_value, - description, - add_log_to_item - ) - - -if __name__ == "__main__": - add_document_property() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/addProperty.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/addProperty.py deleted file mode 100755 index 2e7f89415..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/addProperty.py +++ /dev/null @@ -1,166 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import re -import csv -import click - -from cdbApi import LogEntryEditInformation -from cdbApi import ItemStatusBasicObject -from cdbApi import ApiException - -from CdbApiFactory import CdbApiFactory - -from cdbCli.common.cli.cliBase import CliBase -from cdbApi.models.property_value import PropertyValue - - -################################################################################################ -# # -# Add property to item # -# # -################################################################################################ -def add_property_help( - item_id, prop_name, unique_flag, tag, prop_value, display_value, description, cli -): - """ - This function updates fields on a CDB item - - :param item_id: The ID of the CDB item to add property to - :param prop_name: CDB property name - :param tag : Tag field for the CDB Property - :param prop_value : property value for the Property - :param display_value : display value for CDB Property - :param description: Descrription of the property value. - :param cli: necessary CliBase object - """ - - # Note: Parameter validation is was done by click - - factory = cli.require_authenticated_api() - item_api = factory.getItemApi() - property_type_api = factory.getPropertyTypeApi() - - _ids = item_id.split(",") - new_property_type = property_type_api.get_property_type_by_name(prop_name) - - response_list = [] - for _id in _ids: - try: - # Check to see if property exists and must be unique - break_needed = False - if unique_flag: - properties = item_api.get_properties_for_item(_id) - for prop in properties: - if prop.property_type == new_property_type and prop.tag == tag: - error = ( - "Item Id:," - + str(_id) - + ",Tag " - + prop.tag - + " is already used" - ) - response_list.append(error) - break_needed = True - if break_needed: - continue - - # Go ahead and add the property - property_value = PropertyValue( - property_type=new_property_type, - tag=tag, - value=prop_value, - display_value=display_value, - description=description, - ) - property = item_api.add_item_property_value( - _id, property_value=property_value - ) - response_list.append( - str(_id)+","+ str(property.id) + "," + prop_name + ",Tag," + tag - ) - except ApiException as e: - p = r'"localizedMessage.*' - matches = re.findall(p, e.body) - if matches: - error = ( - "ItemId:," - + str(_id) - + "Error:Error creating property: " - + matches[0][:-2] - ) - response_list.append(error) - - return response_list - - -@click.command() -@click.option( - "--inputfile", - help="Input csv file with id detail value, default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@click.option( - "--item-id-type", - default="id", - type=click.Choice(["id", "qr_id"], case_sensitive=False), - help="Allowed values are 'id'(default) or 'qr_id'", -) -@click.option("--propname", required=True) -@click.option( - "--unique-flag/--no-unique-flag", default=True, help="Prevent duplicate tags [True]" -) -@click.option("--dist", help="Change the CDB distribution (as provided in cdb.conf)") -def add_property(inputfile, propname, item_id_type, unique_flag, dist=None): - """Adds a Property to the cdb item. Property Type - is selected via the required propname flag. If the unique flag is true, - then the property is not added if there is already an exising property of - type propnamewith the same tag. - - \b - Input is either through a named file or through STDIN. Default is STDIN - The format of the input data is - .,,< Display Value>, - where the ID is by the type specified on the commandline.""" - - cli = CliBase(dist) - factory = cli.require_authenticated_api() - item_api = factory.getItemApi() - - property_type_api = factory.getPropertyTypeApi() - new_property_type = property_type_api.get_property_type_by_name(propname) - - # Confirm that the property type exists - - reader = csv.reader(inputfile) - for row in reader: - item_id = row[0] - tag = row[1] - url = row[2] - display_value = row[3] - description = row[4] - - # Get ids if we were given QR Codes. Note, we could have multiple ids specified as the first element of - # the csv. This is a holdover from the old code. - try: - ids = item_id.split(",") - if item_id_type == "qr_id": - ids_from_qr_ids = [ - str(item_api.get_item_by_qr_id(int(qr_id)).id) for qr_id in ids - ] - initialized_str = "," - item_id = initialized_str.join(ids_from_qr_ids) - except Exception as e: - print(e) - continue - - response_list = add_property_help( - item_id, propname, unique_flag, tag, url, display_value, description, cli - ) - for response in response_list: - click.echo(response) - - -if __name__ == "__main__": - add_property() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/cdb_log_to_mqtt.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/cdb_log_to_mqtt.py deleted file mode 100755 index 3d70b08da..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/cdb_log_to_mqtt.py +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env python - -import logging -import click -import time -import datetime -import re -import json -from collections import deque -from CdbApiFactory import CdbApiFactory -import paho.mqtt.client as mqtt -from logging.handlers import TimedRotatingFileHandler - -from cdbCli.common.cli.cliBase import CliBase -from rich import print - - -def start_status_logs(): - - global FORMATTER - FORMATTER = logging.Formatter( - "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - ) - global LOG_FILE - LOG_FILE = "cdb_log_to_mqtt.log" - global FILE_HANDLER - FILE_HANDLER = TimedRotatingFileHandler(LOG_FILE, when="midnight") - FILE_HANDLER.setFormatter(FORMATTER) - # Set up the Main Logger - global LOGGER - LOGGER = logging.getLogger("main") - LOGGER.addHandler(FILE_HANDLER) - LOGGER.setLevel(logging.DEBUG) - LOGGER.propagate = False - # Set up a Logger for the MQTT Code - global PAHO_LOGGER - PAHO_LOGGER = logging.getLogger("paho_mqtt") - PAHO_LOGGER.addHandler(FILE_HANDLER) - PAHO_LOGGER.setLevel(logging.INFO) - PAHO_LOGGER.propagate = False - - -# Set Up Data Structures for interacting with MQTT -MQTT_DATA = {} -MQTT_DATA["queue"] = deque() - - -def mqtt_on_publish(client, userdata, result): - """Callback function for Paho when messages are published""" - LOGGER.debug("MQTT Data published") - pass - - -def mqtt_on_connect(client, userdata, flags, rc): - """Callback function for Paho MQTT when connection is made - to the server""" - if rc == 0: - LOGGER.info("MQTT Connected to broker") - client.connect_flag = True # Signal connection - client.disconnect_flag = False - client.publish(userdata["base_topic"], "ONLINE", qos=1, retain=True) - - else: - LOGGER.info("MQTT Connection failed") - - -def mqtt_on_disconnect(client, userdata, rc): - """Callback function when client disconnects""" - logging.info("MQTT Disconnecting reason " + str(rc)) - client.connect_flag = False - client.disconnect_flag = True - - -def mqtt_on_message(client, userdata, message): - """Callback function when message is received. We just put the - message in a queue""" - LOGGER.info("On_Message Message Received: " + str(message.payload)) - userdata["queue"].append(message) - - -def set_up_mqtt_client(clientname, server, port, username, password, base_topic): - """Sets up the MQTT Connection so that we can publish to it - - :param clientname: The name of this MQTT Applicaiton client - :param server: Address of the MQTT Server - :param port: TCP Port the server is runnig on - :param username: Username on the MQTT Server - :param password: Password for the MQTT Server - :param base_topic: The Base MQTT Topic underwhich we are sending messages.""" - - client = mqtt.Client(clientname, False) - client.on_connect = mqtt_on_connect - client.on_disconnect = mqtt_on_disconnect - client.on_message = mqtt_on_message - client.on_publish = mqtt_on_publish - client.connect_flag = False - client.disconnect_flag = True - client.enable_logger(logger=PAHO_LOGGER) - client.username_pw_set(username, password) - MQTT_DATA["base_topic"] = base_topic - client.user_data_set(MQTT_DATA) - client.will_set(base_topic, payload="OFFLINE", qos=1, retain=True) - client.loop_start() - client.connect(server, port) - while client.disconnect_flag: - time.sleep(0.1) - LOGGER.info("Connected to MQTT Server and in Main Body") - return client - - -def get_cdb_logs(delta_minutes, logApi): - """Gets the CDB Logs for the last delta_minutes minutes and - returns as a list of dicts - - :param delta_minutes: The time interval between sampling the logs - :param logApi: The CDB Api method to obtain the logs. - - - """ - now = datetime.datetime.utcnow() - time_delta = datetime.timedelta(minutes=delta_minutes) - earlier = now - time_delta - results = logApi.get_successful_entity_update_log_since_entered_date(str(earlier)) - results_list = [ - dict( - [ - ("id", result.id), - ("text", result.text), - ("item_id", re.search("\[Item\ Id:\ (.*)\]", result.text).group(1)), - ("entered_on_date_time", result.entered_on_date_time), - ("effective_from_date_time", result.effective_from_date_time), - ("effective_to_date_time", result.effective_to_date_time), - ] - ) - for result in results - if "Item Id" in result.text - ] - return results_list - - -@click.command() -@click.option( - "--cdb_server", - default="https://cdb.aps.anl.gov/cdb", - help="Address of the CDB Server", -) -@click.option( - "--mqtt_server", default="cooper.aps.anl.gov", help="Address of the MQTT Server" -) -@click.option("--mqtt_port", default=1883, help="TCP Port Number for the MQTT Server") -@click.option( - "--mqtt_clientname", - default="cdb_log_to_mqtt", - help="Unique MQTT clientname to identify this app", -) -@click.option( - "--mqtt_applicationtopic", - default="CDBItemUpdate", - help="Top level MQTT Topic under which messages are sent.", -) -@click.option( - "--sleepsecs", - default=60, - help="Time polling interval in seconds between sampling the CDB Logs", -) -@click.argument("mqtt_user") -@click.argument("mqtt_password") -def cdb_log_to_mqtt( - cli, - cdb_server, - mqtt_server, - mqtt_port, - mqtt_clientname, - mqtt_applicationtopic, - sleepsecs, - mqtt_user, - mqtt_password, -): - - """This application periodically downloads the logs from the CDB Server and scans the log for changes to entity - items. If a change is reported by the log within the polling interval then a short message containing the - item id is sent to the channel: - - \b - /ALL/ - - \b - A second message is sent to the channel - - \b - / - - \b - In addition, Online and Offline statuses are reported on the channel directly. - - \b - One use of this logger is to simulate a "Report by Exception" process where a CDB Client is waiting for - a change to an item before using the CDB API to request the data. - """ - # Start up the program status logging - - start_status_logs() - - # Attach to the communication channels - mqtt_client = set_up_mqtt_client( - mqtt_clientname, - mqtt_server, - mqtt_port, - mqtt_user, - mqtt_password, - mqtt_applicationtopic, - ) - - # And now the CDB Authorization - # apiFactory = CdbApiFactory(cdb_server) - try: - cli.api_factory = CdbApiFactory(cdb_server) - apiFactory = cli.require_authenticated_api( - "Authentication as an Administrator is required." - ) - apiFactory.testAuthenticated() - LOGGER.info("CDB Factory object is authenticated") - except: - print("Sorry, CDB factory authentication not valid") - LOGGER.info("Sorry, CDB factory authentication not valid") - exit - itemApi = apiFactory.getItemApi() - logApi = apiFactory.getLogApi() - - # If we are still running, we can begin our loop. It is pretty simple. - # We will continuous get the logs from the CDB Server, scan it for - # changes and if so, send a message to the ALL channel and also - # to an item number specific channel: - - # We initially assume that we are only going to send messages - # from this point forward. - - # The time interval for sleeping is given to us in sleepsecs. - # The total cycle time for sending data to the MQTT server is - # going to be the sleep time + the retrieval and processing time - # for the messages. So, we will calculate a minimum time_delta - # in order to get the logs - - time_delta = 100 * sleepsecs if sleepsecs > 36 else 3600 - LOGGER.info("Time length of CDB Logs (secs): " + str(time_delta)) - previous_log_results = get_cdb_logs(time_delta, logApi) - try: - while True: - log_results = get_cdb_logs(time_delta, logApi) - # Get the list of all of the log messages we need - # to process - new_log_messages = [ - message - for message in log_results - if message not in previous_log_results - ] - item_ids_to_log = {message["item_id"] for message in new_log_messages} - - # Now, we process the messages - # For our initial server, we just want to notify that there - # is a change so our message will be simple. We can build - # on it later. We will just send the "now" time and the id. - LOGGER.debug( - "In Loop, number of item_ids to send: " + str(len(item_ids_to_log)) - ) - for item_id in item_ids_to_log: - message = {"id": item_id, "date": str(datetime.datetime.now())} - json_string = json.dumps(message) - all_topic = mqtt_applicationtopic + "/ALL/" - item_topic = mqtt_applicationtopic + "/" + str(item_id) + "/" - mqtt_client.publish(all_topic, json_string) - mqtt_client.publish(item_topic, json_string) - LOGGER.debug("String Send to ALL Channel :" + json_string) - previous_log_results = log_results - time.sleep(sleepsecs) - except: - logging.exception("Exiting") - mqtt_client.publish(mqtt_applicationtopic, "OFFLINE (normal)") - mqtt_client.disconnect() - mqtt_client.loop_stop() - - -if __name__ == "__main__": - cdb_log_to_mqtt() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/createLocation.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/createLocation.py deleted file mode 100755 index c487bcf7d..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/createLocation.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import csv -import click - -from rich import print -from cdbApi import ApiException - -from cdbApi.models.new_location_information import NewLocationInformation - -from cdbCli.common.cli.cliBase import CliBase - - -################################################################################################ -# # -# Set new location of item # -# # -################################################################################################ -def create_location_helper( - item_api, - parent_location_id, - location_name, - location_qr_id, - location_type, - location_description, -): - """ - This function creates a new location - - :param item_api: Item Api object - :param parent_location_id: The parent ID iunder which this location is created - :param location_name: New Location's Name - :param locaton_qr_id: QR Code of the location - :param location_type: Type of the Location tion - :param location_description: New location's description - """ - - try: - new_location = NewLocationInformation( - parent_location_id=parent_location_id, - location_name=location_name, - location_qr_id=location_qr_id, - location_type=location_type, - location_description=location_description, - ) - result = item_api.create_location(new_location) - print(result) - except Exception as e: - print("Error :", str(e)) - - -@click.command() -@click.option( - "--input-file", - help="Input csv file with new location parameters, see help, default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@click.pass_obj -def create_location(cli, input_file): - """Creates a new location with id, qr_id, name, type, and description - - \b - Example (file): create-location --input-file filename.csv - Example (pipe): cat filename.csv | create-location - Example (terminal): create-location - header - ,,,, - - - Create new location from csv on STDIN(default) or a file - File has the format - ,,,,""" - - try: - factory = cli.require_authenticated_api() - except ApiException: - click.echo("Unauthorized User/ Wrong Username or Password. Try again.") - return - - item_api = factory.getItemApi() - - stdin_msg = "Entry per line: ,,,," - reader, stdin_tty_mode = cli.prepare_cli_input_csv_reader(input_file, stdin_msg) - - # Parse lines of csv - for row in reader: - if row.__len__() == 0 and stdin_tty_mode: - break - location_id = row[0] - location_name = row[1] - location_qr_id = row[2] - location_type = row[3] - location_description = row[4] - create_location_helper( - item_api, - location_id, - location_name, - location_qr_id, - location_type, - location_description, - ) - - -if __name__ == "__main__": - create_location() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/getCatalogItemsByName.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/getCatalogItemsByName.py deleted file mode 100755 index aec30e140..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/getCatalogItemsByName.py +++ /dev/null @@ -1,328 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import csv -import re -import click - -from rich import print -from cdbApi import ApiException - -from CdbApiFactory import CdbApiFactory - -from cdbCli.common.cli.cliBase import CliBase - - -############################################################################################# -# # -# Get info about catalog items from name # -# # -############################################################################################# -def get_inventory_info_by_catalog_id(item_api, catalog_id, field): - """ - This function prints info about a given catalog item's associated inventory items. - - :param item_api: Item Api object - :param catalog_id: ID of the catalog item - :param field: The name of the info field requested - - """ - - derived_items = item_api.get_items_derived_from_item_by_item_id(catalog_id) - fields_requested = field.split(",") - - for item in derived_items: - csvwriter = csv.writer(sys.stdout) - info_list = [] - for f in fields_requested: - f = f.strip(" ") - # Get info for inventory items - - if f == "status": - status = item_api.get_item_status(item.id).value - info = str(status) - elif f == "location": - location = item_api.get_item_location(item.id).location_string - info = str(location) - elif f == "id": - info = str(item.id) - elif f == "qr_id": - qr_id = item.qr_id - info = str(qr_id).zfill(9) - elif f == "name": - info = item.name - elif f == "description": - info = item.description - elif f == "serial number": - info = item.item_identifier1 - elif f == "alternate name": - info = item.item_identifier2 - elif f == "technical system": - info = [ - item.derived_from_item.item_category_list[i].name - for i in range(len(item.derived_from_item.item_category_list)) - ] - elif f == "function": - info = [ - item.derived_from_item.item_type_list[i].name - for i in range(len(item.derived_from_item.item_type_list)) - ] - else: - info = "Invalid Field" - if type(info) != list: - info_list.append(info) - else: - info_list = info_list + info - - for output_message in info_list: - outputlist = [] - outputlist.append("InventoryItem") - outputlist.append(str(catalog_id)) - outputlist.append(str(item.id)) - outputlist.append(str(item.name)) - outputlist.append(str(f)) - outputlist.append(output_message) - csvwriter.writerow(outputlist) - - # Print info to user - - -def get_specific_inventory_info_by_catalog_id(item_api, catalog_id, field, inventory): - """ - This function prints info about a given catalog item's associated inventory items. - - :param item_api: Item Api object - :param catalog_id: ID of the catalog item - :param field: The name of the info field requested - :param inventory: The specific inventory names that are needed - """ - - derived_items = item_api.get_items_derived_from_item_by_item_id(catalog_id) - - # expression must be at beginning of name - r = "^" + inventory.lower() + "$" - - # change SQL wildcard '?' to regex wildcard '.' - if "?" in inventory: - r = r.replace("?", ".") - - # change SQL wildcard '*' to regex wildcard '.*' - if "*" in inventory: - r = r.replace("*", ".*") - - fields_requested = field.split(",") - - for derived_item in derived_items: - - matches = re.findall(r, (derived_item.name).lower()) - - if matches: - csvwriter = csv.writer(sys.stdout) - info_list = [] - for f in fields_requested: - f = f.strip(" ").lower() - - # Get info to print - if f == "status": - status = item_api.get_item_status(derived_item.id).value - info = str(status) - elif f == "location": - location = item_api.get_item_location( - derived_item.id - ).location_string - info = str(location) - elif f == "id": - info = str(derived_item.id) - elif f == "qr_id": - qr_id = derived_item.qr_id - info = str(qr_id).zfill(9) - elif f == "serial number": - info = derived_item.item_identifier1 - elif f == "alternate name": - info = derived_item.item_identifier2 - elif f == "technical system": - info = [ - derived_item.derived_from_item.item_category_list[i].name - for i in range( - len(derived_item.derived_from_item.item_category_list) - ) - ] - elif f == "function": - info = [ - derived_item.derived_from_item.item_type_list[i].name - for i in range( - len(derived_item.derived_from_item.item_type_list) - ) - ] - else: - info = "Invalid Field" - if type(info) != list: - info_list.append(info) - else: - info_list = info_list + info - - for output_message in info_list: - outputlist = [] - outputlist.append("InventoryItem") - outputlist.append(str(catalog_id)) - outputlist.append(str(derived_item.id)) - outputlist.append(str(derived_item.name)) - outputlist.append(str(f)) - outputlist.append(output_message) - csvwriter.writerow(outputlist) - - -def get_catalog_items_by_name_helper(item_api, name, field, inventory): - """ - This function prints info about catalog items given the catalog item name. Supports wildcard characters for names. - - :param item_api: Item Api object - :param name: The name of the catalog item (wildcards * and ? are supported) - :param field: The name of the info field requested - :param inventory: The inventory items needed - """ - - catalog_items = item_api.get_catalog_items() - - field_list = [ - "id", - "name", - "qr_id", - "description", - "location", - "status", - "model number", - "function", - "technical system", - "alternate name", - "serial number", - ] - - fields_requested = field.split(",") - if field == "?": - print("Valid fields:") - print("---------------") - for f in field_list: - print(f) - return - - # expression must be at beginning of name - r = "^" + name.lower() + "$" - - # change SQL wildcard '?' to regex wildcard '.' - if "?" in name: - r = r.replace("?", ".") - - # change SQL wildcard '*' to regex wildcard '.*' - if "*" in name: - r = r.replace("*", ".*") - - csvwriter = csv.writer(sys.stdout) - outputlist = ["ItemType", "DerivedFromItem", "ItemId", "Field", "FieldValue"] - - for catalog_item in catalog_items: - - matches = re.findall(r, (catalog_item.name).lower()) - - if matches: - csvwriter.writerow(outputlist) - info_list = [] - for f in fields_requested: - # Get info to print - f = f.strip(" ").lower() - if ( - f == "status" - or f == "location" - or f == "serial number" - or f == "qr_id" - ): - info = "info not available for catalog items" - elif f == "id": - info = str(catalog_item.id) - elif f == "name": - info = catalog_item.name - elif f == "description": - info = catalog_item.description - elif f == "model number": - info = catalog_item.item_identifier1 - elif f == "alternate name": - info = catalog_item.item_identifier2 - elif f == "technical system": - info = [ - catalog_item.item_category_list[i].name - for i in range(len(catalog_item.item_category_list)) - ] - elif f == "function": - info = [ - catalog_item.item_type_list[i].name - for i in range(len(catalog_item.item_type_list)) - ] - else: - info = "Invalid Field" - if type(info) != list: - info_list.append(info) - else: - info_list = info_list + info - - # Print info to user - - for output_message in info_list: - outputlist = [] - outputlist.append("CatalogItem") - outputlist.append(str(catalog_item.id)) - outputlist.append(str(catalog_item.id)) - outputlist.append(str(catalog_item.name)) - outputlist.append(str(f)) - outputlist.append(output_message) - csvwriter.writerow(outputlist) - - if inventory: - if inventory == "all": - get_inventory_info_by_catalog_id(item_api, catalog_item.id, field) - else: - get_specific_inventory_info_by_catalog_id( - item_api, catalog_item.id, field, inventory - ) - - -@click.command() -@click.option( - "--name", - required=True, - prompt="Item Name", - help="name of the item (use wildcards * and ?)", -) -@click.option( - "--field", prompt="Field Name", help="field to be returned (use ? to get options)" -) -@click.option( - "--inventory", - help='Inventory items to be included by name (use "all" to get all inventory for that catalog item', -) -@click.pass_obj -def get_catalog_items_by_name(cli, name, field, inventory): - """Gets given field(s) and inventory item(s) for given catalog item - - \b - * For multi-word entries enclose in "" - * Wildcards work for catalog names and inventory names - * Wildcard (?): Use ? as any single character - * Wildcard (*): Use * to capture any amount of characters - - \b - Example: get-catalog-items-by-name --name "001 - CDB*" --field id --inventory "unit: 1*" - Example: get-catalog-items-by-name --name "001 ? CDB Test Component" --field "qr_id, description" - """ - try: - factory = cli.require_authenticated_api() - except ApiException: - print("Unauthorized User/ Wrong Username or Password. Try again.") - return - - item_api = factory.getItemApi() - - get_catalog_items_by_name_helper(item_api, name, field, inventory) - - -if __name__ == "__main__": - get_catalog_items_by_name() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/getLocationIdByName.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/getLocationIdByName.py deleted file mode 100755 index b0b67a57d..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/getLocationIdByName.py +++ /dev/null @@ -1,95 +0,0 @@ -#!/usr/bin/env python3 - -import re -import click - -from rich import print -from cdbApi import ApiException - - -from cdbCli.common.cli.cliBase import CliBase - - -################################################################################################ -# # -# Get location ID by name # -# # -################################################################################################ -def get_location_id_by_name_helper(item_api, location_name): - """ - This function gets a locatable item id from a given location name - - :param item_api: Item Api object - :param location_name: The name of the location as a string - """ - - catalog = item_api.get_catalog_items() - # print(len(catalog)) - - inventory = item_api.get_items_by_domain(domain_name="inventory") - # print(len(inventory)) - - locations = item_api.get_items_by_domain("location") - - # expression must be at beginning of name - r = "^" + location_name.lower() + "$" - - # change SQL wildcard '?' to regex wildcard '.' - if "?" in location_name: - r = r.replace("?", ".") - - # change SQL wildcard '*' to regex wildcard '.*' - if "*" in location_name: - r = r.replace("*", ".*") - - found = False - click.echo("Location: ID") - click.echo("--------------") - for i in range(len(locations)): - - matches = re.findall(r, (locations[i].to_dict()["name"]).lower()) - - if matches: - print_string = ( - locations[i].to_dict()["name"] - + ": " - + str(locations[i].to_dict()["id"]) - ) - print(print_string) - found = True - if not found: - print("Location not found") - - -@click.command() -@click.option( - "--location-name", - required=True, - prompt="Location Name:", - help="Location Name (use wildcards ? and *)", -) -@click.pass_obj -def get_location_id_by_name(cli, location_name): - """Gets the corresponding ID for a location name - - \b - * For multi-word entries enclose entry in "" - * Wildcard (?): Use ? as any single character - * Wildcard (*): Use * to capture any amount of characters - - - Example: get-location-id-by-name --location-name 335* - Example: get-location-id-by-name --location-name "335?C?shelf 9" - """ - try: - factory = cli.require_authenticated_api() - except ApiException: - print("Unauthorized User/ Wrong Username or Password. Try again.") - return - item_api = factory.getItemApi() - - get_location_id_by_name_helper(item_api, location_name) - - -if __name__ == "__main__": - get_location_id_by_name() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/getProperties.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/getProperties.py deleted file mode 100644 index d748d9c38..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/getProperties.py +++ /dev/null @@ -1,202 +0,0 @@ -#!/usr/bin/env python -# coding: utf-8 -import datetime -from mailbox import NotEmptyError -import re -import sys -import click -from datetime import date - -import pandas as pd -import sqlite3 -from rich import print -from rich.traceback import install - - -from CdbApiFactory import CdbApiFactory -from cdbCli.common.cli.cliBase import CliBase - - -@click.command() -@click.option( - "--property", required=True, help="Scan the Inventory for items with this Property" -) -@click.option( - "--itemnumbers/--no-itemnumbers", - help="itemnumbers reads item numbers from input, otherwise scans entire database [d:--no-itemnumbers]", - default=False, -) -@click.option("--dist", help="Change the CDB distribution (as provided in cdb.conf)") -@click.option( - "--inputfile", - help="Input for itemnumbers when --itemnumber selected, default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@click.option( - "--printheader/--no-printheader", - help="Output/suppress header line (default --printheader)", - default=True, -) -@click.option( - "--outputfile", - help="Output csv file with item info and properties, default is STDOUT", - type=click.File("r"), - default=sys.stdout, -) -@click.option( - "--item-id-type", - default="id", - type=click.Choice(["id", "qr_id"], case_sensitive=False), - help="Allowed values are 'id'(default) or 'qr_id'", -) - -@click.option( - "--item-type", - default="inventory", - type=click.Choice(["catalog","inventory"],case_sensitive=False), - help="Allowed cdb types are 'inventory'(default) or 'catalog' for full scan", -) -def get_properties( - property, outputfile, inputfile, itemnumbers, item_id_type, item_type, printheader, dist=None): - """Inspects CDB for inventory with the selected property name and outputs a - CSV of the current values in CDB. It can scan the CDB inventory for items - that have the property or take selected id or qr ids from a file.""" - install(show_locals=True) - resultDF = get_properties_helper(property, inputfile, item_id_type, item_type, itemnumbers, dist) - resultDF.to_csv(outputfile, index=False, header=printheader) - - -def get_properties_helper(property, inputfile, item_id_type, item_type, itemnumbers, dist=None): - """Takes a csv file of CDB Item, and Property Names and returns a - dataframe of the current values in CDB or it can scan the CDB inventory for items - that have the property and return that in the dataframe - - Args: - property (str): _description_ - inputfile (file descripter): _description_ - itemnumbers (boolean): Take item number input from input file handler or scan CDB - dist (str, optional): CDB Distribution. Defaults to None. - - Returns: - Pandas Dataframe: Dataframe with results - """ - - cli = CliBase(dist) - factory = cli.require_api() - itemApi = factory.getItemApi() - cableCatalogApi = factory.getCableCatalogItemApi() - propValueApi = factory.getPropertyValueApi() - propname = property - # FIXME: property_type_by_name seems broken. Waiting for Darius - # propTypeApi = factory.getPropertyTypeApi() - # print("Property Name: ",propname) - # property_id = propTypeApi.get_property_type_by_name(propname) - # print("Property ID",property_id) - list_of_property_dicts = [] - catalog_items = ( - itemApi.get_catalog_items() + cableCatalogApi.get_cable_catalog_item_list() - ) - if not itemnumbers and item_type == "inventory": - for cat_item in catalog_items: - for inv_item in itemApi.get_items_derived_from_item_by_item_id(cat_item.id): - property_dicts = screen_item_for_property( - itemApi, propValueApi, propname, inv_item - ) - if len(property_dicts) > 0: - list_of_property_dicts = list_of_property_dicts + property_dicts - elif not itemnumbers and item_type == "catalog": - for cat_item in catalog_items: - property_dicts = screen_item_for_property( - itemApi, propValueApi, propname, cat_item - ) - if len(property_dicts) > 0: - list_of_property_dicts = list_of_property_dicts + property_dicts - else: - for itemnumberstr in inputfile: - item_number = int(itemnumberstr.rstrip()) - if item_id_type == "qr_id": - item = itemApi.get_item_by_qr_id(item_number) - else: - item = itemApi.get_item_by_id(item_number) - property_dicts = screen_item_for_property( - itemApi, propValueApi, propname, item - ) - if len(property_dicts) > 0: - list_of_property_dicts = list_of_property_dicts + property_dicts - return pd.DataFrame(list_of_property_dicts) - - -def get_property_dictionary(item, prop, propname, itemApi, propValueApi): - """Checks the property of the item to see if - there is a match for the propname. - name propname and then returns a list of dictionaries - - Args: - inv_item (cdbApi.models.item.Item): Inventory Item - prop (cdbApi.models.property_value.PropertyValue): property of the inv_item - propname (str): name of the property to match - itemApi (cdbApi.api.item_api.ItemApi): CDB API Object for items - propValueApi (cdbApi.api.property_value_api.PropertyValueApi): CDB API Object for property values - - Returns: - list: A list of dictionary objects containing item and property data - """ - if prop.property_type.name == propname: - entity_info = itemApi.get_item_entity_info(item.id) - prop_result_dict = {} - prop_result_dict["Item_Id"] = item.id - try: - prop_result_dict["Item_QrId"] = item.qr_id - except: - prop_result_dict["Item_QrId"] = "None" - try: - prop_result_dict["Item_Catalog_Name"] = item.derived_from_item.name - except: - prop_result_dict["Item_Catalog_Name"] = item.name - prop_result_dict["Item_Name"] = item.name - prop_result_dict["Item_Serial_No"] = item.item_identifier1 - prop_result_dict["Item_Entry_Owner"] = entity_info.owner_username - prop_result_dict["Item_Entry_Group_Owner"] = entity_info.owner_user_group_name - prop_result_dict["Prop_Name"] = propname - prop_result_dict["Prop_Id"] = prop.id - prop_result_dict["Prop_Value"] = prop.value - prop_result_dict["Prop_Display_Value"] = prop.display_value - prop_result_dict["Prop_Description"] = prop.description - prop_result_dict["Prop_Tag"] = prop.tag - prop_result_dict["Prop_Units"] = prop.units - metadata = propValueApi.get_property_value_metadata(prop.id) - for m in metadata: - metadata_keystring = "Meta_" + m.metadata_key - prop_result_dict[metadata_keystring] = m.metadata_value - return_value = prop_result_dict - else: - return_value = None - return return_value - - -def screen_item_for_property(itemApi, propValueApi, propname, item): - """_summary_ - # TODO: Need to finish comments - Args: - itemApi (_type_): _description_ - propValueApi (_type_): _description_ - propname (_type_): _description_ - inv_item (_type_): _description_ - - Returns: - _type_: _description_ - """ - list_of_property_dicts = [] - properties = itemApi.get_properties_for_item(item.id) - for prop in properties: - prop_result_dict = get_property_dictionary( - item, prop, propname, itemApi, propValueApi - ) - if prop_result_dict != None: - list_of_property_dicts.append(prop_result_dict) - return list_of_property_dicts - - -if __name__ == "__main__": - get_properties() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/help.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/help.py deleted file mode 100755 index d47443d7e..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/help.py +++ /dev/null @@ -1,58 +0,0 @@ -#!/usr/bin/env python3 -from shutil import which - -from rich.console import Console -from rich.table import Table -from rich.panel import Panel - -from cdbCli.common.utility.configurationManager import ConfigurationManager -from cdbCli.service.cli.cdbCliCmnds.info import CMD_HELP_MESSAGE as cdbInfo_help_msg -from cdbCli.service.cli.cdbCliCmnds.search import CMD_HELP_MESSAGE as cdbSearch_help_msg - -def create_help_rich_table(table=None): - """ - Create a help tale for printing with rich.console Console. - - table: Two column table when this command is used for showing help for other CLI utilities that include this CLI. - """ - if not table: - cm = ConfigurationManager.get_instance() - deployment = cm.get_portal_address() - - table = Table.grid(padding=1, pad_edge=True) - table.title = "CDB CLI Help [%s]" % deployment - - table.add_column("Command", no_wrap=True, justify="left", style="green", min_width=16) - table.add_column("Description") - - table.add_row( - "cdbInfo", - cdbInfo_help_msg - ) - - table.add_row( - "cdbSearch", - cdbSearch_help_msg - ) - - table.add_row( - "cdb-cli", - "Entry point for all of of the other cdb command line utilities." - ) - - return table - - -def showHelp(): - table = create_help_rich_table() - - table.add_row( - "cdbHelp", - "Shows this screen and lists all cdb command line utilities." - ) - - console = Console() - console.print(table) - -if __name__ == '__main__': - showHelp() \ No newline at end of file diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/info.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/info.py deleted file mode 100755 index a7a115563..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/info.py +++ /dev/null @@ -1,436 +0,0 @@ -#!/usr/bin/env python3 - -from email.policy import default -import time -import click - -from rich.console import Console -from rich.tree import Tree - -from CdbApiFactory import CdbApiFactory - -from cdbApi.api.item_api import ItemApi -from cdbApi.models.item_hierarchy import ItemHierarchy -from cdbApi.models.item import Item -from cdbApi.models.item_location_information import ItemLocationInformation -from cdbApi.models.machine_design_connector_list_object import MachineDesignConnectorListObject -from cdbCli.common.cli import cliBase -from cdbCli.common.cli.cliBase import CliBase - -CMD_HELP_MESSAGE="Displays Information about a specific CDB component, including basic details, properties, logs, relationships, etc." - -CABLE_DESIGN_PROPERTY_TYPE_NAME="cable_design_internal_property_type" - -INVENTORY_FULL_OPT = "Full" -INVENTORY_SPARES_OPT = "Spare" -INVENTORY_LIST_OPTS = [INVENTORY_SPARES_OPT, INVENTORY_FULL_OPT] - -@cliBase.cli_command_api_exception_handler -def cdbInfo_helper(factory: CdbApiFactory, - all=False, - inventory_mode=INVENTORY_SPARES_OPT, - log_limit=-1, - item_id=None, - qr=None, - # Optional, but used for printing exception - console=None, - format=None): - - item_api = factory.getItemApi() - machine_api = factory.getMachineDesignItemApi() - cable_design_api = factory.getCableDesignItemApi() - domain_api = factory.getDomainApi() - location_api = factory.getLocationItemApi() - property_value_api = factory.getPropertyValueApi() - - if qr: - item = item_api.get_item_by_qr_id(qr) - item_id = item.id - else: - item = item_api.get_item_by_id(item_id) - - domain = domain_api.get_domain_by_id(item.domain_id) - - result_obj = {} - header_style = {} - - # Load Details - item_details = [] - result_obj["Item Details"] = item_details - header_style["Item Details"] = "green" - - item_details.append({"Id": item.id}) - item_details.append({"Name": item.name}) - if domain.item_identifier1_label: - item_details.append({domain.item_identifier1_label: item.item_identifier1}) - if all: - if domain.item_identifier2_label: - item_details.append({domain.item_identifier2_label: item.item_identifier2}) - if domain.item_category_label: - item_category_str = cliBase.simple_obj_list_to_str(item.item_category_list) - item_details.append({domain.item_category_label: item_category_str}) - if domain.item_type_label: - item_type_str = cliBase.simple_obj_list_to_str(item.item_type_list) - item_details.append({domain.item_type_label: item_type_str}) - if item.item_project_list: - project_str = cliBase.simple_obj_list_to_str(item.item_project_list) - item_details.append({"Project": project_str}) - item_details.append({"Domain": domain.name}) - item_details.append({"Description": item.description}) - url = factory.generateCDBUrlForItemId(item_id) - item_details.append({"URL": url}) - - # Inventory or cable inventory attributes - if item.domain_id == factory.INVENTORY_DOMAIN_ID or item.domain_id == factory.CABLE_INVENTORY_DOMAIN_ID: - status = item_api.get_item_status(item_id) - status_str = status.value - item_details.append({"Status": status_str}) - - item_details.append({"Catalog Item": item.derived_from_item.name}) - item_details.append({"Catalog Id": item.derived_from_item.id}) - - # Fetch Location - location = __get_inventory_location(item_api, item.id) - item_details.append({"Location/Housing:": location}) - - if item.domain_id == factory.INVENTORY_DOMAIN_ID or item.domain_id == factory.MACHINE_DESIGN_DOMAIN_ID: - if all: - if item.domain_id == factory.INVENTORY_DOMAIN_ID: - machine = __get_machine_inventory_installed_in(item_api, item) - else: - machine = item - - if machine: - hierarchies = machine_api.get_control_hierarchy_for_machine_element(machine.id) - for i, hierarchy in enumerate(hierarchies): - control_hierarchy_str = "" - if hierarchy.child_item: - while hierarchy: - machine_name = hierarchy.machine_item.name - if format and format == cliBase.FORMAT_RICH_OPT: - if machine_name == item.name: - machine_name = "[green]%s[/green]" % machine_name - - control_hierarchy_str += "%s" + machine_name - if hierarchy.child_item: - control_hierarchy_str += " ➜ " - if hierarchy.interface_to_parent: - interface_addon = "(%s) ➜ " % hierarchy.interface_to_parent - control_hierarchy_str = control_hierarchy_str % interface_addon - else: - control_hierarchy_str = control_hierarchy_str % ""; - - hierarchy = hierarchy.child_item - - item_details.append({"Control %d" % (i + 1): control_hierarchy_str}) - housing_hierarchy : ItemHierarchy = machine_api.get_housing_hierarchy_by_id(machine.id) - if housing_hierarchy: - housing_hierarchy_str = "" - hh = housing_hierarchy - while hh is not None: - machine_name = hh.item.name - if format and format == cliBase.FORMAT_RICH_OPT: - if machine_name == item.name: - housing_hierarchy_str += "[green]%s[/green]" % machine_name - else: - housing_hierarchy_str += machine_name - else: - housing_hierarchy_str += machine_name - - if hh.child_items: - housing_hierarchy_str += " ➜ " - hh = hh.child_items[0] - else: - hh = None - - item_details.append({"Housing": housing_hierarchy_str}) - location : ItemLocationInformation = item_api.get_item_location(machine.id) - if location: - item_details.append({"Location": location.location_string}) - - if all and item.domain_id == factory.MACHINE_DESIGN_DOMAIN_ID: - machine_item = machine_api.get_machine_design_item_by_id(item_id) - machine_conn_list : list[MachineDesignConnectorListObject] = machine_api.get_machine_design_connector_list(item_id) - conn_list = [] - result_obj['Cable Connections'] = conn_list - header_style['Cable Connections'] = 'magenta' - - for machine_conn in machine_conn_list: - if len(machine_conn.connected_cables) > 0: - cable: Item = machine_conn.connected_cables[0] - - conn = {} - conn['Cable'] = cable.name - conn['Cable Id'] = cable.id - conn['Connected Machine(s)'] = machine_conn.connected_to_items_string - conn['Port Name'] = machine_conn.connector_name - - conn_list.append(conn) - - if machine_item.assigned_item: - assigned_item = machine_item.assigned_item - catalog_item = None - catalog_item_str = "" - inventory_item_str = "" - if assigned_item: - if assigned_item.derived_from_item: - inventory_item_str = "%s/%s" % (assigned_item.name, assigned_item.qr_id) - catalog_item = assigned_item.derived_from_item - else: - catalog_item = assigned_item - - catalog_item_str = "%s/%s" % (catalog_item.name, catalog_item.id) - - item_details.append({"Catalog Item (Name/ID)": catalog_item_str}) - item_details.append({"Inventory Item (Tag/QRID)": inventory_item_str}) - - if inventory_item_str != "": - install_state = "Installed" - if not machine_item.is_housed: - install_state = "Planned" - - item_details.append({"Install Status": install_state}) - - if all and item.domain_id == factory.CABLE_DESIGN_DOMAIN_ID: - cable_conn_list = cable_design_api.get_cable_design_connection_list(item_id) - conn_list = [] - result_obj['Cable Endpoints'] = conn_list - header_style['Cable Endpoints'] = 'magenta' - - for cable_conn in cable_conn_list: - conn = {} - conn['Machine'] = cable_conn.md_item_name - conn['Machine Id'] = cable_conn.md_item.id - conn['Connector'] = cable_conn.md_connector_name - - conn_list.append(conn) - - - # Load Logs - if all: - logs = [] - item_logs = item_api.get_logs_for_item(item_id) - for i, item_log in enumerate(item_logs): - if log_limit != -1 and i == log_limit: - break - - log = {} - log['Id'] = item_log.id - log['Text'] = item_log.text - log["User"] = item_log.entered_by_username - if item_log.effective_from_date_time: - log['Date'] = item_log.effective_from_date_time.date() - else: - log['Date'] = item_log.entered_on_date_time.date() - - logs.append(log) - - result_obj["Logs"] = logs - header_style["Logs"] = 'yellow' - - # Load Properties - if all: - properties = [] - item_properties = item_api.get_properties_for_item(item_id) - for item_property in item_properties: - if item_property.property_type.is_internal: - continue - property = {} - property_type = item_property.property_type - property_type_name = property_type.name - property['Type'] = property_type_name - property['Tag'] = item_property.tag - property['Value'] = item_property.value - property['Description'] = item_property.description - - properties.append(property) - - result_obj["Properties"] = properties - header_style["Properties"] = "blue" - - if item.domain_id == factory.CABLE_DESIGN_DOMAIN_ID: - for item_property in item_properties: - if item_property.property_type.name == CABLE_DESIGN_PROPERTY_TYPE_NAME: - metadata_list = property_value_api.get_property_value_metadata(item_property.id) - for metadata in metadata_list: - if (metadata.metadata_value): - new_detail = {} - new_detail[metadata.metadata_key] = metadata.metadata_value - - item_details.append(new_detail) - - # Load Inventory & Catalog specific attributes - if item.domain_id == factory.CATALOG_DOMAIN_ID or item.domain_id == factory.CABLE_CATALOG_DOMAIN_ID: - inventories = [] - item_inventories = item_api.get_items_derived_from_item_by_item_id(item_id) - item_details.append({"# Inventory": len(item_inventories)}) - spare_ctr = 0 - mds = [] - __update_in_machine_list(item_api, item, mds) - add_all_inventory = inventory_mode == INVENTORY_FULL_OPT - - if item_inventories.__len__(): - inventory_domain_id = item_inventories[0].domain_id - inventory_domain = domain_api.get_domain_by_id(inventory_domain_id) - - for item_inventory in item_inventories: - __update_in_machine_list(item_api, item_inventory, mds) - status = item_api.get_item_status(item_inventory.id) - status_str = status.value - spare = False - if 'spare' in status_str.lower(): - spare = True - spare_ctr += 1 - - if all and (add_all_inventory or spare): - inventory = {} - inventory['Id'] = item_inventory.id - inventory['Tag'] = item_inventory.name - inventory['QrId'] = item_inventory.qr_id - id1_label = inventory_domain.item_identifier1_label - if id1_label: - inventory[id1_label] = item_inventory.item_identifier1 - inventory['Status'] = status_str - - inventory["Location"] = __get_inventory_location(item_api, item_inventory.id) - - inventories.append(inventory) - - item_details.append({'# Spare': spare_ctr}) - if item.domain_id == factory.CATALOG_DOMAIN_ID: - item_details.append({"# MD Occurrences": len(mds)}) - if all: - result_obj["Machine Occurrences"] = mds - header_style["Machine Occurrences"] = "magenta" - - if all: - result_obj["%s Inventory" % inventory_mode] = inventories - - # Load location specific attributes - if all and item.domain_id == factory.LOCATION_DOMAIN_ID: - inventory_items = location_api.get_inventory_located_here(item_id) - - items_here = [] - result_obj['Inventory Here'] = items_here - header_style['Inventory Here'] = "magenta" - - for inventory_item in inventory_items: - item_here = {} - item_here["Id"] = inventory_item.id - item_here['Item'] = __get_inventory_to_string(inventory_item) - domain = 'Inventory' - if inventory_item.domain_id == factory.CABLE_INVENTORY_DOMAIN_ID: - domain = "Cable %s" % domain - - item_here['Domain'] = domain - item_here['QrId'] = inventory_item.qr_id - items_here.append(item_here) - - return (result_obj, header_style) - -def wrap_cli_specific_click_options(include_id=True, help_addition=''): - def wrapper(f): - if include_id: - f=click.option( - "--id", - help="id of the item. %s" % help_addition, - )(f) - f=click.option( - "--qr", - help="qrid of the item. %s" % help_addition, - )(f) - - f=click.option( - "--all", - help="Display all data about the item. %s" % help_addition, - is_flag=True - )(f) - f=click.option( - "--inventory-mode", - type=click.Choice(INVENTORY_LIST_OPTS, case_sensitive=False), - default=INVENTORY_SPARES_OPT, - help="To be used with --all switch, the inventory list can be switched to a full list. %s" % help_addition - )(f) - f=click.option( - "--log-limit", - default=5, - help="How many logs can be shown at one time. Use -1 for all logs. %s" % help_addition - )(f) - return f - return wrapper - -@click.command(help=CMD_HELP_MESSAGE) -@wrap_cli_specific_click_options() -@click.option( - "--pager", - help="Scrolling of results similar to opening a text file with less.", - is_flag=True -) -@cliBase.wrap_print_format_cli_click_options -@click.pass_obj -def cdb_info(cli: CliBase, id, qr, pager, all, inventory_mode, log_limit, format): - """Fetch Info about an item in CDB - - Example: cdbInfo --id=123 [--pager] - """ - if not cli: - cli = CliBase() - - if id is not None and qr is not None: - raise click.UsageError("Only qrid or id can be specified not both.") - elif id is None and qr is None: - raise click.UsageError("Missing param, qrid or id must be specified.") - - console = Console() - with console.status("Loading Item Details...", spinner="aesthetic"): - factory = cli.require_api() - result_obj, header_style = cdbInfo_helper(factory=factory, - all=all, - item_id=id, - qr=qr, - inventory_mode=inventory_mode, - log_limit = log_limit, - console=console, - format=format) - - cliBase.print_results(console, result_obj, format, pager, header_style=header_style) - -def __get_machine_inventory_installed_in(item_api, item): - mds = [] - __update_in_machine_list(item_api, item, mds, include_md_item=True) - - if len(mds) == 1: - return mds[0]['md_item'] - return None - -def __update_in_machine_list(item_api: ItemApi, item, mds, include_md_item=False): - memberships = item_api.get_item_memberships(item.id) - - for membership in memberships: - part_item = membership.part_of_item - if part_item.domain_id == CdbApiFactory.MACHINE_DESIGN_DOMAIN_ID: - md_occurrence = {} - if include_md_item: - md_occurrence['md_item'] = part_item - md_occurrence['Machine'] = part_item.name - md_occurrence['Assigned Item'] = item.name - mds.append(md_occurrence) - -def __get_inventory_location(item_api, inventory_id): - location = item_api.get_item_location(inventory_id) - if location.housing_item: - housing_item = location.housing_item - if housing_item.derived_from_item: - return __get_inventory_to_string(housing_item) - else: - return housing_item.name - else: - return location.location_string - -def __get_inventory_to_string(inventory_item): - housing_format_str = "%s - [%s]" - return housing_format_str % (inventory_item.derived_from_item.name, inventory_item.name) - -if __name__ == '__main__': - cdb_info() - \ No newline at end of file diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/search.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/search.py deleted file mode 100755 index 5de903baf..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/search.py +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/env python3 - -from ast import Interactive -from tkinter.messagebox import NO -from unittest import case -import click - -from rich import print -from rich.table import Table -from rich.console import Console -from CdbApiFactory import CdbApiFactory -from cdbApi import ApiException - -from InquirerPy import inquirer -from InquirerPy.base.control import Choice -from cdbApi.api.search_api import SearchApi -from cdbApi.models.search_entities_options import SearchEntitiesOptions -from cdbApi.models.search_entities_results import SearchEntitiesResults -from cdbCli.common.cli import cliBase -from cdbCli.service.cli.cdbCliCmnds.info import cdbInfo_helper - -from cdbCli.common.cli.cliBase import CliBase - -CMD_HELP_MESSAGE="Seraches CDB for a list of matching components with options for domains and interacitve mode that automatically can fetch cdbInfo." - -DOMAIN_ALL_OPT = "All" -DOMAIN_CATALOG_OPT = "Catalog" -DOMAIN_CABLE_CATALOG_OPT = "Cable Catalog" -DOMAIN_INVENTORY_OPT = "Inventory" -DOMAIN_CABLE_INVENTORY_OPT = "Cable Inventory" -DOMAIN_MACHINE_OPT = "Machine Design" -DOMAIN_CABLE_DESIGN_OPT = "Cable Design" -DOMAIN_LOCATION_OPT = "Location" -DOMAIN_MAARC_OPT = "MAARC" - -DOMAIN_OPTS = [ - DOMAIN_ALL_OPT, - DOMAIN_CATALOG_OPT, - DOMAIN_CABLE_CATALOG_OPT, - DOMAIN_INVENTORY_OPT, - DOMAIN_CABLE_INVENTORY_OPT, - DOMAIN_MACHINE_OPT, - DOMAIN_CABLE_DESIGN_OPT, - DOMAIN_LOCATION_OPT, - DOMAIN_MAARC_OPT] - -RESULT_BACK_OPT = "Back" -RESULT_SELECT_OPT = "Select for details" -RESULT_SELECT_W_ALL_OPT = "Select for details /w all opt" -RESULT_RELOAD_OPT = "Reload" -RESULT_OPTS = [RESULT_BACK_OPT, RESULT_SELECT_OPT, RESULT_SELECT_W_ALL_OPT, RESULT_RELOAD_OPT] - -TABLE_STYLE = [None, 'green', 'magenta', 'cyan'] - -@cliBase.cli_command_api_exception_handler -def search_helper(factory: CdbApiFactory, console: Console, search_string, search_domain, format, pager=False): - with console.status("Waiting for search results...", spinner="aesthetic"): - search_api: SearchApi = factory.getSearchApi() - opts = SearchEntitiesOptions(search_text=search_string) - - if search_domain == DOMAIN_ALL_OPT: - opts.include_catalog = True - opts.include_inventory = True - opts.include_machine_design = True - opts.include_cable_catalog = True - opts.include_cable_design = True - opts.include_cable_inventory = True - opts.include_item_location = True - opts.include_maarc = True - if search_domain == DOMAIN_CATALOG_OPT: - opts.include_catalog = True - if search_domain == DOMAIN_INVENTORY_OPT: - opts.include_inventory = True - if search_domain == DOMAIN_MACHINE_OPT: - opts.include_machine_design = True - if search_domain == DOMAIN_CABLE_CATALOG_OPT: - opts.include_cable_catalog = True - if search_domain == DOMAIN_CABLE_INVENTORY_OPT: - opts.include_cable_inventory = True - if search_domain == DOMAIN_CABLE_DESIGN_OPT: - opts.include_cable_design = True - if search_domain == DOMAIN_LOCATION_OPT: - opts.include_item_location = True - if search_domain == DOMAIN_MAARC_OPT: - opts.include_maarc = True - - results: SearchEntitiesResults = search_api.search_entities(search_entities_options=opts) - - resulting_print_obj_list = {} - - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_CATALOG_OPT: - resulting_print_obj_list["%s Results" % DOMAIN_CATALOG_OPT] = create_search_results_printout( - result_list=results.item_domain_catalog_results, - factory=factory - ) - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_INVENTORY_OPT: - resulting_print_obj_list["%s Results" % DOMAIN_INVENTORY_OPT] = create_search_results_printout( - result_list=results.item_domain_inventory_results, - factory=factory - ) - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_MACHINE_OPT: - resulting_print_obj_list["%s Results" % DOMAIN_MACHINE_OPT] = create_search_results_printout( - result_list=results.item_domain_machine_design_results, - factory=factory - ) - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_CABLE_CATALOG_OPT: - resulting_print_obj_list["%s Results" % DOMAIN_CABLE_CATALOG_OPT] = create_search_results_printout( - result_list=results.item_domain_cable_catalog_results, - factory=factory - ) - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_CABLE_INVENTORY_OPT: - resulting_print_obj_list["%s Results" % DOMAIN_CABLE_INVENTORY_OPT] = create_search_results_printout( - result_list=results.item_domain_cable_inventory_results, - factory=factory - ) - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_CABLE_DESIGN_OPT: - resulting_print_obj_list["%s Results" % DOMAIN_CABLE_DESIGN_OPT] = create_search_results_printout( - result_list=results.item_domain_cable_design_results, - factory=factory - ) - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_LOCATION_OPT: - resulting_print_obj_list["%s Results" % DOMAIN_LOCATION_OPT] = create_search_results_printout( - result_list=results.item_domain_location_results, - factory=factory - ) - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_MAARC_OPT: - resulting_print_obj_list["%s Results" % DOMAIN_MAARC_OPT] = create_search_results_printout( - result_list=results.item_domain_maarc_results, - factory=factory - ) - - cliBase.print_results(console, resulting_print_obj_list, format, pager, table_style=TABLE_STYLE) - - return results - - -@click.command(help=CMD_HELP_MESSAGE) -@click.argument('search_string', required=False) -@click.option( - "--pager", - help="Scrolling of results similar to opening a text file with less.", - is_flag=True -) -@click.option( - "--interactive", - help="Allows user to interactively select domain and select item for details. '--search-domain' is ignored with this option.", - is_flag=True -) -@click.option( - "--search-domain", - default=None, - type=click.Choice(DOMAIN_OPTS, case_sensitive=False), - help="Domain to search.", -) -@cliBase.wrap_print_format_cli_click_options -@click.pass_obj -def cdb_search(cli: CliBase, search_string, search_domain, pager, interactive, format): - """Search CDB for items - - \b - * For multi-word entries enclose entry in "" - * Wildcard (?): Use ? as any single character - * Wildcard (*): Use * to capture any amount of characters - - - Example: cdbSearch ”” [--pager] - """ - if not cli: - cli = CliBase() - - console = Console() - factory = cli.require_api() - - if search_string is None: - search_string = inquirer.text("Search String:").execute() - - proceed = None - search_res = None - - while interactive: - if search_domain is None: - search_domain = inquirer.select( - message="Select search domain:", - choices=DOMAIN_OPTS + [ - Choice(value=None, name="Exit"), - ], - default=DOMAIN_ALL_OPT, - ).execute() - if search_domain is None: - exit() - - if search_res is None: - search_res : SearchEntitiesResults = search_helper(factory=factory, console=console, search_string=search_string, search_domain=search_domain, format=format, pager=pager) - - if isinstance(search_res, Exception): - # Standard CLI exception handled and displayed to user. - exit(1) - - if proceed is None: - proceed = inquirer.select( - message="Continue:", - choices=RESULT_OPTS + [Choice(value=None, name="Exit")], - default="Select Item for Details", - ).execute() - if proceed is None: - exit() - - if proceed == RESULT_BACK_OPT: - search_res = None - search_domain = None - proceed = None - continue - elif proceed == RESULT_RELOAD_OPT: - search_res = None - proceed = None - continue - - searched_items = [] - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_CATALOG_OPT: - cat_items = search_res.item_domain_catalog_results - searched_items += cat_items - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_INVENTORY_OPT: - inv_items = search_res.item_domain_inventory_results - searched_items += inv_items - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_MACHINE_OPT: - machine_items = search_res.item_domain_machine_design_results - searched_items += machine_items - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_CABLE_CATALOG_OPT: - items = search_res.item_domain_cable_catalog_results - searched_items += items - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_CABLE_INVENTORY_OPT: - items = search_res.item_domain_cable_inventory_results - searched_items += items - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_CABLE_DESIGN_OPT: - items = search_res.item_domain_cable_design_results - searched_items += items - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_LOCATION_OPT: - items = search_res.item_domain_location_results - searched_items += items - if search_domain == DOMAIN_ALL_OPT or search_domain == DOMAIN_MAARC_OPT: - items = search_res.item_domain_maarc_results - searched_items += items - - all_opt = proceed == RESULT_SELECT_W_ALL_OPT - - item_choices = [] - for item in searched_items: - choice = Choice(name=str(item.object_id) + "- " + item.object_name, - value=item) - item_choices.append(choice) - - prev_inx = 0 - next_inx = 0 - item_selection = None - - while True: - if item_selection is None: - next_inx = prev_inx + 8 - itr_choices = item_choices[prev_inx:next_inx] - prev_inx = next_inx - if next_inx >= item_choices.__len__(): - itr_choices.append(Choice(value=None, name="Start Over")) - prev_inx = 0 - next_inx = 0 - else: - itr_choices.append(Choice(value=None, name="Next Page")) - - item_selection = inquirer.rawlist( - message="Select Item:", - choices=itr_choices - ).execute() - - if item_selection is not None: - cdbInfo_result = cdbInfo_helper(cli=cli, console=console, item_id=item_selection.object_id, all=all_opt, pager=pager, format=format) - if isinstance(cdbInfo_result, Exception): - # Standard CLI exception handled and displayed to user. - exit(1) - else: - if search_domain == None: - search_domain = DOMAIN_ALL_OPT - search_helper(factory=factory, console=console, search_string=search_string, search_domain=search_domain, format=format, pager=pager) - -def create_search_results_printout(result_list, factory): - result_obj = [] - - for result in result_list: - obj = {} - url = factory.generateCDBUrlForItemId(result.object_id) - match: str = result.display - match = match.replace('; ', '\n') - - obj["Id"] = result.object_id - obj['Name'] = result.object_name - obj["Match Description"] = match - obj["URL"] = url - - result_obj.append(obj) - - return result_obj - -if __name__ == "__main__": - cdb_search() \ No newline at end of file diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemDetails.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemDetails.py deleted file mode 100755 index 3a9f98d7a..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemDetails.py +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import re -import csv -import click - -from cdbApi import ItemStatusBasicObject -from cdbApi import ApiException -from cdbCli.common.cli import cliBase - -from cdbCli.common.cli.cliBase import CliBase -from cdbCli.service.cli.cdbCliCmnds.setItemLogById import set_item_log_by_id_helper - - -def set_item_status_by_id_helper(item_api, prop_type_api, item_id, status): - """ - This function updates the status of a given item. - - :param item_api: item api object - :param prop_type_api: property type api - :param item_id: ID of the inventory item. Multiple IDs can be given as a comma separated string - :param status: new status of the item. Only statuses from "statusDict.json" are allowed as parameters - """ - - status_prop = prop_type_api.get_inventory_status_property_type() - - status_list = [ - status.to_dict()["value"] - for status in status_prop.sorted_allowed_property_value_list - ] - - if status == "?": - click.echo("Status Options:") - click.echo("----------------") - for stat in status_list: - click.echo(stat) - return - - try: - item_status = ItemStatusBasicObject(status=status) - - try: - item_api.update_item_status( - item_id=item_id, item_status_basic_object=item_status - ) - except ApiException as e: - p = r'"localizedMessage.*' - matches = re.findall(p, e.body) - if matches: - error = "Error updating status: " + matches[0][:-2] - click.echo(error) - else: - click.echo("Error updating status") - if status not in status_list: - click.echo("Please enter valid status from status list:") - for stat in status_list: - click.echo(stat) - exit(1) - - except KeyError: - click.echo("Error: invalid status entered") - - -################################################################################################ -# # -# Set new location of item # -# # -################################################################################################ -def set_item_details_helper( - item_api, prop_type_api, item_id, detail_type, new_detail_value, add_log_to_item=False -): - """ - This function updates fields on a CDB item - - :param item_api: Item Api object - :param prop_type_api: Property Type Api object - :param item_id: The ID of the inventory item - :param detail_type: The item field to be updated - :param new_detail_value: The new value for the field (will be cast to string) - """ - - try: - item = item_api.get_item_by_id(item_id) - if detail_type == "serial": - old_detail_value = item.item_identifier1 - item.item_identifier1 = str(new_detail_value) - item_api.update_item_details(item) - elif detail_type == "description": - old_detail_value = item.description - item.description = str(new_detail_value) - item_api.update_item_details(item) - elif detail_type == "status": - old_detail_value = item_api.get_item_status(item_id).value - set_item_status_by_id_helper( - item_api, prop_type_api, item_id, status=new_detail_value - ) - else: - click.echo("Invalid item property: " + detail_type) - return - except ApiException as e: - p = r'"localizedMessage.*' - matches = re.findall(p, e.body) - if matches: - error = ( - "ItemId:," - + str(item_id) - + " Error: Error updating detail: " - + matches[0][:-2] - ) - click.echo(error) - else: - response = "Item Id: " + str(item_id) + ", Old (" + detail_type + "): " - response += ( - str(old_detail_value) - + ", New (" - + detail_type - + "): " - + str(new_detail_value) - ) - if add_log_to_item: - set_item_log_by_id_helper(item_api, item_id, log_entry=response) - click.echo(response) - - -@click.command() -@click.option( - "--input-file", - help="Input csv file with id,new detail value, default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@click.option( - "--item-id-type", - default="id", - type=click.Choice(["id", "qr_id"], case_sensitive=False), - help="Allowed values are 'id'(default) or 'qr_id'", -) -@click.option( - "--detail-type", - default="description", - type=click.Choice(["description", "serial", "status"], case_sensitive=False), - help="Allowed values are description(default), 'serial', or 'status; ", -) -@cliBase.wrap_common_cli_click_options -@click.pass_obj -def set_item_details(cli, input_file, item_id_type, detail_type, add_log_to_item): - """Updates select item details (e.g. description, serial number ) - - \b - Example (file): set-item-details --input-file=filename.csv --item-id-type=qr_id --detail-type=description - Example (pipe): cat filename.csv | set-item-details -detail-type=serial - Example (terminal): set-item-details -detail-type=description - header - , - - Input is either through a named file or through STDIN. Default is STDIN - The format of the input data is - , where the ID is by the type specified by the commandline.""" - - try: - factory = cli.require_authenticated_api() - except ApiException: - click.echo("Unauthorized User/ Wrong Username or Password. Try again.") - return - - item_api = factory.getItemApi() - prop_type_api = factory.getPropertyTypeApi() - - stdin_msg = "Entry per line: ,<%s>" % (item_id_type, detail_type) - reader, stdin_tty_mode = cli.prepare_cli_input_csv_reader(input_file, stdin_msg) - - # Parse lines of csv - for row in reader: - if row.__len__() == 0 and stdin_tty_mode: - break - if not row[0]: - continue - - item_id = row[0] - new_detail_value = row[1] - - # Get ids if we were given QR Codes - if item_id_type == "qr_id": - item_id = str(item_api.get_item_by_qr_id(int(item_id)).id) - - set_item_details_helper( - item_api, prop_type_api, item_id, detail_type, new_detail_value, add_log_to_item - ) - - -if __name__ == "__main__": - set_item_details() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemLocation.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemLocation.py deleted file mode 100755 index 415c671b6..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemLocation.py +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import re -import csv -from tkinter.messagebox import NO -import click - -from cdbApi import SimpleLocationInformation -from cdbApi import ApiException -from rich import print -from cdbCli.common.cli import cliBase - -from cdbCli.common.cli.cliBase import CliBase -from cdbCli.service.cli.cdbCliCmnds.setItemLogById import set_item_log_by_id_helper - - -################################################################################################ -# # -# Set new location of item # -# # -################################################################################################ -def set_item_location_helper(item_api, item_id, location_id, add_log_to_item=False): - """ - This function sets a new location for a given inventory item - - :param item_api: Item Api object - :param item_id: The ID of the inventory item - :param location_id: The ID of the location item - """ - - # Note: Parameter validation is was done by click - - # Do not attempt to change a location if we are already at that location - current_location = item_api.get_item_location(item_id) - if current_location.location_item: - current_location_id = current_location.location_item.id - else: - current_location_id = -1 - - if current_location_id == location_id: - print( - "ItemId: " - + str(item_id) - + ", PriorLocationID: " - + str(current_location_id) - + ", NewLocationID: " - + str(location_id) - ) - return - # Get the new location and attempt to move the item to that location. - location = SimpleLocationInformation( - locatable_item_id=item_id, location_item_id=location_id - ) - try: - item_api.update_item_location(simple_location_information=location) - except ApiException as e: - p = r'"localizedMessage.*' - matches = re.findall(p, e.body) - if matches: - error = ( - "ItemId: ," - + str(item_id) - + ". Error: Error updating location: " - + matches[0][:-2] - ) - print(error) - else: - if add_log_to_item: - log = ( - "ItemId: " - + str(item_id) - + ", PriorLocationID: " - + str(current_location_id) - + ", NewLocationID: " - + str(location_id) - ) - set_item_log_by_id_helper(item_api=item_api, item_id=item_id, log_entry=log) - - -@click.command() -@click.option( - "--input-file", - help="Input csv file with id,location, default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@click.option( - "--item-id-type", - default="id", - type=click.Choice(["id", "qr_id"], case_sensitive=False), - help="Allowed values are 'id'(default) or 'qr_id'", -) -@click.option( - "--location-id-type", - default="name", - type=click.Choice(["name", "id", "qr_id"], case_sensitive=False), - help="Allowed values are name(default) 'id' or 'qr_id'", -) -@cliBase.wrap_common_cli_click_options -@click.pass_obj -def set_item_location(cli, input_file, item_id_type, location_id_type, add_log_to_item): - """Set new location for single or multiple items. Locations can be specified - with ids(default) or QRCodes and locations can be specified by name(default), - QRCodes or ids. - - \b - Example (file): set-item-location --input-file=filename.csv --item-id-type=qr_id --location-id-type=name - Example (pipe): cat filename.csv | set-item-location --location-id-type=id - Example (terminal): set-item-location --location-id-type=qr_id - header - , - - Input is either through a named file or through STDIN. Default is STDIN - The format of the input data is - , where the ID is by the type specified by the commandline.""" - - try: - factory = cli.require_authenticated_api() - except ApiException: - click.echo("Unauthorized User/ Wrong Username or Password. Try again.") - return - - item_api = factory.getItemApi() - location_item_api = factory.getLocationItemApi() - - stdin_msg = "Entry per line: ," % (item_id_type, location_id_type) - reader, stdin_tty_mode = cli.prepare_cli_input_csv_reader(input_file, stdin_msg) - - # Parse lines of csv - for row in reader: - if row.__len__() == 0 and stdin_tty_mode: - break - item_id = row[0] - location_id = row[1] - - # Get ids if we were given QR Codes - if item_id_type == "qr_id": - item_id = str(item_api.get_item_by_qr_id(int(item_id)).id) - - # Get the location id if we are given the qr code - if location_id_type == "qr_id": - location_id = item_api.get_item_by_qr_id(int(location_id)).id - if location_id_type == "name": - location_id = location_item_api.get_location_items_by_name(location_id)[ - 0 - ].id - - set_item_location_helper(item_api, item_id, location_id, add_log_to_item) - - -if __name__ == "__main__": - set_item_location() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemLogById.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemLogById.py deleted file mode 100755 index 4f4774163..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemLogById.py +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import re -import click -import csv - -from cdbApi import LogEntryEditInformation -from cdbApi import ApiException -from datetime import datetime - -from cdbCli.common.cli.cliBase import CliBase - - -############################################################################################## -# # -# Add log to item given the item's ID # -# # -############################################################################################## -def set_item_log_by_id_helper(item_api, item_id, log_entry, effective_date=None): - """Helper function to set a log for an item in CDB - - :param item_api: Necessary item api object - :param item_id: item ID of the object which the log is being written for - :param log_entry: the log entry to be written - :param effective_date: optional date of log""" - - if effective_date: - effective_date = datetime.strptime(effective_date, "%Y-%m-%d") - try: - log_entry_obj = LogEntryEditInformation( - item_id=item_id, log_entry=log_entry, effective_date=effective_date - ) - item_api.add_log_entry_to_item(log_entry_edit_information=log_entry_obj) - except ApiException as e: - p = r'"localizedMessage.*' - matches = re.findall(p, e.body) - if matches: - error = "Error uploading log entry: " + matches[0][:-2] - click.echo(error) - else: - click.echo("Error uploading log entry") - exit(1) - - -@click.command() -@click.option( - "--input-file", - help="Input csv file with item_id,log_data,effective_date default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@click.option( - "--effective-date", is_flag=True, help="Set if effective date is listed in input" -) -@click.pass_obj -def set_item_log_by_id(cli, input_file, effective_date=False): - """Adds a log entry to the given item ids with optional effective date - - \b - Example (file): set-item-log-by-id --input-file filename.csv --effective-date='yes' - Example (pipe): cat filename.csv | set-item-log-by-id - Example (terminal): set-item-log-by-id - header - , - Input is either through a named csv file or through STDIN. Default is STDIN - The format of the input data is an intended row to be removed followed by - ,,. - """ - - try: - factory = cli.require_authenticated_api() - except ApiException: - click.echo("Unauthorized User/ Wrong Username or Password. Try again.") - return - - item_api = factory.getItemApi() - - stdin_msg = "Entry per line: ," - reader, stdin_tty_mode = cli.prepare_cli_input_csv_reader(input_file, stdin_msg) - - # Parse lines of csv - for row in reader: - if row.__len__() == 0 and stdin_tty_mode: - break - if not row[0]: - continue - - item_id = row[0] - log_entry = row[1] - if effective_date: - effective_date = row[2] - else: - effective_date = None - - set_item_log_by_id_helper(item_api, item_id, log_entry, effective_date) - - -if __name__ == "__main__": - set_item_log_by_id() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemStatusById.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemStatusById.py deleted file mode 100755 index 0b30976e8..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setItemStatusById.py +++ /dev/null @@ -1,118 +0,0 @@ -#!/usr/bin/env python3 - -import re -import click -import csv -import sys - -from cdbApi import ItemStatusBasicObject -from cdbApi import ApiException - - -############################################################################################## -# # -# Update item status given item ID # -# # -############################################################################################## -def set_item_status_by_id_helper(item_api, prop_type_api, item_id, status): - """ - This function updates the status of a given item. - - :param item_api: item api object - :param prop_type_api: property type api - :param item_id: ID of the inventory item. Multiple IDs can be given as a comma separated string - :param status: new status of the item. Only statuses from "statusDict.json" are allowed as parameters - """ - - status_prop = prop_type_api.get_inventory_status_property_type() - - status_list = [ - status.to_dict()["value"] - for status in status_prop.sorted_allowed_property_value_list - ] - - if status == "?": - click.echo("Status Options:") - click.echo("----------------") - for stat in status_list: - click.echo(stat) - return - - try: - item_status = ItemStatusBasicObject(status=status) - - try: - item_api.update_item_status( - item_id=item_id, item_status_basic_object=item_status - ) - except ApiException as e: - p = r'"localizedMessage.*' - matches = re.findall(p, e.body) - if matches: - error = "Error updating status: " + matches[0][:-2] - click.echo(error) - else: - click.echo("Error updating status") - if status not in status_list: - click.echo("Please enter valid status from status list:") - for stat in status_list: - click.echo(stat) - exit(1) - - except KeyError: - click.echo("Error: invalid status entered") - - -@click.command() -@click.option( - "--input-file", - help="Input csv file with item id default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@click.option( - "--status", - required=True, - prompt="Item Status", - help="New Status of Item", -) -@click.pass_obj -def set_item_status_by_id(cli, input_file, status): - """Updates item status of item with the given ID and updates item log - - \b - Example (file): set-item-status-by-id --input-file filename.csv --status='Ready For Use' - Example (pipe): cat filename.csv | set-item-status-by-id --status="Planned" - Example (terminal): set-item-status-by-id --status="Planned" - header - - Input is either through a named csv file or through STDIN. Default is STDIN - The format of the input data is an intended row to be removed followed by - . - """ - try: - factory = cli.require_authenticated_api() - except ApiException: - click.echo("Unauthorized User/ Wrong Username or Password. Try again.") - return - - item_api = factory.getItemApi() - prop_type_api = factory.getPropertyTypeApi() - - stdin_msg = "Entry per line: " - reader, stdin_tty_mode = cli.prepare_cli_input_csv_reader(input_file, stdin_msg) - - # Parse lines of csv - for row in reader: - if row.__len__() == 0 and stdin_tty_mode: - break - if not row[0]: - continue - - item_id = row[0] - - set_item_status_by_id_helper(item_api, prop_type_api, item_id, status) - - -if __name__ == "__main__": - set_item_status_by_id() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setMachineInstallState.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setMachineInstallState.py deleted file mode 100644 index 509b91edd..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setMachineInstallState.py +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import csv -import click - -from cdbApi import UpdateMachineAssignedItemInformation -from cdbApi import ApiException - -from rich import print -from cdbCli.common.cli import cliBase - -from cdbCli.common.cli.cliBase import CliBase -from cdbCli.service.cli.cdbCliCmnds.setItemLogById import set_item_log_by_id_helper - -################################################################################################ -# # -# Set status if machine child matches item code # -# # -################################################################################################ -def set_machine_install_state_helper( - factory, item_api, machine_api, machine_item, item_id, install_state, stdout_done=False, add_item_log=False -): - - machine_item_child_id = machine_item.assigned_item.id - if machine_item_child_id == int(item_id): - try: - machine_item_info = UpdateMachineAssignedItemInformation( - md_item_id=machine_item.id, - assigned_item_id=item_id, - is_installed=install_state, - ) - machine_api.update_assigned_item(machine_item_info) - - if stdout_done: - print("Updated machine %s for assigned item %s to install state %r" % (machine_item.name, item_id, install_state)) - except ApiException as ex: - exObj = factory.parseApiException(ex) - raise Exception("%s - %s" % (exObj.simple_name, exObj.message)) - else: - if add_item_log: - log = ( - "Machine item: " - + str(machine_item.id) - + " has install state set to " - + str(install_state) - ) - set_item_log_by_id_helper( - item_api=item_api, item_id=machine_item.id, log_entry=log - ) - else: - print( - "Machine item %s assigned item is not %s." - % (str(machine_item.name), item_id) - ) - - -@click.command() -@click.option( - "--input-file", - help="Input csv file with design_name,qr_id default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@click.option( - "--item-id-type", - default="id", - type=click.Choice(["id", "qr_id"], case_sensitive=False), - help="Assigned item identifier provided.", -) -@click.option( - "--installed", - is_flag=True, - help="Add this switch to set items as installed otherwise it defaults to planned.", -) -@cliBase.wrap_common_cli_click_options -@click.pass_obj -def set_machine_install_state(cli, input_file, item_id_type, installed, add_log_to_item=False): - """Set new status for items if id matches child of machine design, otherwise print mismatch - to console. Id is specified by type - - \b - Example (file): set-machine-install-statuses --input-file filename.csv --item-id-type=qr_id - Example (stdin): cat filename.csv | set-machine-install-statuses - Example (terminal): set-machine-install-statuses - ,>""" - """ - - Input is either through a named csv file or through STDIN. Default is STDIN - The format of the input data is an intended row to be removed followed by - , where the ID is by the type specified by the command line.""" - - try: - factory = cli.require_authenticated_api() - except ApiException: - print("Unauthorized User/ Wrong Username or Password. Try again.") - return - - item_api = factory.getItemApi() - machine_api = factory.getMachineDesignItemApi() - - stdin_msg = "Entry per line: ," % item_id_type - reader, stdin_tty_mode = cli.prepare_cli_input_csv_reader(input_file, stdin_msg) - - # Parse machine design names and item codes - for row in reader: - if row.__len__() == 0 and stdin_tty_mode: - break - if not row[0]: - continue - - design_name = row[0] - item_id = row[1] - - try: - results = machine_api.get_machine_design_items_by_name( - design_name - ) - - if results.__len__() > 1: - print("Skipping machine %s, found %d results with this name" % (design_name, results.__len__()), file=sys.stderr) - continue - machine_item = results[0] - - # Get ids if we were given QR Codes - if item_id_type == "qr_id": - item_id = str(item_api.get_item_by_qr_id(int(item_id)).id) - except ApiException as ex: - exObj = factory.parseApiException(ex) - print("Skipping machine %s, an error ocurred. %s" % (design_name, exObj.message), file=sys.stderr) - continue - - # Update corresponding item statuses, or print potential mismatch - set_machine_install_state_helper( - factory, item_api, machine_api, machine_item, - item_id, installed, stdout_done=True, add_item_log=add_log_to_item - ) - - -if __name__ == "__main__": - set_machine_install_state() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setParentLocation.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setParentLocation.py deleted file mode 100755 index ae18941d4..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setParentLocation.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import re -import csv -import click - -from cdbApi import ApiException -from cdbCli.common.cli import cliBase - -from cdbCli.common.cli.cliBase import CliBase -from cdbCli.service.cli.cdbCliCmnds.setItemLogById import set_item_log_by_id_helper - - -################################################################################################ -# # -# Set new location of item # -# # -################################################################################################ -def set_parent_location_helper( - item_api, - loc_item_api, - location_item_id, - parent_item_id, - add_log_to_item=False -): - """ - This function updates the parent location for a child location. - - :param item_api: Item Api Object - :param loc_item_api: Location Item Api Object - :param location_item_id: The ID of the Parent - :param parent_item_id : New Parent - """ - - try: - loc_item_api.update_location_parent(location_item_id, parent_item_id) - except Exception as e: - click.echo("Error with updating location of item: " + str(location_item_id)) - else: - if add_log_to_item: - log = ( - "Location item: " - + str(location_item_id) - + " has parent updated to " - + str(parent_item_id) - ) - set_item_log_by_id_helper( - item_api=item_api, item_id=location_item_id, log_entry=log - ) - - -@click.command() -@click.option( - "--input-file", - help="Input csv file with new location parameters, see help, default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@click.option( - "--item-id-type", - default="id", - type=click.Choice(["id", "qr_id"], case_sensitive=False), - help="Allowed values are 'id'(default) or 'qr_id'", -) -@cliBase.wrap_common_cli_click_options -@click.pass_obj -def set_parent_location(cli, input_file, item_id_type, add_log_to_item): - """Essentially moves one location under another (parent). - - \b - Example (file): set-parent-location --input-file=filename.csv --item-id-type=qr_id - Example (pipe): cat filename.csv | set-parent-location - Example (terminal): set-parent-location - header - , - - CSV input is on STDIN(default) or a file and the csv format is - File has the format - ,""" - - try: - factory = cli.require_authenticated_api() - except ApiException: - click.echo("Unauthorized User/ Wrong Username or Password. Try again.") - return - - item_api = factory.getItemApi() - loc_item_api = factory.getLocationItemApi() - - stdin_msg = "Entry per line: ," % (item_id_type) - reader, stdin_tty_mode = cli.prepare_cli_input_csv_reader(input_file, stdin_msg) - - # Parse lines of csv - for row in reader: - if row.__len__() == 0 and stdin_tty_mode: - break - if not row[0]: - continue - - loc_id = int(row[0]) - parent_id = int(row[1]) - - # Get ids if we were given QR Codes - if item_id_type == "qr_id": - loc_id = item_api.get_item_by_qr_id(int(loc_id)).id - - set_parent_location_helper(item_api, loc_item_api, loc_id, parent_id, add_log_to_item) - - -if __name__ == "__main__": - set_parent_location() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setPropertiesAndMetadata.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setPropertiesAndMetadata.py deleted file mode 100755 index abb6cda89..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setPropertiesAndMetadata.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python -# coding: utf-8 -import datetime -from mailbox import NotEmptyError -import re -import sys -import click -from datetime import date - -import pandas as pd -import sqlite3 -from rich import print -from rich.traceback import install - - -from CdbApiFactory import CdbApiFactory -from cdbCli.common.cli.cliBase import CliBase - - -@click.command() -@click.option("--dist", help="Change the CDB distribution (as provided in cdb.conf)") -@click.option( - "--inputfile", - help="Input csv file with item info and properties, default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@click.option( - "--changemeta", - type=click.Choice(["yes","echo","no"]), - default="echo", -) -@click.option( - "--changeproperty", - type=click.Choice(["yes","echo","no"]), - default="echo", -) -@click.option( - "--remove/--no-remove", - default=False -) - - - -def set_properties(inputfile, changemeta, changeproperty, remove, dist=None): - """Takes a headered csv file with change data for the properties. The format is the same as - what comes out of get_properties. Execute that first and then edit the - resulting csv file for the new property data. - - Set the remove flag to remove the property in its entirety - """ - - install() - cli = CliBase(dist) - factory = cli.require_authenticated_api() - itemApi = factory.getItemApi() - cableCatalogApi = factory.getCableCatalogItemApi() - propValueApi = factory.getPropertyValueApi() - - df = pd.read_csv(inputfile) - for i,row in df.iterrows(): - item_dict = {} - prop_dict = {} - meta_dict = {} - for itemkey in row.keys(): - if not pd.isnull(row[itemkey]) and "Item_" in itemkey: - dictkey = itemkey.replace("Item_","") - item_dict[dictkey.lower()] = row[itemkey] - if not pd.isnull(row[itemkey]) and "Prop_" in itemkey: - dictkey = itemkey.replace("Prop_","") - prop_dict[dictkey.lower()] = row[itemkey] - if not pd.isnull(row[itemkey]) and "Meta_" in itemkey: - dictkey = itemkey.replace("Meta_","") - meta_dict[dictkey.lower()] = row[itemkey] - # We now have three dictionaries parsed if they pertain to the - # Item, Properties, and Metadata Properties - # Lets do some echoing if we need it - # First lets change the properties - # Also, we scan the property listand look for the same ID - # as in the prop ID. The combination of the item id and property - # id identify the property that we are going to change. - properties = itemApi.get_properties_for_item(item_dict["id"]) - for prop in properties: - if prop.id == int(prop_dict["id"]): - if remove == True: - changeproperty == "no" - changemeta == "no" - propValueApi.delete_property_by_id(prop.id) - if changeproperty == "yes": - if "value" in prop_dict.keys(): - prop.value = prop_dict["value"] - if "description" in prop_dict.keys(): - prop.description = prop_dict["description"] - if "units" in prop_dict.keys(): - prop.units = prop_dict["units"] - if "tag" in prop_dict.keys(): - prop.tag = prop_dict["tag"] - if "display_value" in prop_dict.keys(): - prop.display_value = prop_dict["display_value"] - try: - result = itemApi.update_item_property_value(item_dict["id"],property_value=prop) - except Exception as e: - print(e) - elif changeproperty == "echo": - print(prop_dict) - if changemeta == "yes": - metadata = propValueApi.get_property_value_metadata(prop.id) - for metadata_element in metadata: - # If the metadata key lowered is in our meta dictionary, then change - # the value and submit the key. - if metadata_element.metadata_key.lower() in meta_dict.keys(): - metadata_element.metadata_value = str(meta_dict[metadata_element.metadata_key.lower()]) - try: - result = itemApi.update_item_property_metadata(item_dict["id"],prop.id,property_metadata=metadata_element) - except Exception as e: - print(e) - elif changemeta == "echo": - print(meta_dict) - - -if __name__ == "__main__": - set_properties() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setQrIdById.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setQrIdById.py deleted file mode 100755 index 04eb4ac67..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/setQrIdById.py +++ /dev/null @@ -1,112 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import csv -import re -import click - -from cdbApi import ApiException -from cdbCli.common.cli import cliBase - - -from cdbCli.common.cli.cliBase import CliBase -from cdbCli.service.cli.cdbCliCmnds.setItemLogById import set_item_log_by_id_helper - - -############################################################################################## -# # -# Set the QR ID of an inventory item # -# # -############################################################################################## -def set_qr_id_by_id_helper(item_api, item_id, qr_id, add_log_to_item): - """ - This function sets a new QR ID for a given item - - :param item_api: Item Api object - :param item_id: The ID of the item - :param qr_id: The new desired QR ID of the item - """ - - try: - item = item_api.get_item_by_id(item_id) - - old_qr_id = str(item.qr_id) - item.qr_id = int(qr_id) - item_api.update_item_details(item=item) - - except ApiException as e: - p = r'"localizedMessage.*' - matches = re.findall(p, e.body) - if matches: - error = "Error setting QR ID: " + matches[0][:-2] - print(error) - else: - print("Error setting QR ID") - else: - if str(item.qr_id) == old_qr_id: - print( - "Item ID: " + str(item_id) + " has unchanged QR ID: " + str(item.qr_id) - ) - else: - echo_string = ( - "Item ID: " - + str(item_id) - + ", Old QRId: " - + str(old_qr_id) - + ", New QRId: " - + str(qr_id) - ) - if add_log_to_item: - set_item_log_by_id_helper( - item_api=item_api, item_id=item_id, log_entry=echo_string - ) - print(echo_string) - - -@click.command() -@click.option( - "--input-file", - help="Input csv file with new location parameters, see help, default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@cliBase.wrap_common_cli_click_options -@click.pass_obj -def set_qr_id_by_id(cli, input_file, add_log_to_item): - """Assigns QR Ids to a set of Item IDs. This will overwrite QR Codes if already assigned, but - but throws an error if new QR Code is already assigned. - - \b - Example (file): set-qr-id-by-id --input-file=filename.csv - Example (pipe): cat filename.csv | set-qr-id-by-id - Example (terminal): set-qr-id-by-id - header - , - - CSV input is on STDIN(default) or a file and the csv format is - ,""" - - try: - factory = cli.require_authenticated_api() - item_api = factory.getItemApi() - except ApiException: - click.echo("Unauthorized User/ Wrong Username or Password. Try again.") - return - - stdin_msg = "Entry per line: ," - reader, stdin_tty_mode = cli.prepare_cli_input_csv_reader(input_file, stdin_msg) - - # Parse lines of csv - for row in reader: - if row.__len__() == 0 and stdin_tty_mode: - break - if not row[0]: - continue - - item_id = row[0] - qr_id = row[1] - set_qr_id_by_id_helper(item_api, item_id, qr_id, add_log_to_item) - - -if __name__ == "__main__": - set_qr_id_by_id() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/updateHierarchy.py b/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/updateHierarchy.py deleted file mode 100755 index 50b7bd418..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cdbCliCmnds/updateHierarchy.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import re -import csv -import click - -from rich import print -from cdbApi import ApiException -from cdbCli.common.cli import cliBase - -from cdbCli.common.cli.cliBase import CliBase -from cdbCli.service.cli.cdbCliCmnds.setItemLogById import set_item_log_by_id_helper - - -################################################################################################ -# # -# Update Assembly Part Name # -# # -################################################################################################ -def update_item_assembly_help(item_api, item_id, part_name, assigned_item_id, add_log_to_item): - """ - This function updates fields on a CDB item - - :param item_api: Item Api object - :param item_id: The ID of the inventory item assembly - :param part_name: The part_name of the field for the assignment - :param assigned_item_id: The id of the item to be assigned to the part_name - """ - try: - item = item_api.get_item_by_id(item_id) - item_hierarchy = item_api.get_item_hierarchy_by_id(item_id) - element_dict = { - item_hierarchy.child_items[i] - .derived_element_name: item_hierarchy.child_items[i] - .element_id - for i in range(len(item_hierarchy.child_items)) - } - item_hierarchy_after_assignment = item_api.update_contained_item( - element_dict[part_name], assigned_item_id - ) - element_dict_after = { - item_hierarchy_after_assignment.child_items[i] - .derived_element_name: item_hierarchy_after_assignment.child_items[i] - .element_id - for i in range(len(item_hierarchy_after_assignment.child_items)) - } - print( - "ItemId: " - + str(item_id) - + ", New (" - + part_name - + "): " - + str(element_dict_after[part_name]) - ) - except ApiException as e: - p = r'"localizedMessage.*' - matches = re.findall(p, e.body) - if matches: - print( - "ItemId:," - + str(item_id) - + "Error:Error updating assembly item: " - + matches[0][:-2] - ) - else: - if add_log_to_item: - log = ( - "ItemId: " - + str(item_id) - + ", New (" - + part_name - + "): " - + str(element_dict_after[part_name]) - ) - set_item_log_by_id_helper(item_api=item_api, item_id=item_id, log_entry=log) - - -@click.command() -@click.option( - "--input-file", - help="Input csv file with id, part name, assigned id. default is STDIN", - type=click.File("r"), - default=sys.stdin, -) -@click.option( - "--item-id-type", - default="id", - type=click.Choice(["id", "qr_id"], case_sensitive=False), - help="Allowed values are 'id'(default) or 'qr_id'", -) -@click.option( - "--assigned-item-id-type", - default="id", - type=click.Choice(["id", "qr_id"], case_sensitive=False), - help="Allowed values are 'id'(default) or 'qr_id'", -) -@cliBase.wrap_common_cli_click_options -@click.pass_obj -def update_hierarchy(cli, input_file, item_id_type, assigned_item_id_type, add_log_to_item): - """Updates item hierarchy (e.g. assemblies) - - \b - Example (file): update-hierarchy --input-file=filename.csv --item-id-type=qr_id --assigned-id-type=qr_id - Example (pipe): cat filename.csv | update-hierarchy - Example (terminal): update-hierarchy --location-id-type=qr_id - header - ,, - - Input is either through a named file or through STDIN. Default is STDIN - The format of the input data is - ,, where the ID is by the type specified by the commandline.""" - try: - factory = cli.require_authenticated_api() - except ApiException: - click.echo("Unauthorized User/ Wrong Username or Password. Try again.") - return - - item_api = factory.getItemApi() - - stdin_msg = "Entry per line: ,," % (item_id_type, assigned_item_id_type) - reader, stdin_tty_mode = cli.prepare_cli_input_csv_reader(input_file, stdin_msg) - - # Parse lines of csv - for row in reader: - if row.__len__() == 0 and stdin_tty_mode: - break - item_id = row[0] - part_name = row[1] - assigned_item_id = row[2] - - # Get ids if we were given QR Codes. - if item_id_type == "qr_id": - item_id = str(item_api.get_item_by_qr_id(int(item_id)).id) - if assigned_item_id_type == "qr_id": - assigned_item_id = item_api.get_item_by_qr_id(int(assigned_item_id)).id - - update_item_assembly_help(item_api, item_id, part_name, assigned_item_id, add_log_to_item) - - -if __name__ == "__main__": - update_hierarchy() diff --git a/tools/developer_tools/python-client/cdbCli/service/cli/cli.py b/tools/developer_tools/python-client/cdbCli/service/cli/cli.py deleted file mode 100755 index e03e1fe47..000000000 --- a/tools/developer_tools/python-client/cdbCli/service/cli/cli.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 - -import click - -from cdbCli.common.cli.cliBase import CliBase - -from cdbCli.service.cli.cdbCliCmnds.addDocumentFile import add_document_file -from cdbCli.service.cli.cdbCliCmnds.addDocumentProperty import add_document_property -from cdbCli.service.cli.cdbCliCmnds.cdb_log_to_mqtt import cdb_log_to_mqtt -from cdbCli.service.cli.cdbCliCmnds.createLocation import create_location -from cdbCli.service.cli.cdbCliCmnds.getCatalogItemsByName import get_catalog_items_by_name -from cdbCli.service.cli.cdbCliCmnds.getLocationIdByName import get_location_id_by_name -from cdbCli.service.cli.cdbCliCmnds.getProperties import get_properties -from cdbCli.service.cli.cdbCliCmnds.info import cdb_info -from cdbCli.service.cli.cdbCliCmnds.search import cdb_search -from cdbCli.service.cli.cdbCliCmnds.setItemDetails import set_item_details -from cdbCli.service.cli.cdbCliCmnds.setItemLocation import set_item_location -from cdbCli.service.cli.cdbCliCmnds.setItemLogById import set_item_log_by_id -from cdbCli.service.cli.cdbCliCmnds.setItemStatusById import set_item_status_by_id -from cdbCli.service.cli.cdbCliCmnds.setMachineInstallState import set_machine_install_state -from cdbCli.service.cli.cdbCliCmnds.setParentLocation import set_parent_location -from cdbCli.service.cli.cdbCliCmnds.setPropertiesAndMetadata import set_properties -from cdbCli.service.cli.cdbCliCmnds.setQrIdById import set_qr_id_by_id -from cdbCli.service.cli.cdbCliCmnds.updateHierarchy import update_hierarchy -from cdbCli.service.cli.cdbCliCmnds.addProperty import add_property - -class AliasedGroup(click.Group): - - def get_command(self, ctx, cmd_name): - rv = click.Group.get_command(self, ctx, cmd_name) - if rv is not None: - return rv - matches = [x for x in self.list_commands(ctx) - if x.startswith(cmd_name)] - if not matches: - return None - elif len(matches) == 1: - return click.Group.get_command(self, ctx, matches[0]) - ctx.fail('Too many matches: %s' % ', '.join(sorted(matches))) - - -@click.group(cls=AliasedGroup) -@click.option("--dist", help="Change the CDB distribution (as provided in cdb.conf)") -@click.pass_context -def entry_point(ctx, dist=None): - ctx.obj = CliBase(dist) - -def main(): - entry_point.add_command(cdb_search) - entry_point.add_command(cdb_info) - entry_point.add_command(add_document_file) - entry_point.add_command(add_document_property) - entry_point.add_command(add_property) - entry_point.add_command(cdb_log_to_mqtt) - entry_point.add_command(create_location) - entry_point.add_command(get_catalog_items_by_name) - entry_point.add_command(get_location_id_by_name) - entry_point.add_command(get_properties) - entry_point.add_command(set_item_details) - entry_point.add_command(set_item_location) - entry_point.add_command(set_item_log_by_id) - entry_point.add_command(set_item_status_by_id) - entry_point.add_command(set_machine_install_state) - entry_point.add_command(set_parent_location) - entry_point.add_command(set_properties) - entry_point.add_command(set_qr_id_by_id) - entry_point.add_command(update_hierarchy) - - entry_point() - - -if __name__ == "__main__": - main() diff --git a/tools/developer_tools/python-client/conda-recipe/API/conda-build.sh b/tools/developer_tools/python-client/conda-build.sh similarity index 50% rename from tools/developer_tools/python-client/conda-recipe/API/conda-build.sh rename to tools/developer_tools/python-client/conda-build.sh index 1f6e086ad..6f654d0e7 100755 --- a/tools/developer_tools/python-client/conda-recipe/API/conda-build.sh +++ b/tools/developer_tools/python-client/conda-build.sh @@ -1,8 +1,5 @@ #!/bin/bash -MY_DIR=`dirname $0` && cd $MY_DIR && MY_DIR=`pwd` -ROOT_DIR=$MY_DIR - ENV_NAME=bely-api-env CONDA_DIR=$CONDA_PREFIX_1 echo $CONDA_DIR @@ -20,31 +17,37 @@ fi source $CONDA_DIR/etc/profile.d/conda.sh || exit 1 -# Prepare build source. -rm -rf src -mkdir src -ln -s ../../../generatePyClient.sh src/ -cp ../../BelyApiFactory.py src/ -cp ../../setup-api.py src/setup.py -cp ../../ClientApiConfig.yml src/ -## Clean up and build new version of bely api -./src/generatePyClient.sh $1 || exit 1 +# Default URL for generating updated API +DEFAULT_URL="http://localhost:8080/bely" + +# Check if the first argument is provided, otherwise use the default URL +URL=${1:-$DEFAULT_URL} + +# Output the URL being used +echo "Generating updated APIs using URL: $URL" + +./generatePyClient.sh $URL + +if [ $? -ne 0 ]; then + echo "Generating API failed. Exiting." + exit 1 +fi # Clean and Build -rm -rvf ./build -conda build . --output-folder ./build || exit 1 +rm -rf ./build + +# Build API +conda build conda-recipe/API --output-folder ./build || exit 1 -# Install build into a new env +# Install build into a new env conda create -n $ENV_NAME -y || exit 1 conda activate $ENV_NAME || exit 1 -conda install BELY-API -c ./build -y || exit 1 +conda install bely-api -c ./build -y || exit 1 #Export conda list -n $ENV_NAME --explicit > $ENV_NAME.txt echo "Please use the c2 tool to upload the $ENV_NAME.txt" -# Clean up conda activate conda env remove -n $ENV_NAME -rm -rf src \ No newline at end of file diff --git a/tools/developer_tools/python-client/conda-recipe/API/build.sh b/tools/developer_tools/python-client/conda-recipe/API/build.sh deleted file mode 100644 index 2ec8aeaa3..000000000 --- a/tools/developer_tools/python-client/conda-recipe/API/build.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env bash - -pip install . diff --git a/tools/developer_tools/python-client/conda-recipe/API/meta.yaml b/tools/developer_tools/python-client/conda-recipe/API/meta.yaml index cf0bdb9e9..3587ac99d 100644 --- a/tools/developer_tools/python-client/conda-recipe/API/meta.yaml +++ b/tools/developer_tools/python-client/conda-recipe/API/meta.yaml @@ -1,23 +1,23 @@ -{% set name = "BELY-API" %} +{% set name = "bely-api" %} {% set version = "2026.3.0" %} package: - name: "{{ name|lower }}" + name: "{{ name|lower }}" version: "{{ version }}" source: - path: ./src + path: ../../packages/api build: number: 0 noarch: python - + script: {{ PYTHON }} -m pip install . --no-deps --no-build-isolation requirements: - build: + host: + - python>=3.10 + - hatchling - pip - - python>3.10 - - setuptools run: - python - python-dateutil @@ -30,6 +30,6 @@ test: - BelyApiFactory about: - home: "https://git.aps.anl.gov/controls/hla/bely" - license: "Copyright (c) UChicago Argonne, LLC. All rights reserved." - summary: "Library Containing Component DB APIs" + home: "https://github.com/AdvancedPhotonSource/BELY" + license: "Copyright (c) UChicago Argonne, LLC. All rights reserved." + summary: "Python client API library used to communicate with BELY API." diff --git a/tools/developer_tools/python-client/conda-recipe/CLI/meta.yaml b/tools/developer_tools/python-client/conda-recipe/CLI/meta.yaml deleted file mode 100644 index 84645f803..000000000 --- a/tools/developer_tools/python-client/conda-recipe/CLI/meta.yaml +++ /dev/null @@ -1,40 +0,0 @@ -{% set name = "ComponentDB-CLI" %} -{% set version = "3.15.4" %} - -package: - name: "{{ name|lower }}" - version: "{{ version }}" - -source: - url: "https://pypi.io/packages/source/{{ name[0] }}/{{ name }}/{{ name }}-{{ version }}.tar.gz" - sha256: 0043323080c2012df4ad3c6f36e83011c6888dff0e28005653c2b3aa6b0a6820 -build: - number: 0 - noarch: python - script: "{{ PYTHON }} -m pip install . --no-deps --ignore-installed -vvv " - -requirements: - build: - - python - - pip - run: - - python - - python-dateutil - - urllib3 - - pydantic - - click - - pandas - - rich - - InquirerPy - - paho-mqtt - - componentdb-api=={{ version }} - - -test: - imports: - - cdbCli - -about: - home: "https://github.com/AdvancedPhotonSource/ComponentDB" - license: "Copyright (c) UChicago Argonne, LLC. All rights reserved." - summary: "Command line utilities for Component DB" diff --git a/tools/developer_tools/python-client/conda-recipe/InquirerPy-dep/meta.yaml b/tools/developer_tools/python-client/conda-recipe/InquirerPy-dep/meta.yaml deleted file mode 100644 index 240ad0754..000000000 --- a/tools/developer_tools/python-client/conda-recipe/InquirerPy-dep/meta.yaml +++ /dev/null @@ -1,27 +0,0 @@ -{% set name = "InquirerPy" %} -{% set version = "0.3.3" %} - -package: - name: "{{ name|lower }}" - version: "{{ version }}" - -source: - url: "https://pypi.io/packages/source/{{ name[0] }}/{{ name }}/{{ name }}-{{ version }}.tar.gz" - sha256: 29a1ace830d98730e0a2fc01b4484256491c182cdde93ad66ff602b1b510aaeb - -build: - number: 0 - noarch: python - script: "{{ PYTHON }} -m pip install . --no-deps --ignore-installed -vvv " - -requirements: - build: - - python - - pip - - poetry - run: - - prompt_toolkit - - pfzy - -about: - home: "https://github.com/kazhala/InquirerPy" diff --git a/tools/developer_tools/python-client/conda-recipe/pfzy-dep/meta.yaml b/tools/developer_tools/python-client/conda-recipe/pfzy-dep/meta.yaml deleted file mode 100644 index 3a6edd6f5..000000000 --- a/tools/developer_tools/python-client/conda-recipe/pfzy-dep/meta.yaml +++ /dev/null @@ -1,24 +0,0 @@ -{% set name = "pfzy" %} -{% set version = "0.3.4" %} - -package: - name: "{{ name|lower }}" - version: "{{ version }}" - -source: - url: "https://pypi.io/packages/source/{{ name[0] }}/{{ name }}/{{ name }}-{{ version }}.tar.gz" - sha256: 717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1 - -build: - number: 0 - noarch: python - script: "{{ PYTHON }} -m pip install . --no-deps --ignore-installed -vvv " - -requirements: - build: - - python - - pip - - poetry - -about: - home: "https://github.com/kazhala/pfzy" diff --git a/tools/developer_tools/python-client/generatePyClient.sh b/tools/developer_tools/python-client/generatePyClient.sh index 1571df2e3..31f85af61 100755 --- a/tools/developer_tools/python-client/generatePyClient.sh +++ b/tools/developer_tools/python-client/generatePyClient.sh @@ -36,12 +36,12 @@ curl -O $OPEN_API_GENERATOR_JAR_URL java -jar $OPEN_API_GENERATOR_JAR generate -i "$CDB_OPENAPI_YML_URL" -g python -o $GEN_OUT_DIR -c $GEN_CONFIG_FILE_PATH || exit 1 # Clean up -rm -rv belyApi +rm -rv packages/api/belyApi rm $OPEN_API_GENERATOR_JAR # Fetch the generated Api cd $GEN_OUT_DIR -cp -rv belyApi ../ +cp -rv belyApi ../packages/api/ cd .. # Clean up diff --git a/tools/developer_tools/python-client/BelyApiFactory.py b/tools/developer_tools/python-client/packages/api/BelyApiFactory.py similarity index 100% rename from tools/developer_tools/python-client/BelyApiFactory.py rename to tools/developer_tools/python-client/packages/api/BelyApiFactory.py diff --git a/tools/developer_tools/python-client/packages/api/pyproject.toml b/tools/developer_tools/python-client/packages/api/pyproject.toml new file mode 100644 index 000000000..685076793 --- /dev/null +++ b/tools/developer_tools/python-client/packages/api/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "bely-api" +version = "2026.3.0" +description = "Python client API library used to communicate with BELY API." +license = { text = "Copyright (c) UChicago Argonne, LLC. All rights reserved." } +maintainers = [ + { name = "Dariusz Jarosz", email = "djarosz@aps.anl.gov" }, +] +requires-python = ">=3.10" +dependencies = [ + "python-dateutil", + "urllib3", + "certifi", + "pydantic>=1.10", +] + +[project.urls] +Homepage = "https://github.com/AdvancedPhotonSource/BELY" + +[project.scripts] +bely-test = "BelyApiFactory:run_command" + +# belyApi/ is generated by ../../generatePyClient.sh and is gitignored, so it must be +# explicitly included here -- hatchling otherwise honors .gitignore when selecting files. +[tool.hatch.build] +ignore-vcs = true + +[tool.hatch.build.targets.wheel] +only-include = ["belyApi", "BelyApiFactory.py"] + +[tool.hatch.build.targets.sdist] +only-include = ["belyApi", "BelyApiFactory.py"] diff --git a/tools/developer_tools/python-client/pyproject.toml b/tools/developer_tools/python-client/pyproject.toml new file mode 100644 index 000000000..1a2212dfb --- /dev/null +++ b/tools/developer_tools/python-client/pyproject.toml @@ -0,0 +1,15 @@ +# Copyright (c) UChicago Argonne, LLC. All rights reserved. +# See LICENSE file. +# +# Virtual uv workspace root for the BELY python client. This project itself is +# not published -- it just groups packages/api so it can be built/tested via uv. +# See README.md for the full regenerate / build / publish procedure. + +[tool.uv.workspace] +members = ["packages/*"] + +[tool.uv.sources] +bely-api = { workspace = true } + +[dependency-groups] +dev = ["pytest"] diff --git a/tools/developer_tools/python-client/setup-api.py b/tools/developer_tools/python-client/setup-api.py deleted file mode 100644 index 413f9626c..000000000 --- a/tools/developer_tools/python-client/setup-api.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python - -""" -Copyright (c) UChicago Argonne, LLC. All rights reserved. -See LICENSE file. -""" - -from setuptools import setup - -setup(name='bely_api', - version='2026.3.0', - packages=["belyApi", - "belyApi.api", - "belyApi.models"], - py_modules=["BelyApiFactory"], - install_requires=['python-dateutil', - 'urllib3', - 'certifi', - 'pydantic>=1.10'], - license='Copyright (c) UChicago Argonne, LLC. All rights reserved.', - description='Python client API library used to communicate with BELY API.', - maintainer='Dariusz Jarosz', - maintainer_email='djarosz@aps.anl.gov', - url='https://git.aps.anl.gov/controls/hla/bely', - entry_points={ - 'console_scripts': [ - 'bely-test = BelyApiFactory:run_command' - ] - }) diff --git a/tools/developer_tools/python-client/setup-cli.py b/tools/developer_tools/python-client/setup-cli.py deleted file mode 100644 index 0c2126bdc..000000000 --- a/tools/developer_tools/python-client/setup-cli.py +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env python - -""" -Copyright (c) UChicago Argonne, LLC. All rights reserved. -See LICENSE file. -""" - -""" -DEV NOTE: To publish API -# Update version in this file -python3 setup.py sdist -twine upload dist/(specific version file) -""" - -from setuptools import setup -from setuptools import find_packages - -setup(name='ComponentDB-CLI', - version='3.15.8', - packages=['cdbCli', - 'cdbCli.common', - 'cdbCli.common.cli', - 'cdbCli.common.utility', - 'cdbCli.service', - 'cdbCli.service.cli', - 'cdbCli.service.cli.cdbCliCmnds',], - install_requires=['python-dateutil', - 'urllib3', - 'six', - 'paho-mqtt', - 'click', - 'pandas', - 'rich', - 'InquirerPy', - 'ComponentDB-API==3.15.8'], - license='Copyright (c) UChicago Argonne, LLC. All rights reserved.', - description='Python APIs used to communicate with java hosted ComponentDB API.', - maintainer='Dariusz Jarosz', - maintainer_email='djarosz@aps.anl.gov', - url='https://github.com/AdvancedPhotonSource/ComponentDB', - entry_points={ - 'console_scripts': [ - 'cdb-cli = cdbCli.service.cli.cli:main', - 'cdbSearch = cdbCli.service.cli.cdbCliCmnds.search:cdb_search', - 'cdbInfo = cdbCli.service.cli.cdbCliCmnds.info:cdb_info', - 'cdbHelp = cdbCli.service.cli.cdbCliCmnds.help:showHelp' - ] - }) diff --git a/tools/developer_tools/python-client/uv.lock b/tools/developer_tools/python-client/uv.lock new file mode 100644 index 000000000..c00db8278 --- /dev/null +++ b/tools/developer_tools/python-client/uv.lock @@ -0,0 +1,359 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[manifest] +members = [ + "bely-api", +] + +[manifest.dependency-groups] +dev = [{ name = "pytest" }] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "bely-api" +version = "2026.3.0" +source = { editable = "packages/api" } +dependencies = [ + { name = "certifi" }, + { name = "pydantic" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] + +[package.metadata] +requires-dist = [ + { name = "certifi" }, + { name = "pydantic", specifier = ">=1.10" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] From b488b3acc1384eecfe69b9c783eac5296bf3b5a7 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 21 Aug 2026 14:27:52 -0500 Subject: [PATCH 57/62] Prepare for pypi packaging and follow the new api structure. --- tools/developer_tools/bely-cli/CLAUDE.md | 29 ++++++++++++------- tools/developer_tools/bely-cli/README.md | 7 +++++ tools/developer_tools/bely-cli/pyproject.toml | 4 +-- tools/developer_tools/bely-cli/uv.lock | 5 ++-- 4 files changed, 29 insertions(+), 16 deletions(-) diff --git a/tools/developer_tools/bely-cli/CLAUDE.md b/tools/developer_tools/bely-cli/CLAUDE.md index c963caaf2..b5929c036 100644 --- a/tools/developer_tools/bely-cli/CLAUDE.md +++ b/tools/developer_tools/bely-cli/CLAUDE.md @@ -31,29 +31,36 @@ server before invoking the installed `bely-cli`. ## Dependency on the generated API client -`bely-api` **is** published on PyPI, but `pyproject.toml` still pins it to a local sdist so -you can develop against an unpublished, freshly-regenerated client before it's released: +`bely-api` **is** published on PyPI, but `pyproject.toml` still points at the local +`../python-client` workspace package so you can develop against an unpublished, +freshly-regenerated client before it's released: ```toml +[project] +dependencies = [..., "bely-api==2026.3.0", ...] + [tool.uv.sources] -bely-api = { path = "../python-client/dist/bely_api-2026.3.0.tar.gz" } +bely-api = { path = "../python-client/packages/api" } ``` -That tarball is produced from `../python-client` (`setup-api.py`, sources generated by -`generatePyClient.sh` from the running server's OpenAPI spec). It is gitignored, so a -fresh checkout of this repo does not have it — CI works around this (see below). +`../python-client/packages/api` is a uv workspace member; its `belyApi/` subpackage is +generated by `generatePyClient.sh` from the running server's OpenAPI spec and is gitignored, +so a fresh checkout of this repo does not have it — CI works around this (see below). +`[tool.uv.sources]` is stripped from published artifacts, so PyPI consumers get the plain +`bely-api==` pin. -When bumping the version: update the version in `pyproject.toml` **and** -`conda-recipe/meta.yaml`, `uv sync`, **and publish the new `bely-api` sdist to PyPI** -(CI resolves it from there, not from the local path). +Version bumps are handled by `../../../sbin/bely_prepare_release.py` (repo root), which +updates `pyproject.toml`'s own version, the `bely-api==` pin, and `conda-recipe/meta.yaml` +together, then refreshes `uv.lock`. Publishing both `bely-api` and `bely-cli` to PyPI is +`../../../sbin/bely_release_pip.py` (or `make release-python-client` from the repo root) — +it publishes `bely-api` first since `bely-cli` pins it exactly. ### CI `.github/workflows/test-bely-cli.yml` sets `UV_NO_SOURCES_PACKAGE: bely-api` at job level, which makes `uv sync` and every `uv run` inside `run_test.sh` ignore the `[tool.uv.sources]` local-path override for just that one package and resolve it from -PyPI instead — the published sdist is byte-identical to the local one (same sha256 as the -`uv.lock` entry), so this doesn't change what gets tested. +PyPI instead, since the generated `belyApi/` subpackage isn't present on a fresh checkout. The client exposes two importable names: `belyApi` (generated models/exceptions) and `BelyApiFactory` (top-level module, not a package). Always construct clients through diff --git a/tools/developer_tools/bely-cli/README.md b/tools/developer_tools/bely-cli/README.md index eb46ff8ff..a404c0291 100644 --- a/tools/developer_tools/bely-cli/README.md +++ b/tools/developer_tools/bely-cli/README.md @@ -8,6 +8,12 @@ The published command is `bely-cli`. ## Installation +From PyPI: + +```bash +pip install bely-cli +``` + For development, from this directory: ```bash @@ -38,6 +44,7 @@ To view images inline in `bely-cli tui` (see [Images](#images) below), install t `images` extra — it pulls in Pillow and `textual-image`, which the base install skips: ```bash +pip install 'bely-cli[images]' # from PyPI uv sync --extra images # development, via uv run uv tool install --force --editable '.[images]' # development, editable tool install conda install bely-cli textual-image -c # deployment diff --git a/tools/developer_tools/bely-cli/pyproject.toml b/tools/developer_tools/bely-cli/pyproject.toml index 0b55c7d5e..57242c6c6 100644 --- a/tools/developer_tools/bely-cli/pyproject.toml +++ b/tools/developer_tools/bely-cli/pyproject.toml @@ -13,7 +13,7 @@ maintainers = [{ name = "Dariusz Jarosz", email = "djarosz@aps.anl.gov" }] dependencies = [ "click>=8.1.0", "PyYAML>=6.0.0", - "bely-api>=2026.3.0", + "bely-api==2026.3.0", "textual>=0.86.0", "rich>=13.7.0", ] @@ -34,4 +34,4 @@ dev = ["pytest>=7.0.0", "textual-image[textual]>=0.12.0"] where = ["src"] [tool.uv.sources] -bely-api = { path = "../python-client/dist/bely_api-2026.3.0.tar.gz" } +bely-api = { path = "../python-client/packages/api" } diff --git a/tools/developer_tools/bely-cli/uv.lock b/tools/developer_tools/bely-cli/uv.lock index 87c9485f2..bee40f1d9 100644 --- a/tools/developer_tools/bely-cli/uv.lock +++ b/tools/developer_tools/bely-cli/uv.lock @@ -18,14 +18,13 @@ wheels = [ [[package]] name = "bely-api" version = "2026.3.0" -source = { path = "../python-client/dist/bely_api-2026.3.0.tar.gz" } +source = { directory = "../python-client/packages/api" } dependencies = [ { name = "certifi" }, { name = "pydantic" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { hash = "sha256:ee9327fc02ddf11e3c0d70ba2e0a7babb3ccb6872096cd413a174bdcdc563f1b" } [package.metadata] requires-dist = [ @@ -62,7 +61,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "bely-api", path = "../python-client/dist/bely_api-2026.3.0.tar.gz" }, + { name = "bely-api", directory = "../python-client/packages/api" }, { name = "click", specifier = ">=8.1.0" }, { name = "pyyaml", specifier = ">=6.0.0" }, { name = "rich", specifier = ">=13.7.0" }, From 35e5f8d9bbdad9248fc1ca0174429b75832c58ee Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 21 Aug 2026 14:33:28 -0500 Subject: [PATCH 58/62] Add uv packaging to pypi for api and cli --- sbin/bely_release_bely_api_pip.py | 70 --------------- sbin/bely_release_pip.py | 137 ++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 70 deletions(-) delete mode 100755 sbin/bely_release_bely_api_pip.py create mode 100755 sbin/bely_release_pip.py diff --git a/sbin/bely_release_bely_api_pip.py b/sbin/bely_release_bely_api_pip.py deleted file mode 100755 index 034316540..000000000 --- a/sbin/bely_release_bely_api_pip.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env python - -""" -Copyright (c) UChicago Argonne, LLC. All rights reserved. -See LICENSE file. -""" - -import os -from pathlib import Path -import shutil - -DIST_ROOT_DIRECTORY_ENV_KEY = "LOGR_ROOT_DIR" -PYTHON_SRC_DIST_PATH = 'tools/developer_tools/python-client' -DIST_VERSION_FILE_PATH = 'etc/version' -PYTHON_API_SETUP_FILE = 'setup-api.py' - -rootDir = os.getenv(DIST_ROOT_DIRECTORY_ENV_KEY) -if rootDir is None: - raise EnvironmentError('Please run setup.sh from the root directory of the bely distribution.') - - -def getDistVersion(): - versionFilePath = '%s/%s' % (rootDir, DIST_VERSION_FILE_PATH) - return open(versionFilePath, 'r').read().split('\n')[0] - -def publish_api(setup_file): - setupFilePath = "%s/%s/%s" % (rootDir, PYTHON_SRC_DIST_PATH, setup_file) - setupFile = open(setupFilePath, 'r') - - projectName = "" - - for line in setupFile.readlines(): - if 'name=' in line: - projectName = line.split("'")[1] - if 'version=' in line: - versionLineSplit = line.split("'") - versionNumber = versionLineSplit[1] - - os.chdir('%s/%s' % (rootDir, PYTHON_SRC_DIST_PATH)) - - p = Path('setup.py') - p.symlink_to("./%s" % setup_file) - - buildExit = os.system('python setup.py sdist') - - # Clean up - if os.path.exists('./setup.py'): - os.remove('./setup.py') - - if buildExit == 0: - # Clean up - egg_info_file_path = './%s.egg-info' % projectName.replace("-", "_") - if os.path.exists(egg_info_file_path): - shutil.rmtree(egg_info_file_path) - - tarFileName = '%s-%s.tar.gz' % (projectName, versionNumber) - - # Attempt to upload to pip - distFilePath = 'dist/%s' % tarFileName - return distFilePath - -new_api_generator_script = "%s/%s/%s http://localhost:8080/bely" % (rootDir, PYTHON_SRC_DIST_PATH, 'generatePyClient.sh') -generation_exit = os.system(new_api_generator_script) - -if generation_exit == 0: - api_bin_path = publish_api(PYTHON_API_SETUP_FILE) - - print(api_bin_path) - - pipExit = os.system('twine upload %s' % api_bin_path) diff --git a/sbin/bely_release_pip.py b/sbin/bely_release_pip.py new file mode 100755 index 000000000..477c40f17 --- /dev/null +++ b/sbin/bely_release_pip.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python + +""" +Copyright (c) UChicago Argonne, LLC. All rights reserved. +See LICENSE file. +""" + +# Builds and publishes the BELY python client packages (bely-api, bely-cli) +# to PyPI using uv. +# +# DEV NOTE: To publish a release +# source setup.sh +# ./sbin/bely_release_pip.py +# or, via make: +# make release-python-client +# +# Regenerating belyApi requires a running portal (default http://localhost:8080/bely). +# Publishing requires a PyPI API token, e.g. via UV_PUBLISH_TOKEN or ~/.pypirc. + +import argparse +import glob +import os +import shutil +import subprocess + +DIST_ROOT_DIRECTORY_ENV_KEY = "LOGR_ROOT_DIR" +PYTHON_CLIENT_DIR = "tools/developer_tools/python-client" +CLI_DIR = "tools/developer_tools/bely-cli" +DEFAULT_PORTAL_URL = "http://localhost:8080/bely" + +rootDir = os.getenv(DIST_ROOT_DIRECTORY_ENV_KEY) +if rootDir is None: + raise EnvironmentError("Please run setup.sh from the root directory of the bely distribution.") + +clientDir = os.path.join(rootDir, PYTHON_CLIENT_DIR) +cliDir = os.path.join(rootDir, CLI_DIR) + +# Published first-to-last: bely-cli pins bely-api exactly, so bely-api must land on +# PyPI before bely-cli is published against it. +PACKAGES = { + "api": {"cwd": clientDir, "build_args": ["--package", "bely-api"]}, + "cli": {"cwd": cliDir, "build_args": []}, +} + + +def run(args, cwd): + print("+ (%s) %s" % (cwd, " ".join(args))) + subprocess.run(args, check=True, cwd=cwd) + + +def regenerate_client(portal_url): + run(["./generatePyClient.sh", portal_url], clientDir) + generated = os.path.join(clientDir, "packages", "api", "belyApi", "__init__.py") + if not os.path.exists(generated): + raise RuntimeError("generatePyClient.sh did not produce %s" % generated) + + +def build(name): + spec = PACKAGES[name] + dist_dir = os.path.join(spec["cwd"], "dist") + if os.path.isdir(dist_dir): + shutil.rmtree(dist_dir) + run(["uv", "lock"], spec["cwd"]) + run(["uv", "build", "--out-dir", dist_dir] + spec["build_args"], spec["cwd"]) + artifacts = sorted(glob.glob(os.path.join(dist_dir, "*"))) + if not artifacts: + raise RuntimeError("uv build produced no artifacts in %s" % dist_dir) + return artifacts + + +def publish(artifacts, publish_url): + args = ["uv", "publish"] + if publish_url: + args += ["--publish-url", publish_url] + args += artifacts + run(args, rootDir) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "packages", + nargs="*", + default=["api", "cli"], + help="which package(s) to release: api, cli, or both (default: both)", + ) + parser.add_argument( + "--portal-url", + default=DEFAULT_PORTAL_URL, + help="running BELY portal used to regenerate belyApi (default: %(default)s)", + ) + parser.add_argument( + "--skip-generate", + action="store_true", + help="skip regenerating belyApi; use whatever is already in packages/api/belyApi", + ) + parser.add_argument( + "--publish-url", + default=None, + help="alternate index, e.g. https://test.pypi.org/legacy/ for a dry run upload", + ) + parser.add_argument("--dry-run", action="store_true", help="build only; do not upload") + args = parser.parse_args() + + unknown = sorted(set(args.packages) - set(PACKAGES)) + if unknown: + raise ValueError("Unknown package(s): %s (choose from %s)" % (", ".join(unknown), ", ".join(PACKAGES))) + + # Always build/publish api before cli, regardless of the order given on the command line. + selected = [name for name in ("api", "cli") if name in args.packages] + + if "api" in selected and not args.skip_generate: + regenerate_client(args.portal_url) + + built = {} + for name in selected: + artifacts = build(name) + built[name] = artifacts + print("\nBuilt %d artifact(s) for %s:" % (len(artifacts), name)) + for artifact in artifacts: + print(" %s" % os.path.relpath(artifact, rootDir)) + + if args.dry_run: + print("\n--dry-run given, not publishing.") + return + + response = input("\nPublish these to %s? [y/N]: " % (args.publish_url or "PyPI")).strip().lower() + if response not in ("y", "yes"): + print("Aborted, nothing published.") + return + + for name in selected: + publish(built[name], args.publish_url) + + +if __name__ == "__main__": + main() From a9d9bc4643d3ae97c9ad7e566396cd4fb94220e7 Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 21 Aug 2026 14:35:44 -0500 Subject: [PATCH 59/62] Add prepare release that will update all the necessary versions before a new version is released. --- sbin/bely_prepare_release.py | 281 +++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100755 sbin/bely_prepare_release.py diff --git a/sbin/bely_prepare_release.py b/sbin/bely_prepare_release.py new file mode 100755 index 000000000..13774a857 --- /dev/null +++ b/sbin/bely_prepare_release.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python + +""" +Copyright (c) UChicago Argonne, LLC. All rights reserved. +See LICENSE file. +""" + +# Bumps the release version string across the repo in one pass. Locates +# occurrences by pattern (not a hardcoded file list) so it also repairs any +# files that drifted out of sync in a previous manual release. + +import argparse +import glob +import os +import re +import shutil +import subprocess +import sys + +DIST_ROOT_DIRECTORY_ENV_KEY = "LOGR_ROOT_DIR" +VERSION_FILE_PATH = "etc/version" +RELEASE_NOTES_DIR = "docs/release-notes" + +rootDir = os.getenv(DIST_ROOT_DIRECTORY_ENV_KEY) +if rootDir is None: + raise EnvironmentError("Please run setup.sh from the root directory of the bely distribution.") + +# Matches whatever inconsistent forms already exist in the repo -- "2026.3", +# "2026.3.0", pre-release suffixes like "2026.3.dev0" -- so drift can be found +# and normalized. The new version supplied on the command line is validated +# separately against the strict three-component form (see NEW_VERSION_RE). +FIND_VERSION_RE = r"\d+\.\d+(?:\.[A-Za-z0-9]+){0,2}" +NEW_VERSION_RE = r"\d+\.\d+\.\d+(?:\.[A-Za-z0-9]+)?" + + +def paths(pattern): + """Resolve a repo-relative path or glob to a sorted list of repo-relative paths.""" + matches = glob.glob(os.path.join(rootDir, pattern), recursive=True) + return sorted(os.path.relpath(p, rootDir) for p in matches) + + +# Each spec is a group of files sharing the same version marker pattern(s). +# Every pattern must contain a single named group "ver". +SPECS = [ + { + "name": "etc/version", + "paths": lambda: paths(VERSION_FILE_PATH), + "patterns": [r"^(?P%s)\s*$" % FIND_VERSION_RE], + }, + { + "name": "openapi.yaml", + "paths": lambda: paths("src/java/LogrPortal/src/java/openapi.yaml"), + "patterns": [r"^(\s*version:\s*')(?P%s)(')" % FIND_VERSION_RE], + }, + { + "name": "python-client API pyproject", + "paths": lambda: paths("tools/developer_tools/python-client/packages/api/pyproject.toml"), + "patterns": [r'^(version = ")(?P%s)(")' % FIND_VERSION_RE], + }, + { + "name": "bely-cli pyproject", + "paths": lambda: paths("tools/developer_tools/bely-cli/pyproject.toml"), + "patterns": [ + r'^(version = ")(?P%s)(")' % FIND_VERSION_RE, + r'(bely-api==)(?P%s)(")' % FIND_VERSION_RE, + ], + }, + { + "name": "bely-cli __version__", + "paths": lambda: paths("tools/developer_tools/bely-cli/src/bely_cli/__init__.py"), + "patterns": [r'^(__version__ = ")(?P%s)(")' % FIND_VERSION_RE], + }, + { + "name": "bely-mqtt-message-broker", + "paths": lambda: paths("tools/developer_tools/bely-mqtt-message-broker/pyproject.toml") + + paths("tools/developer_tools/bely-mqtt-message-broker/setup.py") + + paths("tools/developer_tools/bely-mqtt-message-broker/src/bely_mqtt/__init__.py"), + "patterns": [ + r'^(\s*version\s*=\s*")(?P%s)(")' % FIND_VERSION_RE, + r'^(\s*__version__\s*=\s*")(?P%s)(")' % FIND_VERSION_RE, + ], + }, + { + "name": "conda recipes", + "paths": lambda: paths("tools/developer_tools/python-client/conda-recipe/API/meta.yaml") + + paths("tools/developer_tools/bely-cli/conda-recipe/meta.yaml") + + paths("tools/developer_tools/bely-mqtt-message-broker/conda-recipe/meta.yaml"), + "patterns": [r'(set version = ")(?P%s)(")' % FIND_VERSION_RE], + }, +] + +# Conda build numbers get reset to 0 for a fresh release. +CONDA_BUILD_NUMBER_RE = re.compile(r"^(\s*number:\s*)\d+\s*$", re.MULTILINE) + +RELEASE_NOTES_STUB = "# General\n- \n# Bug Fixes\n- \n" + + +def read(path): + with open(os.path.join(rootDir, path), "r") as f: + return f.read() + + +def write(path, contents): + with open(os.path.join(rootDir, path), "w") as f: + f.write(contents) + + +def current_version(): + return read(VERSION_FILE_PATH).strip() + + +def normalize_for_compare(version): + """Pad a bare . version (as etc/version currently is) to three + components so drift detection compares like with like against the rest of the + repo's three-component versions, instead of flagging every file as drifted.""" + parts = version.split(".") + if len(parts) == 2: + parts.append("0") + return ".".join(parts) + + +def bump_file(path, patterns, new_version): + """Apply every pattern to a file's contents, returning (new_contents, old_versions_found).""" + contents = read(path) + old_versions = [] + + def substitute(match): + old_versions.append(match.group("ver")) + # Replace only the "ver" span, keeping whatever surrounding literal + # text the pattern captured (quotes, prefixes, etc.) untouched. + span_start = match.start("ver") - match.start() + span_end = match.end("ver") - match.start() + return match.group(0)[:span_start] + new_version + match.group(0)[span_end:] + + for pattern in patterns: + contents = re.sub(pattern, substitute, contents, flags=re.MULTILINE) + + return contents, old_versions + + +def reset_conda_build_number(path): + contents = read(path) + new_contents, count = CONDA_BUILD_NUMBER_RE.subn(r"\g<1>0", contents) + changed = new_contents != contents + return new_contents, changed + + +def scaffold_release_notes(version): + notes_path = os.path.join(RELEASE_NOTES_DIR, "%s.md" % version) + if os.path.exists(os.path.join(rootDir, notes_path)): + return notes_path, False + return notes_path, True + + +def build_plan(new_version, base_version): + """Compute every change without touching disk. Returns (file_changes, notes_path, notes_is_new).""" + file_changes = [] + + for spec in SPECS: + spec_paths = spec["paths"]() + if not spec_paths: + print("WARNING: spec '%s' matched no files" % spec["name"], file=sys.stderr) + continue + + sparse = spec.get("sparse", False) + spec_hits = 0 + for path in spec_paths: + new_contents, old_versions = bump_file(path, spec["patterns"], new_version) + if not old_versions: + if not sparse: + print( + "WARNING: %s matched no version markers for spec '%s'" % (path, spec["name"]), + file=sys.stderr, + ) + continue + spec_hits += 1 + drift = any(normalize_for_compare(v) != normalize_for_compare(base_version) for v in old_versions) + file_changes.append( + { + "path": path, + "old_versions": old_versions, + "new_contents": new_contents, + "drift": drift, + } + ) + + if sparse and spec_hits == 0: + print("WARNING: spec '%s' matched no version markers in any file" % spec["name"], file=sys.stderr) + + conda_recipe_paths = ( + paths("tools/developer_tools/python-client/conda-recipe/API/meta.yaml") + + paths("tools/developer_tools/bely-cli/conda-recipe/meta.yaml") + + paths("tools/developer_tools/bely-mqtt-message-broker/conda-recipe/meta.yaml") + ) + for path in conda_recipe_paths: + new_contents, changed = reset_conda_build_number(path) + if changed: + file_changes.append( + { + "path": path, + "old_versions": ["build number -> 0"], + "new_contents": new_contents, + "drift": False, + "build_number_reset": True, + } + ) + + notes_path, notes_is_new = scaffold_release_notes(new_version) + + return file_changes, notes_path, notes_is_new + + +def print_plan(file_changes, notes_path, notes_is_new, new_version): + print("Preparing release %s\n" % new_version) + for change in file_changes: + if change.get("build_number_reset"): + print(" %-70s conda build number -> 0" % change["path"]) + continue + old = ", ".join(sorted(set(change["old_versions"]))) + marker = " (DRIFT)" if change["drift"] else "" + print(" %-70s %s -> %s%s" % (change["path"], old, new_version, marker)) + if notes_is_new: + print(" %-70s (new stub)" % notes_path) + else: + print(" %-70s (already exists, left alone)" % notes_path) + print() + print("Not managed by this script -- update manually if needed:") + print(" docs/update/v%s.md" % new_version) + print(" db/sql/updates/updateTo%s.sql" % new_version) + print() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", nargs="?", help="new release version, e.g. 2026.4.0") + parser.add_argument("--dry-run", action="store_true", help="print planned changes without writing") + args = parser.parse_args() + + base_version = current_version() + + new_version = args.version + if not new_version: + new_version = input("Current version is %s. New version: " % base_version).strip() + + if not re.fullmatch(NEW_VERSION_RE, new_version): + raise ValueError("Version '%s' does not look like ..[.]" % new_version) + + file_changes, notes_path, notes_is_new = build_plan(new_version, base_version) + print_plan(file_changes, notes_path, notes_is_new, new_version) + + if args.dry_run: + return + + response = input("Write these changes? [Y/n]: ").strip().lower() + if response not in ("", "y", "yes"): + print("Aborted, no files written.") + return + + for change in file_changes: + write(change["path"], change["new_contents"]) + if notes_is_new: + write(notes_path, RELEASE_NOTES_STUB) + + print("Wrote %d file(s)." % (len(file_changes) + (1 if notes_is_new else 0))) + + refresh_uv_lock() + + +def refresh_uv_lock(): + """Keep uv.lock in sync with the version bump just written to the workspace members.""" + if shutil.which("uv") is None: + print("WARNING: uv not found on PATH, skipping `uv lock` refresh.", file=sys.stderr) + return + for relative_dir in ("tools/developer_tools/python-client", "tools/developer_tools/bely-cli"): + project_dir = os.path.join(rootDir, relative_dir) + subprocess.run(["uv", "lock"], cwd=project_dir, check=True) + print("Refreshed %s/uv.lock." % relative_dir) + + +if __name__ == "__main__": + main() From 6ef506d5bc449bb45df8989830127a0cfe3dc19b Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 21 Aug 2026 14:41:15 -0500 Subject: [PATCH 60/62] Document the new functionality in readme and Makefile. --- Makefile | 7 +++++++ README.md | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/Makefile b/Makefile index 18a9e33a2..5a7ee4c0d 100644 --- a/Makefile +++ b/Makefile @@ -11,9 +11,16 @@ SUBDIRS = src .PHONY: support support-mysql dev-config .PHONY: db backup db-dev deploy-web-portal undeploy-web-portal deploy-web-service undeploy-web-service .PHONY: db-dev backup-dev deploy-web-portal-dev undeploy-web-portal-dev deploy-web-service-dev undeploy-web-service-dev +.PHONY: prepare-release release-python-client default: +prepare-release: + $(TOP)/sbin/bely_prepare_release.py + +release-python-client: + $(TOP)/sbin/bely_release_pip.py + prepare-dev-env: support db dev-config dev-config: diff --git a/README.md b/README.md index 6607d78d1..4c36a6f2d 100644 --- a/README.md +++ b/README.md @@ -127,5 +127,17 @@ source setup.sh make test ``` +# Preparing a Release + +```sh +source setup.sh +make prepare-release # bumps the version everywhere and scaffolds release notes +# review the diff, then commit +make release-python-client # publishes bely-api and bely-cli to PyPI +``` + +See `tools/developer_tools/python-client/README.md` for the full build/publish/conda +procedure, including TestPyPI dry runs. + # License [Copyright (c) UChicago Argonne, LLC. All rights reserved.](https://github.com/AdvancedPhotonSource/ComponentDB/blob/master/LICENSE) From a346c4c5ce04b75f15acc13e3cc0af2ab062f51b Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 21 Aug 2026 14:55:26 -0500 Subject: [PATCH 61/62] Extend the pypi packaging to the mqtt framework --- README.md | 2 +- sbin/bely_release_pip.py | 24 ++++++++++++------- tools/developer_tools/python-client/README.md | 4 ++-- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4c36a6f2d..74932464d 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ make test source setup.sh make prepare-release # bumps the version everywhere and scaffolds release notes # review the diff, then commit -make release-python-client # publishes bely-api and bely-cli to PyPI +make release-python-client # publishes bely-api, bely-cli, and bely-mqtt-framework to PyPI ``` See `tools/developer_tools/python-client/README.md` for the full build/publish/conda diff --git a/sbin/bely_release_pip.py b/sbin/bely_release_pip.py index 477c40f17..075601cd1 100755 --- a/sbin/bely_release_pip.py +++ b/sbin/bely_release_pip.py @@ -5,8 +5,8 @@ See LICENSE file. """ -# Builds and publishes the BELY python client packages (bely-api, bely-cli) -# to PyPI using uv. +# Builds and publishes the BELY python client packages (bely-api, bely-cli, +# bely-mqtt-framework) to PyPI using uv. # # DEV NOTE: To publish a release # source setup.sh @@ -26,6 +26,7 @@ DIST_ROOT_DIRECTORY_ENV_KEY = "LOGR_ROOT_DIR" PYTHON_CLIENT_DIR = "tools/developer_tools/python-client" CLI_DIR = "tools/developer_tools/bely-cli" +MQTT_DIR = "tools/developer_tools/bely-mqtt-message-broker" DEFAULT_PORTAL_URL = "http://localhost:8080/bely" rootDir = os.getenv(DIST_ROOT_DIRECTORY_ENV_KEY) @@ -34,12 +35,14 @@ clientDir = os.path.join(rootDir, PYTHON_CLIENT_DIR) cliDir = os.path.join(rootDir, CLI_DIR) +mqttDir = os.path.join(rootDir, MQTT_DIR) -# Published first-to-last: bely-cli pins bely-api exactly, so bely-api must land on -# PyPI before bely-cli is published against it. +# Published first-to-last: bely-cli and bely-mqtt-framework both depend on bely-api, +# so bely-api must land on PyPI before either of them is published. PACKAGES = { "api": {"cwd": clientDir, "build_args": ["--package", "bely-api"]}, "cli": {"cwd": cliDir, "build_args": []}, + "mqtt": {"cwd": mqttDir, "build_args": []}, } @@ -60,7 +63,10 @@ def build(name): dist_dir = os.path.join(spec["cwd"], "dist") if os.path.isdir(dist_dir): shutil.rmtree(dist_dir) - run(["uv", "lock"], spec["cwd"]) + # Only refresh a lockfile for uv-managed projects; bely-mqtt-framework is a + # plain setuptools project with no uv.lock, and `uv build` doesn't need one. + if os.path.exists(os.path.join(spec["cwd"], "uv.lock")): + run(["uv", "lock"], spec["cwd"]) run(["uv", "build", "--out-dir", dist_dir] + spec["build_args"], spec["cwd"]) artifacts = sorted(glob.glob(os.path.join(dist_dir, "*"))) if not artifacts: @@ -81,8 +87,8 @@ def main(): parser.add_argument( "packages", nargs="*", - default=["api", "cli"], - help="which package(s) to release: api, cli, or both (default: both)", + default=["api", "cli", "mqtt"], + help="which package(s) to release: api, cli, mqtt, or any combination (default: all)", ) parser.add_argument( "--portal-url", @@ -106,8 +112,8 @@ def main(): if unknown: raise ValueError("Unknown package(s): %s (choose from %s)" % (", ".join(unknown), ", ".join(PACKAGES))) - # Always build/publish api before cli, regardless of the order given on the command line. - selected = [name for name in ("api", "cli") if name in args.packages] + # Always build/publish api first, regardless of the order given on the command line. + selected = [name for name in ("api", "cli", "mqtt") if name in args.packages] if "api" in selected and not args.skip_generate: regenerate_client(args.portal_url) diff --git a/tools/developer_tools/python-client/README.md b/tools/developer_tools/python-client/README.md index 672bd5a75..d0032c91c 100644 --- a/tools/developer_tools/python-client/README.md +++ b/tools/developer_tools/python-client/README.md @@ -52,8 +52,8 @@ Or use the wrapper script, which also regenerates `belyApi` first and prompts be ./sbin/bely_release_pip.py api --publish-url https://test.pypi.org/legacy/ # TestPyPI ``` -or `make release-python-client` from the repo root (publishes both `bely-api` and -`bely-cli`). +or `make release-python-client` from the repo root (publishes `bely-api`, `bely-cli`, +and `bely-mqtt-framework`). `make prepare-release` (`sbin/bely_prepare_release.py`) bumps the version in `packages/api/pyproject.toml` (and everywhere else version strings live) and refreshes From f73f16b92e14e2cb37c5f0d21167919f9c2535fc Mon Sep 17 00:00:00 2001 From: Dariusz Jarosz Date: Fri, 21 Aug 2026 15:01:33 -0500 Subject: [PATCH 62/62] add make help --- Makefile | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/Makefile b/Makefile index 5a7ee4c0d..c7b6caab8 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ TOP = . #SUBDIRS = irmis src SUBDIRS = src +.PHONY: help .PHONY: support support-mysql dev-config .PHONY: db backup db-dev deploy-web-portal undeploy-web-portal deploy-web-service undeploy-web-service .PHONY: db-dev backup-dev deploy-web-portal-dev undeploy-web-portal-dev deploy-web-service-dev undeploy-web-service-dev @@ -15,6 +16,48 @@ SUBDIRS = src default: +help: + @echo "BELY - available make targets" + @echo "" + @echo "Setup:" + @echo " support Install support software (Java, Payara, MySQL, etc.)" + @echo " support-portal Install support software for the web portal only" + @echo " support-mysql Install MySQL and deploy mysqld" + @echo " support-netbeans Install NetBeans IDE" + @echo " dev-config Create development configuration" + @echo " configuration Create deployment configuration" + @echo " prepare-dev-env support + db + dev-config" + @echo "" + @echo "Database:" + @echo " clean-db Create a clean database with schema" + @echo " test-db Create a test database with test data" + @echo " db Create the database (interactive)" + @echo " backup Backup the database" + @echo "" + @echo "Build & Deploy:" + @echo " configure-web-portal Configure the web portal" + @echo " deploy-web-portal Deploy the web portal" + @echo " deploy-web-service Deploy the web service" + @echo " deploy-cdb-plugin Deploy a BELY plugin" + @echo " unconfigure-web-portal Unconfigure the web portal" + @echo " undeploy-web-portal Undeploy the web portal" + @echo " undeploy-web-service Undeploy the web service" + @echo "" + @echo "Testing:" + @echo " test Run the full test suite (backs up DB, deploys test DB," + @echo " runs tests, restores DB)" + @echo " test-plugins Run plugin utility tests" + @echo "" + @echo "Release:" + @echo " prepare-release Bump the version across the repo and scaffold release notes" + @echo " release-python-client Build and publish bely-api, bely-cli, and" + @echo " bely-mqtt-framework to PyPI" + @echo "" + @echo "Development variants:" + @echo " Most Setup/Database/Build & Deploy targets above have a '-dev' counterpart" + @echo " (e.g. db-dev, backup-dev, deploy-web-portal-dev) that operates against the" + @echo " dev configuration/database instead of the production one." + prepare-release: $(TOP)/sbin/bely_prepare_release.py