from __future__ import annotations from pathlib import Path import os import sys import json import typer from rich.console import Console from rich.panel import Panel from .logging_utils import setup_logging from .pipeline import build_in_folder from .io import load_invoice_yaml from .init import init_invoice_folder, init_invoice_under_root from .setup import install_mustang, check_for_mustang_update from .editor import open_in_editor app = typer.Typer(add_completion=False, no_args_is_help=True) console = Console() def _repo_root() -> Path: return Path(__file__).resolve().parent.parent def _default_template() -> Path: return _repo_root() / "templates" / "invoice.html.j2" def _mustang_default_path() -> Path: return _repo_root() / "tools" / "mustang" / "Mustang-CLI.jar" def _mustang_dir() -> Path: return _mustang_default_path().parent def _default_mustang_jar() -> Path | None: env = os.environ.get("JR_EINVOICE_MUSTANG_JAR") if env: return Path(env).expanduser() return _mustang_default_path() def _is_gui_session() -> bool: return bool(os.environ.get("WAYLAND_DISPLAY") or os.environ.get("DISPLAY")) def _fail(msg: str, exc: Exception | None = None, exit_code: int = 1): console.print(Panel.fit(f"[bold red]ERROR[/bold red]: {msg}", border_style="red")) if exc: console.print(f"[red]{type(exc).__name__}: {exc}[/red]") raise typer.Exit(exit_code) @app.command("setup-mustang") def setup_mustang( version: str = typer.Option("2.22.0", "--version", help="Mustang-CLI version to download"), dest: Path = typer.Option(_mustang_default_path(), "--dest", help="Where to place Mustang-CLI.jar"), verify_sha1: bool = typer.Option(True, "--verify-sha1/--no-verify-sha1", help="Verify downloaded jar using Maven .sha1 file"), log_level: str = typer.Option("INFO", "--log-level", help="Console log level: DEBUG/INFO/WARNING/ERROR"), ): logger = setup_logging(log_level, None) try: install_mustang(version=version, target_path=dest, logger=logger, verify_sha1=verify_sha1) console.print(Panel.fit(f"[bold green]OK[/bold green]\nInstalled Mustang CLI to:\n{dest}", border_style="green")) except Exception as e: _fail("setup-mustang failed", e) @app.command("mustang-update") def mustang_update( apply: bool = typer.Option(False, "--apply", help="If set, downloads and installs latest version when available"), verify_sha1: bool = typer.Option(True, "--verify-sha1/--no-verify-sha1", help="Verify downloaded jar using Maven .sha1 file"), log_level: str = typer.Option("INFO", "--log-level", help="Console log level: DEBUG/INFO/WARNING/ERROR"), ): logger = setup_logging(log_level, None) try: needs, installed, latest = check_for_mustang_update(_mustang_dir(), logger) if not needs: console.print(Panel.fit(f"[bold green]Up-to-date[/bold green]\nInstalled: {installed}\nLatest: {latest}", border_style="green")) return if not apply: console.print(Panel.fit( f"[bold yellow]Update available[/bold yellow]\nInstalled: {installed or '(none)'}\nLatest: {latest}\n\nRun with --apply to install.", border_style="yellow" )) return install_mustang(version=latest, target_path=_mustang_default_path(), logger=logger, verify_sha1=verify_sha1) console.print(Panel.fit(f"[bold green]Updated[/bold green]\nNow installed: {latest}", border_style="green")) except Exception as e: _fail("mustang-update failed", e) @app.command() def init( invoice_dir: Path = typer.Argument(..., help="Target invoice folder OR invoices root (when --auto-folder is set)"), customer: str | None = typer.Option(None, "--customer", "-c", help="Customer key/name, e.g. 'unicredit'"), project: str | None = typer.Option(None, "--project", "-p", help="Project/subject to prefill first line item name"), invoice_number: str | None = typer.Option(None, "--invoice-number", "-n", help="Invoice number to prefill. If omitted, can be auto-suggested."), invoices_root: Path | None = typer.Option(None, "--invoices-root", help="Folder containing all invoice folders; used to auto-suggest the next invoice number"), auto_folder: bool = typer.Option(False, "--auto-folder", help="If set, treat INVOICE_DIR as invoices root and create a subfolder Rechnung__ automatically"), due_days: int = typer.Option(14, "--due-days", help="Default due date offset in days"), force: bool = typer.Option(False, "--force", help="Overwrite invoice.yaml/assets if they already exist"), open_editor: bool = typer.Option(False, "--open", help="Open invoice.yaml in $EDITOR after init"), open_auto: bool = typer.Option(False, "--open-auto", help="Open invoice.yaml automatically if a GUI session is detected (Wayland/X11) and stdout is a TTY"), print_path: bool = typer.Option(False, "--print-path", help="Print the created invoice folder path (useful for scripts)"), print_path_only: bool = typer.Option(False, "--print-path-only", help="Only print the path (no rich panel); best for scripts"), log_level: str = typer.Option("INFO", "--log-level", help="Console log level: DEBUG/INFO/WARNING/ERROR"), ): logger = setup_logging(log_level, None) try: if auto_folder: target = init_invoice_under_root( invoices_root=invoice_dir, repo_root=_repo_root(), logger=logger, customer=customer, project=project, invoice_number=invoice_number, due_days=due_days, force=force, ) else: target = init_invoice_folder( invoice_dir, _repo_root(), logger, force=force, customer=customer, invoice_number=invoice_number, invoices_root=invoices_root, due_days=due_days, project=project, ) target_path = target.resolve() invoice_yaml = target_path / "invoice.yaml" if print_path_only: sys.stdout.write(str(target_path) + "\n") else: console.print(Panel.fit( f"[bold green]OK[/bold green]\nInitialized: {target_path}\nEdit: {invoice_yaml}\nThen run: jr-einvoice generate {target_path}", border_style="green" )) if print_path: console.print(str(target_path)) do_open = open_editor or (open_auto and _is_gui_session() and sys.stdout.isatty()) if do_open: open_in_editor(invoice_yaml, logger) except Exception as e: _fail("init failed", e) @app.command() def generate( invoice_dir: Path = typer.Argument(..., exists=True, file_okay=False, dir_okay=True, readable=True, help="Invoice folder containing invoice.yaml"), invoice_filename: str = typer.Option("invoice.yaml", "--file", help="Invoice YAML filename inside the folder"), out_subdir: str = typer.Option("out", "--out-subdir", help="Where to write artifacts inside the invoice folder"), template: Path = typer.Option(_default_template(), "--template", exists=True, readable=True, help="Jinja2 HTML template"), mustang_jar: Path | None = typer.Option(None, "--mustang-jar", help="Path to Mustang CLI jar (default: tools/mustang/Mustang-CLI.jar or env JR_EINVOICE_MUSTANG_JAR)"), prefer_weasyprint: bool = typer.Option(True, "--prefer-weasyprint/--prefer-wkhtmltopdf", help="Prefer WeasyPrint if available"), log_level: str = typer.Option("INFO", "--log-level", help="Console log level: DEBUG/INFO/WARNING/ERROR"), print_artifacts: bool = typer.Option(False, "--print-artifacts", help="Print artifact paths after generation"), print_artifacts_only: bool = typer.Option(False, "--print-artifacts-only", help="Only print artifact paths (no rich output)"), json_out: bool = typer.Option(False, "--json", help="Print artifacts as JSON to stdout (best for CI)"), ): try: _ = load_invoice_yaml(invoice_dir / invoice_filename) except Exception as e: _fail(f"Failed to load/validate {invoice_filename} in {invoice_dir}", e) log_file = invoice_dir / out_subdir / "logs" / "run.log" logger = setup_logging(log_level, log_file) jar = mustang_jar or _default_mustang_jar() if jar is None or not Path(jar).exists(): _fail("Mustang CLI jar is required (validation is always on). Run: jr-einvoice setup-mustang or jr-einvoice mustang-update --apply") try: logger.info("jr-einvoice generate start") res = build_in_folder( invoice_dir=invoice_dir, out_subdir=out_subdir, template_path=template, logger=logger, mustang_jar=Path(jar), combine=True, do_validate=True, prefer_weasyprint=prefer_weasyprint, invoice_filename=invoice_filename, ) artifacts = { "invoice_dir": str(res.invoice_dir), "out_dir": str(res.out_dir), "invoice_pdf": str(res.invoice_pdf), "facturx_xml": str(res.facturx_xml), "facturx_pdf": str(res.facturx_pdf or ""), "validate_report": str(res.validate_report or ""), "log": str(log_file), } if json_out: sys.stdout.write(json.dumps(artifacts, ensure_ascii=False) + "\n") return if print_artifacts_only: # plain list, one per line for k in ["invoice_pdf", "facturx_xml", "facturx_pdf", "validate_report", "log"]: v = artifacts.get(k, "") if v: sys.stdout.write(v + "\n") return console.print(Panel.fit( f"[bold green]OK[/bold green]\n" f"Invoice folder: {res.invoice_dir}\n" f"Artifacts: {res.out_dir}\n" f"PDF: {res.invoice_pdf}\n" f"XML: {res.facturx_xml}\n" f"Factur-X PDF: {res.facturx_pdf or '-'}\n" f"Validate report: {res.validate_report or '-'}\n" f"Log: {log_file}", border_style="green" )) if print_artifacts: console.print(str(res.invoice_pdf)) console.print(str(res.facturx_xml)) if res.facturx_pdf: console.print(str(res.facturx_pdf)) if res.validate_report: console.print(str(res.validate_report)) console.print(str(log_file)) except Exception as e: logger.exception("Generate failed") _fail("Generate failed. See out/logs/run.log for details.", e)