diff --git a/.copier-answers.yml b/.copier-answers.yml index 75f386f..b1733b1 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -4,10 +4,11 @@ _src_path: https://github.com/trobz/trobz-python-template.git author_email: moncoeur2k1@gmail.com author_username: duydoanh enable_docs_site: false -enable_github_action: false +enable_github_action: true package_name: odoo_db project_description: Odoo databases management CLI tool project_name: odoo-db project_type: cli +publish_to_pypi: false repository_name: odoo-db repository_namespace: trobz diff --git a/.github/actions/setup-python-env/action.yaml b/.github/actions/setup-python-env/action.yaml new file mode 100644 index 0000000..8decdbd --- /dev/null +++ b/.github/actions/setup-python-env/action.yaml @@ -0,0 +1,28 @@ +name: "Setup Python Environment" + +inputs: + python-version: + description: "Python version to use" + required: true + default: "3.12" + uv-version: + description: "uv version to use" + required: true + +runs: + using: "composite" + steps: + - uses: actions/setup-python@v6 + with: + python-version: ${{ inputs.python-version }} + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + version: ${{ inputs.uv-version }} + enable-cache: 'true' + cache-suffix: ${{ matrix.python-version }} + + - name: Install Python dependencies + run: uv sync --frozen + shell: bash diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml new file mode 100644 index 0000000..659cec1 --- /dev/null +++ b/.github/workflows/pre-commit.yaml @@ -0,0 +1,26 @@ +name: pre-commit + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: + - main + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v6 + + - uses: actions/cache@v5 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + + - name: Run checks + run: make check diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..e77a16f --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,40 @@ +name: release + +on: + push: + branches: + - main + + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + + permissions: + contents: write + + outputs: + released: ${{ steps.release.outputs.released }} + tag: ${{ steps.release.outputs.tag }} + + steps: + + - name: Checkout Repository on Release Branch + uses: actions/checkout@v6 + with: + ref: ${{ github.ref_name }} + + - name: Force release branch to be at workflow sha + run: | + git reset --hard ${{ github.sha }} + + - name: Semantic Version Release + id: release + uses: python-semantic-release/python-semantic-release@v10.5.2 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + git_committer_name: "github-actions" + git_committer_email: "actions@users.noreply.github.com" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..3b043e0 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,30 @@ +name: test + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + push: + branches: + - main + +jobs: + tests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + fail-fast: false + defaults: + run: + shell: bash + steps: + - name: Check out + uses: actions/checkout@v6 + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + with: + python-version: ${{ matrix.python-version }} + + - name: Run tests + run: make test diff --git a/.gitignore b/.gitignore index 1168fd0..c6051e1 100644 --- a/.gitignore +++ b/.gitignore @@ -218,3 +218,6 @@ __marimo__/ # Plans (local planning files) plans/ + +# Logs +logs/*.log diff --git a/AGENTS.md b/AGENTS.md index 13b0793..e2eebbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,17 @@ > Quick reference for AI coding agents. +## Agent Discipline + +**After every change, always:** +1. Update `README.md` and `AGENTS.md` to reflect changes (new flags, commands, behavior). +2. Run `make check` (lint + format + type-check) before committing. +3. Run pre-commit: `uv run pre-commit run -a` or via `make check`. + +Never skip these steps. They catch regressions and keep docs in sync. + +--- + ## Project `odoo-db` — CLI tool for Odoo database management. Connects to local PostgreSQL @@ -15,16 +26,20 @@ Odoo locally. ## Entry Points - `odoo_db/main.py` — CLI entry point (`odoo-db` command) +- `odoo_db/db.py` — all DB queries (psycopg3) +- `odoo_db/output.py` — output formatting (text/json/prometheus) ## CLI Structure ``` -odoo-db [--output-file FILE] [--output-format FORMAT] COMMAND [ARGS] +odoo-db [--output-file FILE] [--output-format FORMAT] [--log-level LEVEL] [--log-file FILE] COMMAND [ARGS] ``` **Global flags:** - `--output-file` — default `-` (stdout) - `--output-format` — `text` (default), `json`, `prometheus` +- `--log-level` — `DEBUG`, `INFO`, `WARNING` (default), `ERROR` +- `--log-file` — default `logs/odoo-db.log` (auto-created, gitignored) **Commands:** @@ -33,9 +48,10 @@ odoo-db [--output-file FILE] [--output-format FORMAT] COMMAND [ARGS] | `list` | All local Odoo DBs: name, version, neutralized status. `--verbose`: + module count, user count | | `modules ` | Installed modules with version | | `crons ` | Active scheduled actions | -| `jobs ` | Queue jobs (pg_queue_job) | -| `users ` | Users list | -| `locks ` | Active DB locks | +| `jobs ` | Queue job counts by state (returns message if queue_job not installed) | +| `users ` | Active users with connection status (via bus_presence if available) | +| `locks ` | Active DB locks (blocked/blocking PIDs + queries) | +| `stats ` | Per-table record counts and sizes by year; `--years N` (default 3), `--top N` (default 20) | **Key SQL for `list`:** ```sql @@ -56,6 +72,12 @@ Connect via psycopg3 (Unix socket, peer auth): psycopg.connect(dbname=db_name) # no host/user needed for local socket ``` +## Logging + +- Console handler always active. +- File handler writes to `--log-file` (default `logs/odoo-db.log`), parent dir auto-created. +- `logs/*.log` is gitignored; `logs/.gitkeep` tracks the directory. + ## Dev Commands Run `make help` for all commands. Key ones: @@ -71,4 +93,5 @@ make test # Run pytest - `Makefile` — Project commands - `pyproject.toml` — Dependencies and build config - `ruff.toml` — Linter/formatter rules +- `logs/` — Log output directory (`.gitkeep` tracked, `*.log` gitignored) - `tests/` — Test suite (pytest) diff --git a/Makefile b/Makefile index 3316e74..adc04b7 100644 --- a/Makefile +++ b/Makefile @@ -50,6 +50,3 @@ help: [[print(f'\033[36m{m[0]:<20}\033[0m {m[1]}') for m in re.findall(r'^([a-zA-Z_-]+):.*?## (.*)$$', open(makefile).read(), re.M)] for makefile in ('$(MAKEFILE_LIST)').strip().split()]" .DEFAULT_GOAL := help - - - diff --git a/README.md b/README.md index 5ca6f11..41bc34c 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,86 @@ # odoo-db -Odoo databases management CLI tool - -This is where you should write a short paragraph that describes what your module does, -how it does it, and why people should use it. +CLI tool for Odoo database management. Connects to local PostgreSQL via Unix +socket (peer auth — no credentials needed). Designed for developers running +Odoo locally. ## Installation ```bash -pip install odoo-db +uv tool install git+https://github.com/trobz/odoo-db +``` + +Or for development: + +```bash +git clone https://github.com/trobz/odoo-db +cd odoo-db +make install # install deps + pre-commit hooks +uv tool install --editable . # make `odoo-db` available globally +``` + +## Usage + +``` +odoo-db [OPTIONS] COMMAND [DB] +``` + +**Global options:** + +| Option | Default | Description | +|--------|---------|-------------| +| `--output-file` | `-` (stdout) | Write output to file | +| `--output-format` | `text` | Output format: `text`, `json`, `prometheus` | +| `--log-level` | `WARNING` | Logging level: `DEBUG`, `INFO`, `WARNING`, `ERROR` | +| `--log-file` | `logs/odoo-db.log` | Log file path (auto-created) | + +**Commands:** + +| Command | Description | +|---------|-------------| +| `list` | List all Odoo DBs with version and neutralization status | +| `modules ` | List installed modules with version | +| `crons ` | List active scheduled actions | +| `jobs ` | Queue job counts by state (requires `queue_job` module) | +| `users ` | List active users with connection status | +| `locks ` | Show active PostgreSQL locks | +| `stats ` | Per-table record counts and sizes by year (`--years N`, `--top N`) | + +## Examples + +```bash +# List all local Odoo databases +odoo-db list + +# Verbose: also show module count and user count +odoo-db list --verbose + +# Output as JSON +odoo-db --output-format json list + +# Export prometheus metrics to file +odoo-db --output-format prometheus --output-file /tmp/odoo.prom list + +# Show installed modules for a specific database +odoo-db modules my_db + +# Show queue jobs +odoo-db jobs my_db + +# Per-table stats: record counts and sizes for last 3 years +odoo-db stats my_db + +# Top 10 tables, last 5 years +odoo-db stats my_db --top 10 --years 5 + +# Debug mode with full logging +odoo-db --log-level debug list ``` -With [`uv`](https://docs.astral.sh/uv/): +## Dev ```bash -uv tool install odoo-db +make install # Install deps + pre-commit hooks +make check # Lint, format, type-check +make test # Run tests ``` diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/odoo_db/db.py b/odoo_db/db.py new file mode 100644 index 0000000..9b9fc0e --- /dev/null +++ b/odoo_db/db.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +import logging +from contextlib import contextmanager +from dataclasses import dataclass + +import psycopg +from psycopg import sql + +logger = logging.getLogger(__name__) + + +@contextmanager +def connect(dbname: str): + logger.debug("connecting to %s", dbname) + with psycopg.connect(f"dbname={dbname}") as conn: + yield conn + + +def _is_odoo(cur: psycopg.Cursor) -> bool: + cur.execute("SELECT 1 FROM pg_tables WHERE tablename='ir_module_module'") + return bool(cur.fetchone()) + + +def list_databases() -> list[str]: + with connect("postgres") as conn, conn.cursor() as cur: + cur.execute(""" + SELECT datname FROM pg_database + WHERE NOT datistemplate AND datallowconn + AND datname NOT IN ('postgres', 'template0', 'template1') + ORDER BY datname + """) + return [row[0] for row in cur.fetchall()] + + +@dataclass +class DbSummary: + name: str + version: str + neutralized: bool + module_count: int | None = None + user_count: int | None = None + + +def get_db_summary(dbname: str, verbose: bool = False) -> DbSummary | None: + try: + with connect(dbname) as conn, conn.cursor() as cur: + if not _is_odoo(cur): + return None + + cur.execute("SELECT latest_version FROM ir_module_module WHERE name='base'") + row = cur.fetchone() + if not row or not row[0]: + return None + parts = row[0].split(".") + version = ".".join(parts[:2]) if len(parts) >= 2 else row[0] + + cur.execute("SELECT value FROM ir_config_parameter WHERE key='database.is_neutralized'") + row = cur.fetchone() + neutralized = row is not None and row[0] == "True" + + module_count = user_count = None + if verbose: + cur.execute("SELECT count(*) FROM ir_module_module WHERE state='installed'") + module_count = cur.fetchone()[0] + cur.execute("SELECT count(*) FROM res_users WHERE active=true") + user_count = cur.fetchone()[0] + + return DbSummary( + name=dbname, + version=version, + neutralized=neutralized, + module_count=module_count, + user_count=user_count, + ) + except Exception as exc: + logger.warning("skipping %s: %s", dbname, exc) + return None + + +def get_modules(dbname: str) -> list[dict]: + with connect(dbname) as conn, conn.cursor() as cur: + cur.execute(""" + SELECT name, latest_version + FROM ir_module_module + WHERE state = 'installed' + ORDER BY name + """) + return [{"name": row[0], "version": row[1] or ""} for row in cur.fetchall()] + + +def get_crons(dbname: str) -> list[dict]: + with connect(dbname) as conn, conn.cursor() as cur: + cur.execute(""" + SELECT cron_name, interval_number, interval_type, nextcall + FROM ir_cron + WHERE active = true + ORDER BY nextcall + """) + return [ + { + "name": row[0], + "interval": f"{row[1]} {row[2]}", + "nextcall": str(row[3]), + } + for row in cur.fetchall() + ] + + +def get_jobs(dbname: str) -> list[dict] | None: + """Returns None if queue_job module not installed.""" + with connect(dbname) as conn, conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM ir_module_module WHERE name = ANY(%s) AND state = %s", + (["connector", "queue_job"], "installed"), + ) + if not cur.fetchone(): + return None + cur.execute("SELECT state, count(*) FROM queue_job GROUP BY state ORDER BY state") + return [{"state": row[0], "count": row[1]} for row in cur.fetchall()] + + +def get_users(dbname: str) -> list[dict]: + with connect(dbname) as conn, conn.cursor() as cur: + cur.execute("SELECT tablename FROM pg_tables WHERE tablename IN ('mail_presence', 'bus_presence')") + presence_tables = {row[0] for row in cur.fetchall()} + + if "mail_presence" in presence_tables: + # Odoo 19+: mail.presence with direct status column + cur.execute(""" + SELECT ru.login, rp.name, COALESCE(mp.status, 'offline') AS state + FROM res_users ru + LEFT JOIN res_partner rp ON ru.partner_id = rp.id + LEFT JOIN mail_presence mp ON mp.user_id = ru.id + WHERE ru.active = TRUE + ORDER BY ru.login + """) + elif "bus_presence" in presence_tables: + # Odoo 14-18: bus.presence.status is updated in real-time (HTTP and WebSocket) + cur.execute(""" + SELECT ru.login, rp.name, COALESCE(bp.status, 'offline') AS state + FROM res_users ru + LEFT JOIN res_partner rp ON ru.partner_id = rp.id + LEFT JOIN bus_presence bp ON bp.user_id = ru.id + WHERE ru.active = TRUE + ORDER BY ru.login + """) + else: + cur.execute(""" + SELECT ru.login, rp.name, 'unknown' AS state + FROM res_users ru + LEFT JOIN res_partner rp ON ru.partner_id = rp.id + WHERE ru.active = TRUE + ORDER BY ru.login + """) + return [{"login": row[0], "name": row[1] or "", "state": row[2]} for row in cur.fetchall()] + + +def get_stats(dbname: str, years: int = 3, top: int = 20) -> dict: + with connect(dbname) as conn, conn.cursor() as cur: + # All Odoo tables (have create_date) + cur.execute(""" + SELECT c.relname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_attribute a ON a.attrelid = c.oid + WHERE n.nspname = 'public' AND a.attname = 'create_date' AND c.relkind = 'r' + ORDER BY c.relname + """) + all_tables = [row[0] for row in cur.fetchall()] + + # Map table -> model name + cur.execute("SELECT replace(model, '.', '_') AS tbl, model FROM ir_model") + table_to_model = {row[0]: row[1] for row in cur.fetchall()} + + # Table sizes (top N by total size) + cur.execute( + """ + SELECT relname, + pg_total_relation_size(relid) AS total_bytes, + pg_relation_size(relid) AS table_bytes + FROM pg_statio_user_tables + WHERE relname = ANY(%s) + ORDER BY total_bytes DESC + LIMIT %s + """, + (all_tables, top), + ) + size_rows = cur.fetchall() + top_tables = [row[0] for row in size_rows] + + # Year columns for the report + cur.execute("SELECT EXTRACT(year FROM NOW())::int") + current_year = cur.fetchone()[0] + year_cols = list(range(current_year - years + 1, current_year + 1)) + + # Records per year per table + table_year_counts: dict[str, dict[int, int]] = {} + for table in top_tables: + cur.execute( + sql.SQL(""" + SELECT EXTRACT(year FROM create_date)::int AS yr, count(*) + FROM {} + WHERE create_date >= NOW() - make_interval(years => %s) + GROUP BY yr + """).format(sql.Identifier(table)), + (years,), + ) + table_year_counts[table] = {row[0]: row[1] for row in cur.fetchall()} + + # Total record count per table + total_counts: dict[str, int] = {} + for table in top_tables: + cur.execute(sql.SQL("SELECT count(*) FROM {}").format(sql.Identifier(table))) + total_counts[table] = cur.fetchone()[0] + + # Index sizes per table (sum all indexes per table) + cur.execute( + """ + SELECT i.relname AS table_name, sum(pg_relation_size(i.indexrelid)) AS index_bytes + FROM pg_stat_all_indexes i + JOIN pg_class c ON i.relid = c.oid + WHERE i.schemaname NOT IN ('information_schema', 'pg_catalog', 'pg_toast', 'pg_logical') + AND i.relname = ANY(%s) + GROUP BY i.relname + """, + (top_tables,), + ) + index_sizes = {row[0]: row[1] for row in cur.fetchall()} + + # Attachment sizes per model (dedup by checksum) + cur.execute(""" + WITH unique_attachments AS ( + SELECT res_model, file_size, + row_number() OVER (PARTITION BY checksum ORDER BY id) AS rowno + FROM ir_attachment + ) + SELECT res_model, sum(file_size) + FROM unique_attachments + WHERE rowno = 1 + GROUP BY res_model + """) + # keyed by model name (dotted), convert to table name for lookup + attachment_by_model = {row[0]: row[1] for row in cur.fetchall()} + attachment_sizes = {tbl: attachment_by_model.get(mdl, 0) for tbl, mdl in table_to_model.items()} + + # Total DB size + cur.execute("SELECT pg_size_pretty(pg_database_size(current_database()))") + db_size = cur.fetchone()[0] + + tables = [] + for relname, total_bytes, table_bytes in size_rows: + tables.append({ + "table": relname, + "model": table_to_model.get(relname, ""), + "total_records": total_counts.get(relname, 0), + "total_size_bytes": total_bytes, + "table_size_bytes": table_bytes, + "index_size_bytes": index_sizes.get(relname, 0), + "attachment_size_bytes": attachment_sizes.get(relname, 0), + "year_counts": {yr: table_year_counts.get(relname, {}).get(yr, 0) for yr in year_cols}, + }) + + return { + "db_size": db_size, + "years": year_cols, + "tables": tables, + } + + +def get_locks(dbname: str) -> dict: + with connect(dbname) as conn, conn.cursor() as cur: + cur.execute( + """ + SELECT blocked_locks.pid, blocking_locks.pid + FROM pg_catalog.pg_locks blocked_locks + JOIN pg_catalog.pg_stat_activity blocked_activity + ON blocked_activity.pid = blocked_locks.pid + JOIN pg_catalog.pg_locks blocking_locks + ON blocking_locks.locktype = blocked_locks.locktype + AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database + AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation + AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page + AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple + AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid + AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid + AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid + AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid + AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid + AND blocking_locks.pid != blocked_locks.pid + JOIN pg_catalog.pg_stat_activity blocking_activity + ON blocking_activity.pid = blocking_locks.pid + WHERE NOT blocked_locks.granted + AND blocked_activity.datname = %s + """, + (dbname,), + ) + + blocked_by: dict[int, list[int]] = {} + for blocked_pid, blocking_pid in cur.fetchall(): + blocked_by.setdefault(blocked_pid, []).append(blocking_pid) + + blocked = set(blocked_by.keys()) + blocking = {pid for pids in blocked_by.values() for pid in pids} + blocking_not_blocked = sorted(blocking - blocked) + + queries: dict[int, str] = {} + all_pids = list(blocked | blocking) + if all_pids: + cur.execute( + """ + SELECT pid, left(query, 120) + FROM pg_stat_activity + WHERE pid = ANY(%s) + """, + (all_pids,), + ) + queries = {row[0]: row[1] for row in cur.fetchall()} + + return { + "blocked_count": len(blocked), + "blocking_count": len(blocking), + "blocking_not_blocked": blocking_not_blocked, + "details": [{"blocked_pid": bp, "blocking_pids": pids} for bp, pids in blocked_by.items()], + "queries": {str(pid): q for pid, q in queries.items()}, + } diff --git a/odoo_db/main.py b/odoo_db/main.py index 2f4863f..5296e1b 100644 --- a/odoo_db/main.py +++ b/odoo_db/main.py @@ -1,12 +1,357 @@ +from __future__ import annotations + +import logging +from contextlib import contextmanager +from pathlib import Path +from typing import Annotated + +import psycopg import typer -app = typer.Typer() +from odoo_db import db, output + +app = typer.Typer(no_args_is_help=True) + + +@contextmanager +def _handle_errors(db_name: str): + try: + yield + except psycopg.OperationalError as e: + raw = str(e).strip() + # Extract the FATAL/ERROR reason from the pg error chain + for part in reversed(raw.split(":")): + part = part.strip() + if part: + msg = part + break + else: + msg = raw + typer.echo(f"Error [{db_name}]: {msg}", err=True) + raise typer.Exit(1) from None + except Exception as e: + typer.echo(f"Error: {e}", err=True) + raise typer.Exit(1) from e + + +_output_file: str | None = None +_output_format: str = "text" + + +@app.callback() +def main( + output_file: Annotated[str, typer.Option("--output-file")] = "-", + output_format: Annotated[str, typer.Option("--output-format")] = "text", + log_level: Annotated[str, typer.Option("--log-level")] = "WARNING", + log_file: Annotated[str, typer.Option("--log-file")] = "logs/odoo-db.log", +): + global _output_file, _output_format + _output_file = None if output_file == "-" else output_file + _output_format = output_format + + fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + root = logging.getLogger() + root.setLevel(log_level.upper()) + + console = logging.StreamHandler() + console.setFormatter(fmt) + root.addHandler(console) + + log_path = Path(log_file) + log_path.parent.mkdir(parents=True, exist_ok=True) + fh = logging.FileHandler(log_path) + fh.setFormatter(fmt) + root.addHandler(fh) + + +def _writer() -> output.Writer: + return output.Writer(_output_file, _output_format) + + +# --------------------------------------------------------------------------- +# list +# --------------------------------------------------------------------------- + + +@app.command(name="list") +def cmd_list( + verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False, +): + """List all Odoo databases: name, version, neutralized status.""" + with _handle_errors("postgres"): + names = db.list_databases() + summaries = [s for name in names if (s := db.get_db_summary(name, verbose=verbose))] + + with _writer() as w: + if w.fmt == "json": + w.json([ + { + "db": s.name, + "version": s.version, + "neutralized": s.neutralized, + **({"modules": s.module_count, "users": s.user_count} if verbose else {}), + } + for s in summaries + ]) + elif w.fmt == "prometheus": + lines: list[str] = [] + lines.append("# HELP odoo_db_info Odoo database metadata") + lines.append("# TYPE odoo_db_info gauge") + for s in summaries: + labels = f'db="{s.name}",version="{s.version}",neutralized="{str(s.neutralized).lower()}"' + lines.append(f"odoo_db_info{{{labels}}} 1") + if verbose: + lines.append(f'odoo_db_modules_installed{{db="{s.name}"}} {s.module_count}') + lines.append(f'odoo_db_users_active{{db="{s.name}"}} {s.user_count}') + w.prometheus(lines) + else: + headers = ["database", "version", "neutralized"] + if verbose: + headers += ["modules", "users"] + rows = [ + [ + s.name, + s.version, + "yes" if s.neutralized else "no", + *([str(s.module_count), str(s.user_count)] if verbose else []), + ] + for s in summaries + ] + w.table(headers, rows, empty_msg="No Odoo databases found.") + + +# --------------------------------------------------------------------------- +# modules +# --------------------------------------------------------------------------- + + +@app.command() +def modules(db_name: Annotated[str, typer.Argument(metavar="DB")]): + """List installed modules with version for a database.""" + with _handle_errors(db_name): + rows_data = db.get_modules(db_name) + + with _writer() as w: + if w.fmt == "json": + w.json(rows_data) + elif w.fmt == "prometheus": + lines = [ + "# HELP odoo_db_modules_installed Installed module count", + "# TYPE odoo_db_modules_installed gauge", + f'odoo_db_modules_installed{{db="{db_name}"}} {len(rows_data)}', + ] + w.prometheus(lines) + else: + w.table(["module", "version"], [[r["name"], r["version"]] for r in rows_data]) + + +# --------------------------------------------------------------------------- +# crons +# --------------------------------------------------------------------------- + + +@app.command() +def crons(db_name: Annotated[str, typer.Argument(metavar="DB")]): + """List active scheduled actions for a database.""" + with _handle_errors(db_name): + rows_data = db.get_crons(db_name) + + with _writer() as w: + if w.fmt == "json": + w.json(rows_data) + elif w.fmt == "prometheus": + lines = [ + "# HELP odoo_db_crons_active Active scheduled action count", + "# TYPE odoo_db_crons_active gauge", + f'odoo_db_crons_active{{db="{db_name}"}} {len(rows_data)}', + ] + w.prometheus(lines) + else: + w.table( + ["name", "interval", "nextcall"], + [[r["name"], r["interval"], r["nextcall"]] for r in rows_data], + ) + + +# --------------------------------------------------------------------------- +# jobs +# --------------------------------------------------------------------------- + + +@app.command() +def jobs(db_name: Annotated[str, typer.Argument(metavar="DB")]): + """List queue job counts by state for a database.""" + with _handle_errors(db_name): + rows_data = db.get_jobs(db_name) + + with _writer() as w: + if rows_data is None: + w.text("queue_job module not installed.") + return + + if w.fmt == "json": + w.json(rows_data) + elif w.fmt == "prometheus": + lines = [ + "# HELP odoo_db_queue_jobs Queue job count by state", + "# TYPE odoo_db_queue_jobs gauge", + ] + for r in rows_data: + lines.append(f'odoo_db_queue_jobs{{db="{db_name}",state="{r["state"]}"}} {r["count"]}') + w.prometheus(lines) + else: + if not rows_data: + w.text("No jobs.") + return + w.table(["state", "count"], [[r["state"], str(r["count"])] for r in rows_data]) + + +# --------------------------------------------------------------------------- +# users +# --------------------------------------------------------------------------- + + +@app.command() +def users(db_name: Annotated[str, typer.Argument(metavar="DB")]): + """List active users for a database.""" + with _handle_errors(db_name): + rows_data = db.get_users(db_name) + + with _writer() as w: + if w.fmt == "json": + w.json(rows_data) + elif w.fmt == "prometheus": + connected = sum(1 for r in rows_data if r["state"] == "connected") + lines = [ + "# HELP odoo_db_users_active Active user count", + "# TYPE odoo_db_users_active gauge", + f'odoo_db_users_active{{db="{db_name}"}} {len(rows_data)}', + "# HELP odoo_db_users_connected Connected users (last 55s)", + "# TYPE odoo_db_users_connected gauge", + f'odoo_db_users_connected{{db="{db_name}"}} {connected}', + ] + w.prometheus(lines) + else: + w.table( + ["login", "name", "state"], + [[r["login"], r["name"], r["state"]] for r in rows_data], + ) + + +# --------------------------------------------------------------------------- +# locks +# --------------------------------------------------------------------------- + + +@app.command() +def locks(db_name: Annotated[str, typer.Argument(metavar="DB")]): + """Show active database locks for a database.""" + with _handle_errors(db_name): + data = db.get_locks(db_name) + + with _writer() as w: + if w.fmt == "json": + w.json(data) + elif w.fmt == "prometheus": + lines = [ + "# HELP odoo_db_locks_blocked Blocked process count", + "# TYPE odoo_db_locks_blocked gauge", + f'odoo_db_locks_blocked{{db="{db_name}"}} {data["blocked_count"]}', + "# HELP odoo_db_locks_blocking Blocking process count", + "# TYPE odoo_db_locks_blocking gauge", + f'odoo_db_locks_blocking{{db="{db_name}"}} {data["blocking_count"]}', + ] + w.prometheus(lines) + else: + w.text(f"Blocked: {data['blocked_count']}") + w.text(f"Blocking: {data['blocking_count']}") + w.text(f"Blocking (not blocked) PIDs: {data['blocking_not_blocked'] or 'none'}") + if data["details"]: + w.text("") + rows = [[str(d["blocked_pid"]), ", ".join(str(p) for p in d["blocking_pids"])] for d in data["details"]] + w.table(["blocked_pid", "blocking_pids"], rows) + if data["queries"]: + w.text("") + w.text("Queries involved:") + for pid, query in data["queries"].items(): + w.text(f" [{pid}] {query}") + + +# --------------------------------------------------------------------------- +# stats +# --------------------------------------------------------------------------- + + +def _fmt_bytes(b: int) -> str: + for unit in ("B", "KB", "MB", "GB"): + if b < 1024: + return f"{b:.0f} {unit}" + b //= 1024 + return f"{b:.0f} TB" @app.command() -def hello(name: str): - print(f"Hello {name}") +def stats( + db_name: Annotated[str, typer.Argument(metavar="DB")], + years: Annotated[int, typer.Option("--years", "-y", help="Number of years to show")] = 3, + top: Annotated[int, typer.Option("--top", "-n", help="Number of top tables to show")] = 20, +): + """Show per-table record counts and sizes for a database.""" + with _handle_errors(db_name): + data = db.get_stats(db_name, years=years, top=top) + + year_cols = data["years"] + tables = data["tables"] + + with _writer() as w: + if w.fmt == "json": + w.json(data) + elif w.fmt == "prometheus": + lines = [ + "# HELP odoo_db_table_size_bytes Table total size in bytes", + "# TYPE odoo_db_table_size_bytes gauge", + ] + for t in tables: + lines.append(f'odoo_db_table_size_bytes{{db="{db_name}",table="{t["table"]}"}} {t["total_size_bytes"]}') + lines += [ + "# HELP odoo_db_table_records Total record count per table", + "# TYPE odoo_db_table_records gauge", + ] + for t in tables: + lines.append(f'odoo_db_table_records{{db="{db_name}",table="{t["table"]}"}} {t["total_records"]}') + w.prometheus(lines) + else: + w.text(f"Total DB size: {data['db_size']}\n") + w.text("Columns:") + w.text(" size = total table size (heap + indexes + toast)") + w.text(" indexes = sum of all index sizes") + w.text(" attach = attachment file sizes linked to this model (dedup by checksum)") + w.text(f" {'/'.join(str(y) for y in year_cols)} = records created that year") + w.text("") + headers = ["table", "model", "records", "size", "indexes", "attach"] + [str(y) for y in year_cols] + rows = [ + [ + t["table"], + t["model"], + f"{t['total_records']:,}", + _fmt_bytes(t["total_size_bytes"]), + _fmt_bytes(t["index_size_bytes"]), + _fmt_bytes(t["attachment_size_bytes"]), + *[f"{t['year_counts'].get(y, 0):,}" for y in year_cols], + ] + for t in tables + ] + footer = [ + f"TOP {len(tables)}", + "", + f"{sum(t['total_records'] for t in tables):,}", + _fmt_bytes(sum(t["total_size_bytes"] for t in tables)), + _fmt_bytes(sum(t["index_size_bytes"] for t in tables)), + _fmt_bytes(sum(t["attachment_size_bytes"] for t in tables)), + *[f"{sum(t['year_counts'].get(y, 0) for t in tables):,}" for y in year_cols], + ] + w.table(headers, rows, footer=footer) if __name__ == "__main__": - app() \ No newline at end of file + app() diff --git a/odoo_db/output.py b/odoo_db/output.py new file mode 100644 index 0000000..33dc872 --- /dev/null +++ b/odoo_db/output.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +import json +import sys +from typing import Any + +from rich.console import Console +from rich.table import Table + + +def _open(output_file: str | None): + if output_file is None: + return sys.stdout, False + return open(output_file, "w"), True + + +class Writer: + def __init__(self, output_file: str | None, fmt: str): + self.fmt = fmt + self._f, self._owned = _open(output_file) + + def __enter__(self): + return self + + def __exit__(self, *_): + if self._owned: + self._f.close() + + def _write(self, text: str): + print(text, file=self._f) + + def table( + self, + headers: list[str], + rows: list[list[str]], + empty_msg: str = "(no results)", + footer: list[str] | None = None, + ): + if not rows: + self._write(empty_msg) + return + t = Table(show_header=True, header_style="bold cyan", show_lines=True, show_footer=footer is not None) + for i, h in enumerate(headers): + t.add_column(h, footer=("[bold]" + footer[i] + "[/bold]") if footer else "") + for row in rows: + t.add_row(*row) + console = Console(file=self._f) + console.print(t) + + def json(self, data: Any): + self._write(json.dumps(data, indent=2, default=str)) + + def prometheus(self, lines: list[str]): + for line in lines: + self._write(line) + + def text(self, msg: str): + self._write(msg) diff --git a/pyproject.toml b/pyproject.toml index 06f9d51..fa30d87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ license-files = ["LICENSE"] dependencies = [ "typer>=0.20", "psycopg[binary]>=3.1", + "rich>=13.0", ] [project.urls] @@ -40,3 +41,36 @@ module-name = "odoo_db" module-root = "" +[project.optional-dependencies] +build = ["uv ~= 0.7.12"] + +[tool.semantic_release] +version_toml = ["pyproject.toml:project.version"] +build_command = """ + python -m pip install -e '.[build]' + uv lock --upgrade-package odoo-db + git add uv.lock + uv build +""" + +# Commit parser - this should be at the top level +commit_parser = "conventional" + +# Allow 0.x.x versions (prevents jumping straight to 1.0.0) +allow_zero_version = true + +# Don't do major version bumps when in 0.x.x +major_on_zero = false + +# Tag format +tag_format = "v{version}" + +[tool.semantic_release.changelog] +exclude_commit_patterns = [ + '''chore(?:\([^)]*?\))?: .+''', + '''ci(?:\([^)]*?\))?: .+''', + '''style(?:\([^)]*?\))?: .+''', + '''test(?:\([^)]*?\))?: .+''', + '''build\((?!deps\): .+)''', + '''initial commit.*''', +] diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..d610dad --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,20 @@ +from typer.testing import CliRunner + +from odoo_db.main import app + +runner = CliRunner() + + +def test_help(): + result = runner.invoke(app, ["--help"]) + assert result.exit_code == 0 + + +def test_list_help(): + result = runner.invoke(app, ["list", "--help"]) + assert result.exit_code == 0 + + +def test_modules_help(): + result = runner.invoke(app, ["modules", "--help"]) + assert result.exit_code == 0 diff --git a/uv.lock b/uv.lock index fc8011e..b477ddc 100644 --- a/uv.lock +++ b/uv.lock @@ -420,9 +420,15 @@ version = "0.0.0" source = { editable = "." } dependencies = [ { name = "psycopg", extra = ["binary"] }, + { name = "rich" }, { name = "typer" }, ] +[package.optional-dependencies] +build = [ + { name = "uv" }, +] + [package.dev-dependencies] dev = [ { name = "pre-commit" }, @@ -435,8 +441,11 @@ dev = [ [package.metadata] requires-dist = [ { name = "psycopg", extras = ["binary"], specifier = ">=3.1" }, + { name = "rich", specifier = ">=13.0" }, { name = "typer", specifier = ">=0.20" }, + { name = "uv", marker = "extra == 'build'", specifier = "~=0.7.12" }, ] +provides-extras = ["build"] [package.metadata.requires-dev] dev = [ @@ -1067,6 +1076,31 @@ 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" }, ] +[[package]] +name = "uv" +version = "0.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c5/d1defc44eea72552ee7667212359bded10ac8f7e39b62b042a3d4a9d4040/uv-0.7.22.tar.gz", hash = "sha256:f5cf159907d594e33433f14737d1ee843dc8799edfcf57b5b8c0f282d1117051", size = 3387947, upload-time = "2025-07-17T17:01:01.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/10/55d46d79d61da0ae4ca50e126b369cbce3f2ffd478df61e7e280d3d13302/uv-0.7.22-py3-none-linux_armv6l.whl", hash = "sha256:995bdc2d8ec75620544bad1bea389334c740ff4aeeb42fbee93107b0780fa1d2", size = 17824193, upload-time = "2025-07-17T17:00:02.285Z" }, + { url = "https://files.pythonhosted.org/packages/46/31/163f836537bb3c41b7f7687e539096cc251dd7ebc7afb492cc8ef77a7243/uv-0.7.22-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bc2d9d49b8bc83ef2a0cbfd39926e2112059b9ddddb7e3baa31726cce33c707b", size = 17908564, upload-time = "2025-07-17T17:00:06.768Z" }, + { url = "https://files.pythonhosted.org/packages/64/f5/0ee734f5e988fd8f26aad1f150703fe8c7d664029c9c677b989b69caf104/uv-0.7.22-py3-none-macosx_11_0_arm64.whl", hash = "sha256:573edda226dc26e6fea03aa89a45af2f2a367ad1f466af15c4eb54286dae042f", size = 16615938, upload-time = "2025-07-17T17:00:10.01Z" }, + { url = "https://files.pythonhosted.org/packages/d4/45/71eec20e6390e464885e0546037dcecf1f10afac884e701ce70cb990774f/uv-0.7.22-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c366847fc6260c3fa101be0a99ed1ac8613537ea3ec4fa6e4aac5d1a173945ec", size = 17171889, upload-time = "2025-07-17T17:00:13.988Z" }, + { url = "https://files.pythonhosted.org/packages/d7/86/088894e7013116e5edcce29d9e7313046289be1c50056fdc0e9a336200e3/uv-0.7.22-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:706393788882cca2581d681fcf51bcbd5b04aa695e9dad41c048219f6a2a0b0a", size = 17535032, upload-time = "2025-07-17T17:00:17.472Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/216225a343659a76162a6ccef43aeacca060f1a0a5c0b56042a30e3fb7d6/uv-0.7.22-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f61c122ef8d6679dab90a24c3a2c4c688b6b3112e6e2735ae1590784520fc82b", size = 18261859, upload-time = "2025-07-17T17:00:20.513Z" }, + { url = "https://files.pythonhosted.org/packages/35/88/a566e43a77713b0fb66e933f82752a089b5e27d820a2cc549431985259f3/uv-0.7.22-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:b4660613c1fd86f607856e21fd2700bc19ae39fedcfc5865434ddbd3b76b39ae", size = 19472386, upload-time = "2025-07-17T17:00:24.167Z" }, + { url = "https://files.pythonhosted.org/packages/30/5f/310f4d21dc10577cc4aff3dc12f21f7108840c2025be479551143a158b98/uv-0.7.22-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3178dd8b118d61ffbf59297417ee0a5136b3fe34bca76105ce78a2854d9e6a2", size = 19224926, upload-time = "2025-07-17T17:00:28.279Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/eac566208accded98ff0a74d4995e7805715c5ac4bc71ff2ff9a16cf4d39/uv-0.7.22-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d7a4dbf219bcabefc760498b0b137a85966673ed43580f9dc339ff2bdaae73", size = 18784319, upload-time = "2025-07-17T17:00:31.492Z" }, + { url = "https://files.pythonhosted.org/packages/88/12/11dfd036bf776a775f6972a3f9ccfa95b4817ec7a21a427f233a5d1d904b/uv-0.7.22-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:560b00403b9cf82e2ae7d6d680c6d54c3f5c709b3b6357e092059c5d4b682baa", size = 18676032, upload-time = "2025-07-17T17:00:34.656Z" }, + { url = "https://files.pythonhosted.org/packages/c8/55/8da57cdf08fa83228e78d0f882c56278021d370029a6f726d40d2fd1c17f/uv-0.7.22-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:996f50ba85a21da7bb3d6b08be952d3fe06bede3573a50fb576d2fa77d72b1ed", size = 17455515, upload-time = "2025-07-17T17:00:37.832Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7b/00f98f7aaf540537ca5261dcd2b59a81f3bc93b148e1c1ef61243c1c1708/uv-0.7.22-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:b2abb6c638afcd5fd020284a2f36b71d41277675d23732691dc92dd4376db4b8", size = 17493955, upload-time = "2025-07-17T17:00:40.841Z" }, + { url = "https://files.pythonhosted.org/packages/3d/45/49b9dca3aac1c595a967cfb78b30675a0746c7bd15ad219f727950b7c893/uv-0.7.22-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8a66db63e0220a0d05bf9836c1b35fd8562a8d28f6989b412c914dbc4be15e16", size = 17802203, upload-time = "2025-07-17T17:00:43.931Z" }, + { url = "https://files.pythonhosted.org/packages/77/f4/e3acf80651e3650d6606109e95c56292616c423557b8503200409005a0db/uv-0.7.22-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:49a78e2703d26de1b95730a1310505debb121a178732f8dc321ed99de8eca708", size = 18772914, upload-time = "2025-07-17T17:00:47.731Z" }, + { url = "https://files.pythonhosted.org/packages/2a/50/74062e3a7f468e7f9dafc953920a1781608a2933a1d14fa48493f8b5dca4/uv-0.7.22-py3-none-win32.whl", hash = "sha256:e6115997907151e28858c238f7af18782d3ed4c24e2c0572710e05f08895cb19", size = 17716379, upload-time = "2025-07-17T17:00:51.265Z" }, + { url = "https://files.pythonhosted.org/packages/16/1a/dd8390675bf572b472063af0c612b0875156ea5ff7d2660ea44f7e361709/uv-0.7.22-py3-none-win_amd64.whl", hash = "sha256:d476f10783d1a9d49fa14fd9447fc694c75d3a93a7ff237a12bbc69eb9d29c27", size = 19512351, upload-time = "2025-07-17T17:00:55.347Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/51de664e7eeeb71b63cfe74c95cdbdc3abc20fa1443b60e78c1536ac8e64/uv-0.7.22-py3-none-win_arm64.whl", hash = "sha256:8c478034d422b99327c58914463c0841aed0bc1e8edf231ff861e191cdfea862", size = 18049309, upload-time = "2025-07-17T17:00:58.725Z" }, +] + [[package]] name = "virtualenv" version = "21.3.2"