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:
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
+7 -3
View File
@@ -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
```
-1
View File
@@ -10,7 +10,6 @@ requires-python = ">=3.11"
dependencies = [
"plankapy>=2.2.2",
"typer",
"python-dotenv",
"rich",
]
+87 -38
View File
@@ -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_env_config() -> tuple[Optional[str], Optional[str], Optional[str]]:
return (
os.getenv("PLANKA_URL"),
os.getenv("PLANKA_USERNAME"),
os.getenv("PLANKA_PASSWORD"),
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]]:
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.")
+1 -4
View File
@@ -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
Generated
-11
View File
@@ -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"