From 082de3d5ea4cbeecbb87e08c9d943ed91107ca98 Mon Sep 17 00:00:00 2001 From: Felix Rudat Date: Tue, 10 Feb 2026 08:39:41 +0100 Subject: [PATCH] feat: update Makefile and README for new linting and testing commands; refactor credential storage to JSON format --- Makefile | 6 +- README.md | 10 +++- pyproject.toml | 1 - scripts/planka_cli.py | 125 +++++++++++++++++++++++++++++------------- tests/test_cli.py | 5 +- uv.lock | 11 ---- 6 files changed, 100 insertions(+), 58 deletions(-) diff --git a/Makefile b/Makefile index 72e71d4..0a5a3d8 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,6 @@ -.PHONY: setup run build smoke clean lint test +.PHONY: setup run build smoke clean lint lint-fix test check + +.DEFAULT_GOAL := check setup: uv venv @@ -18,6 +20,8 @@ lint-fix: test: uv run pytest tests/ -v +check: lint test + build: uv run pyinstaller planka-cli.spec --noconfirm diff --git a/README.md b/README.md index d0a9fa5..3e2bdc2 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,10 @@ planka-cli boards list ## Authentication -`login` stores credentials in a `.env` file next to the `planka-cli` binary (or next to -`scripts/planka_cli.py` when running from source). You can also set credentials via -environment variables: +`login` stores credentials in `~/.config/planka-cli/tokens/credentials.json` by default. +Override with `--tokenstore PATH` or the `PLANKATOKENS` environment variable. You can also +set credentials via environment variables. Note: `--tokenstore` is a global option and +must appear before the command name. ```bash export PLANKA_URL=https://planka.example @@ -103,6 +104,9 @@ pip install -e . ```bash make setup make run +make lint +make test +make check make build ``` diff --git a/pyproject.toml b/pyproject.toml index 6c189a8..305afd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ requires-python = ">=3.11" dependencies = [ "plankapy>=2.2.2", "typer", - "python-dotenv", "rich", ] diff --git a/scripts/planka_cli.py b/scripts/planka_cli.py index 2e31ce5..104d322 100644 --- a/scripts/planka_cli.py +++ b/scripts/planka_cli.py @@ -1,3 +1,4 @@ +import json import os import shutil import sys @@ -7,29 +8,15 @@ from typing import Optional, Union import click import typer -from dotenv import load_dotenv, set_key from plankapy.v2 import Card, Planka from rich.console import Console from rich.table import Table from typer.core import TyperGroup - -def get_binary_dir() -> Path: - argv0 = sys.argv[0] - path = Path(argv0) - if not path.is_absolute(): - resolved = shutil.which(argv0) - if resolved: - path = Path(resolved) - if path.exists(): - return path.resolve().parent if path.is_file() else path.resolve() - return Path(__file__).resolve().parent - - -ENV_PATH = get_binary_dir() / ".env" - -# Load environment variables from the .env next to the binary/script. -load_dotenv(dotenv_path=ENV_PATH) +TOKEN_ENV_VAR = "PLANKATOKENS" +DEFAULT_TOKEN_DIR = Path.home() / ".config" / "planka-cli" / "tokens" +CREDENTIALS_FILENAME = "credentials.json" +TOKENSTORE_OVERRIDE: Optional[str] = None class HelpOnUnknownCommandGroup(TyperGroup): @@ -56,18 +43,68 @@ app.add_typer(notifications_app, name="notifications") @app.callback(invoke_without_command=True) -def main(ctx: typer.Context): +def main( + ctx: typer.Context, + tokenstore: Optional[str] = typer.Option(None, "--tokenstore", help="Token storage path."), +): """Planka CLI.""" + global TOKENSTORE_OVERRIDE + TOKENSTORE_OVERRIDE = tokenstore if ctx.invoked_subcommand is None: click.echo(ctx.get_help()) +def get_token_dir(tokenstore: Optional[str] = None) -> Path: + if tokenstore: + return Path(tokenstore).expanduser().resolve() + if TOKENSTORE_OVERRIDE: + return Path(TOKENSTORE_OVERRIDE).expanduser().resolve() + env = os.environ.get(TOKEN_ENV_VAR) + if env: + return Path(env).expanduser().resolve() + return DEFAULT_TOKEN_DIR + + +def get_credentials_path(tokenstore: Optional[str] = None) -> Path: + return get_token_dir(tokenstore) / CREDENTIALS_FILENAME + + +def load_stored_credentials(tokenstore: Optional[str] = None) -> dict[str, str]: + credentials_path = get_credentials_path(tokenstore) + if not credentials_path.exists(): + return {} + try: + data = json.loads(credentials_path.read_text()) + except json.JSONDecodeError as exc: + console.print( + f"[bold red]Error:[/bold red] Invalid credentials file at {credentials_path}: {exc}" + ) + raise typer.Exit(1) from exc + if not isinstance(data, dict): + console.print( + f"[bold red]Error:[/bold red] Credentials file at {credentials_path} must be a JSON object." + ) + raise typer.Exit(1) + return {str(key): str(value) for key, value in data.items()} + + def get_env_config() -> tuple[Optional[str], Optional[str], Optional[str]]: - return ( - os.getenv("PLANKA_URL"), - os.getenv("PLANKA_USERNAME"), - os.getenv("PLANKA_PASSWORD"), - ) + planka_url = os.getenv("PLANKA_URL") + planka_username = os.getenv("PLANKA_USERNAME") + planka_password = os.getenv("PLANKA_PASSWORD") + + if planka_url and planka_username and planka_password: + return planka_url, planka_username, planka_password + + stored = load_stored_credentials() + if not planka_url: + planka_url = stored.get("PLANKA_URL") + if not planka_username: + planka_username = stored.get("PLANKA_USERNAME") + if not planka_password: + planka_password = stored.get("PLANKA_PASSWORD") + + return planka_url, planka_username, planka_password def parse_iso_datetime(value: Optional[str]) -> Optional[datetime]: @@ -162,11 +199,12 @@ def render_notifications(title: str, notifications: list) -> None: def get_planka() -> Planka: planka_url, planka_username, planka_password = get_env_config() if not planka_url or not planka_username or not planka_password: + credentials_path = get_credentials_path() console.print( "[bold red]Error:[/bold red] Missing credentials. " f"Set PLANKA_URL, PLANKA_USERNAME, and PLANKA_PASSWORD or run " f"`planka-cli login --url ... --username ... --password ...` " - f"to write {ENV_PATH}." + f"to write {credentials_path}." ) sys.exit(1) @@ -185,35 +223,46 @@ def login( username: str = typer.Option(..., "--username", "-n", help="Planka username"), password: str = typer.Option(..., "--password", "-p", help="Planka password"), ): - """Store credentials in a .env file next to the binary.""" + """Store credentials in ~/.config/planka-cli/tokens/credentials.json.""" + credentials_path = get_credentials_path() try: - ENV_PATH.parent.mkdir(parents=True, exist_ok=True) - set_key(str(ENV_PATH), "PLANKA_URL", url) - set_key(str(ENV_PATH), "PLANKA_USERNAME", username) - set_key(str(ENV_PATH), "PLANKA_PASSWORD", password) + credentials_path.parent.mkdir(parents=True, exist_ok=True) + credentials_path.write_text( + json.dumps( + { + "PLANKA_URL": url, + "PLANKA_USERNAME": username, + "PLANKA_PASSWORD": password, + }, + indent=2, + ) + + "\n" + ) except OSError as e: - console.print(f"[bold red]Error:[/bold red] Could not write {ENV_PATH}: {e}") + console.print(f"[bold red]Error:[/bold red] Could not write {credentials_path}: {e}") raise typer.Exit(1) try: - os.chmod(ENV_PATH, 0o600) + os.chmod(credentials_path, 0o600) except OSError: pass - console.print(f"[green]Saved credentials to[/green] {ENV_PATH}") + console.print(f"[green]Saved credentials to[/green] {credentials_path}") @app.command() def logout(): - """Delete the stored .env file next to the binary.""" - if not ENV_PATH.exists(): - console.print(f"No stored credentials found at {ENV_PATH}") + """Delete the stored ~/.config/planka-cli/tokens/credentials.json file.""" + token_dir = get_token_dir() + credentials_path = get_credentials_path() + if not credentials_path.exists(): + console.print(f"No stored credentials found at {credentials_path}") return try: - ENV_PATH.unlink() + shutil.rmtree(token_dir) except OSError as e: - console.print(f"[bold red]Error:[/bold red] Could not delete {ENV_PATH}: {e}") + console.print(f"[bold red]Error:[/bold red] Could not delete {credentials_path}: {e}") raise typer.Exit(1) console.print("[green]Logged out.[/green] Removed stored credentials.") diff --git a/tests/test_cli.py b/tests/test_cli.py index eec6b54..4f33017 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -66,10 +66,7 @@ class TestLoginLogout: def test_logout_without_credentials(self, tmp_path, monkeypatch): """Logout without stored credentials should be graceful.""" - # Point ENV_PATH to a temp location - import scripts.planka_cli as cli_module - - monkeypatch.setattr(cli_module, "ENV_PATH", tmp_path / ".env") + monkeypatch.setenv("PLANKATOKENS", str(tmp_path)) result = runner.invoke(app, ["logout"]) assert result.exit_code == 0 assert "No stored credentials" in result.output diff --git a/uv.lock b/uv.lock index d8d4899..07e7697 100644 --- a/uv.lock +++ b/uv.lock @@ -170,7 +170,6 @@ source = { editable = "." } dependencies = [ { name = "plankapy", version = "2.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "plankapy", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, - { name = "python-dotenv" }, { name = "rich" }, { name = "typer" }, ] @@ -185,7 +184,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "plankapy", specifier = ">=2.2.2" }, - { name = "python-dotenv" }, { name = "rich" }, { name = "typer" }, ] @@ -299,15 +297,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, ] -[[package]] -name = "python-dotenv" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, -] - [[package]] name = "pywin32-ctypes" version = "0.2.3"