feat: update Makefile and README for new linting and testing commands; refactor credential storage to JSON format

This commit is contained in:
Felix Rudat
2026-02-10 08:39:41 +01:00
parent e158946211
commit 082de3d5ea
6 changed files with 100 additions and 58 deletions
+5 -1
View File
@@ -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: setup:
uv venv uv venv
@@ -18,6 +20,8 @@ lint-fix:
test: test:
uv run pytest tests/ -v uv run pytest tests/ -v
check: lint test
build: build:
uv run pyinstaller planka-cli.spec --noconfirm uv run pyinstaller planka-cli.spec --noconfirm
+7 -3
View File
@@ -47,9 +47,10 @@ planka-cli boards list
## Authentication ## Authentication
`login` stores credentials in a `.env` file next to the `planka-cli` binary (or next to `login` stores credentials in `~/.config/planka-cli/tokens/credentials.json` by default.
`scripts/planka_cli.py` when running from source). You can also set credentials via Override with `--tokenstore PATH` or the `PLANKATOKENS` environment variable. You can also
environment variables: set credentials via environment variables. Note: `--tokenstore` is a global option and
must appear before the command name.
```bash ```bash
export PLANKA_URL=https://planka.example export PLANKA_URL=https://planka.example
@@ -103,6 +104,9 @@ pip install -e .
```bash ```bash
make setup make setup
make run make run
make lint
make test
make check
make build make build
``` ```
-1
View File
@@ -10,7 +10,6 @@ requires-python = ">=3.11"
dependencies = [ dependencies = [
"plankapy>=2.2.2", "plankapy>=2.2.2",
"typer", "typer",
"python-dotenv",
"rich", "rich",
] ]
+87 -38
View File
@@ -1,3 +1,4 @@
import json
import os import os
import shutil import shutil
import sys import sys
@@ -7,29 +8,15 @@ from typing import Optional, Union
import click import click
import typer import typer
from dotenv import load_dotenv, set_key
from plankapy.v2 import Card, Planka from plankapy.v2 import Card, Planka
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from typer.core import TyperGroup from typer.core import TyperGroup
TOKEN_ENV_VAR = "PLANKATOKENS"
def get_binary_dir() -> Path: DEFAULT_TOKEN_DIR = Path.home() / ".config" / "planka-cli" / "tokens"
argv0 = sys.argv[0] CREDENTIALS_FILENAME = "credentials.json"
path = Path(argv0) TOKENSTORE_OVERRIDE: Optional[str] = None
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)
class HelpOnUnknownCommandGroup(TyperGroup): class HelpOnUnknownCommandGroup(TyperGroup):
@@ -56,18 +43,68 @@ app.add_typer(notifications_app, name="notifications")
@app.callback(invoke_without_command=True) @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.""" """Planka CLI."""
global TOKENSTORE_OVERRIDE
TOKENSTORE_OVERRIDE = tokenstore
if ctx.invoked_subcommand is None: if ctx.invoked_subcommand is None:
click.echo(ctx.get_help()) 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]]: def get_env_config() -> tuple[Optional[str], Optional[str], Optional[str]]:
return ( planka_url = os.getenv("PLANKA_URL")
os.getenv("PLANKA_URL"), planka_username = os.getenv("PLANKA_USERNAME")
os.getenv("PLANKA_USERNAME"), planka_password = os.getenv("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]: 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: def get_planka() -> Planka:
planka_url, planka_username, planka_password = get_env_config() planka_url, planka_username, planka_password = get_env_config()
if not planka_url or not planka_username or not planka_password: if not planka_url or not planka_username or not planka_password:
credentials_path = get_credentials_path()
console.print( console.print(
"[bold red]Error:[/bold red] Missing credentials. " "[bold red]Error:[/bold red] Missing credentials. "
f"Set PLANKA_URL, PLANKA_USERNAME, and PLANKA_PASSWORD or run " f"Set PLANKA_URL, PLANKA_USERNAME, and PLANKA_PASSWORD or run "
f"`planka-cli login --url ... --username ... --password ...` " f"`planka-cli login --url ... --username ... --password ...` "
f"to write {ENV_PATH}." f"to write {credentials_path}."
) )
sys.exit(1) sys.exit(1)
@@ -185,35 +223,46 @@ def login(
username: str = typer.Option(..., "--username", "-n", help="Planka username"), username: str = typer.Option(..., "--username", "-n", help="Planka username"),
password: str = typer.Option(..., "--password", "-p", help="Planka password"), 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: try:
ENV_PATH.parent.mkdir(parents=True, exist_ok=True) credentials_path.parent.mkdir(parents=True, exist_ok=True)
set_key(str(ENV_PATH), "PLANKA_URL", url) credentials_path.write_text(
set_key(str(ENV_PATH), "PLANKA_USERNAME", username) json.dumps(
set_key(str(ENV_PATH), "PLANKA_PASSWORD", password) {
"PLANKA_URL": url,
"PLANKA_USERNAME": username,
"PLANKA_PASSWORD": password,
},
indent=2,
)
+ "\n"
)
except OSError as e: 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) raise typer.Exit(1)
try: try:
os.chmod(ENV_PATH, 0o600) os.chmod(credentials_path, 0o600)
except OSError: except OSError:
pass pass
console.print(f"[green]Saved credentials to[/green] {ENV_PATH}") console.print(f"[green]Saved credentials to[/green] {credentials_path}")
@app.command() @app.command()
def logout(): def logout():
"""Delete the stored .env file next to the binary.""" """Delete the stored ~/.config/planka-cli/tokens/credentials.json file."""
if not ENV_PATH.exists(): token_dir = get_token_dir()
console.print(f"No stored credentials found at {ENV_PATH}") credentials_path = get_credentials_path()
if not credentials_path.exists():
console.print(f"No stored credentials found at {credentials_path}")
return return
try: try:
ENV_PATH.unlink() shutil.rmtree(token_dir)
except OSError as e: 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) raise typer.Exit(1)
console.print("[green]Logged out.[/green] Removed stored credentials.") console.print("[green]Logged out.[/green] Removed stored credentials.")
+1 -4
View File
@@ -66,10 +66,7 @@ class TestLoginLogout:
def test_logout_without_credentials(self, tmp_path, monkeypatch): def test_logout_without_credentials(self, tmp_path, monkeypatch):
"""Logout without stored credentials should be graceful.""" """Logout without stored credentials should be graceful."""
# Point ENV_PATH to a temp location monkeypatch.setenv("PLANKATOKENS", str(tmp_path))
import scripts.planka_cli as cli_module
monkeypatch.setattr(cli_module, "ENV_PATH", tmp_path / ".env")
result = runner.invoke(app, ["logout"]) result = runner.invoke(app, ["logout"])
assert result.exit_code == 0 assert result.exit_code == 0
assert "No stored credentials" in result.output assert "No stored credentials" in result.output
Generated
-11
View File
@@ -170,7 +170,6 @@ source = { editable = "." }
dependencies = [ dependencies = [
{ name = "plankapy", version = "2.2.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { 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 = "plankapy", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
{ name = "python-dotenv" },
{ name = "rich" }, { name = "rich" },
{ name = "typer" }, { name = "typer" },
] ]
@@ -185,7 +184,6 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "plankapy", specifier = ">=2.2.2" }, { name = "plankapy", specifier = ">=2.2.2" },
{ name = "python-dotenv" },
{ name = "rich" }, { name = "rich" },
{ name = "typer" }, { 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" }, { 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]] [[package]]
name = "pywin32-ctypes" name = "pywin32-ctypes"
version = "0.2.3" version = "0.2.3"