feat: update Makefile and README for new linting and testing commands; refactor credential storage to JSON format
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"plankapy>=2.2.2",
|
||||
"typer",
|
||||
"python-dotenv",
|
||||
"rich",
|
||||
]
|
||||
|
||||
|
||||
+87
-38
@@ -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.")
|
||||
|
||||
+1
-4
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user