Skip to content

Commit 8d03b2e

Browse files
authored
Merge pull request #40 from taskbadger/sk/project-keys
Support project API keys with auto-detection of org/project
2 parents 9d92479 + b4662b2 commit 8d03b2e

10 files changed

Lines changed: 300 additions & 17 deletions

taskbadger/cli_main.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from taskbadger import __version__
77
from taskbadger.cli import create, get, list_tasks_command, run, update
88
from taskbadger.config import get_config, write_config
9+
from taskbadger.sdk import _parse_token
910

1011
app = typer.Typer(
1112
rich_markup_mode="rich",
@@ -30,9 +31,18 @@ def version_callback(value: bool):
3031
def configure(ctx: typer.Context):
3132
"""Update CLI configuration."""
3233
config = ctx.meta["tb_config"]
33-
config.organization_slug = typer.prompt("Organization slug", default=config.organization_slug)
34-
config.project_slug = typer.prompt("Project slug", default=config.project_slug)
35-
config.token = typer.prompt("API Key", default=config.token)
34+
token = typer.prompt("API Key", default=config.token)
35+
parsed = _parse_token(token)
36+
if parsed:
37+
org_slug, project_slug, api_key = parsed
38+
print(f"Project key detected — organization: [green]{org_slug}[/green], project: [green]{project_slug}[/green]")
39+
config.organization_slug = org_slug
40+
config.project_slug = project_slug
41+
config.token = token
42+
else:
43+
config.organization_slug = typer.prompt("Organization slug", default=config.organization_slug)
44+
config.project_slug = typer.prompt("Project slug", default=config.project_slug)
45+
config.token = token
3646
path = write_config(config)
3747
print(f"Config written to [green]{path}[/green]")
3848

taskbadger/config.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import typer
88
from tomlkit import document, table
99

10-
from taskbadger.sdk import _TB_HOST, _init
10+
from taskbadger.sdk import _TB_HOST, _init, _parse_token
1111

1212
APP_NAME = "taskbadger"
1313

@@ -47,10 +47,20 @@ def from_dict(config_dict, **overrides) -> "Config":
4747
"""
4848
defaults = config_dict.get("defaults", {})
4949
auth = config_dict.get("auth", {})
50+
token = overrides.get("token") or _from_env("API_KEY", auth.get("token"))
51+
organization_slug = overrides.get("org") or _from_env("ORG", defaults.get("org"))
52+
project_slug = overrides.get("project") or _from_env("PROJECT", defaults.get("project"))
53+
54+
if token:
55+
parsed = _parse_token(token)
56+
if parsed:
57+
organization_slug = parsed[0]
58+
project_slug = parsed[1]
59+
5060
return Config(
51-
token=overrides.get("token") or _from_env("API_KEY", auth.get("token")),
52-
organization_slug=overrides.get("org") or _from_env("ORG", defaults.get("org")),
53-
project_slug=overrides.get("project") or _from_env("PROJECT", defaults.get("project")),
61+
token=token,
62+
organization_slug=organization_slug,
63+
project_slug=project_slug,
5464
host=overrides.get("host") or auth.get("host"),
5565
tags=config_dict.get("tags", {}),
5666
)

taskbadger/sdk.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import base64
12
import datetime
23
import logging
34
import os
@@ -35,6 +36,26 @@
3536
_TB_HOST = "https://taskbadger.net"
3637

3738

39+
def _parse_token(token):
40+
"""Try to decode a project API key.
41+
42+
Project keys are base64-encoded strings in the format ``org/project/key``.
43+
44+
Returns:
45+
A tuple of ``(organization_slug, project_slug, api_key)`` if *token*
46+
is a valid project key, otherwise ``None``.
47+
"""
48+
try:
49+
decoded = base64.b64decode(token, validate=True).decode("utf-8")
50+
except Exception:
51+
return None
52+
53+
parts = decoded.split("/")
54+
if len(parts) == 3 and all(parts):
55+
return tuple(parts)
56+
return None
57+
58+
3859
def init(
3960
organization_slug: str = None,
4061
project_slug: str = None,
@@ -43,9 +64,16 @@ def init(
4364
tags: dict[str, str] = None,
4465
before_create: Callback = None,
4566
):
46-
"""Initialize Task Badger client
67+
"""Initialize Task Badger client.
68+
69+
If *token* is a project API key (base64-encoded ``org/project/key``),
70+
the organization and project slugs are extracted automatically and
71+
*organization_slug* / *project_slug* are ignored.
4772
48-
Call this function once per thread
73+
For legacy API keys, *organization_slug* and *project_slug* are
74+
required and a deprecation warning is emitted.
75+
76+
Call this function once per thread.
4977
"""
5078
_init(_TB_HOST, organization_slug, project_slug, token, systems, tags, before_create)
5179

@@ -64,6 +92,17 @@ def _init(
6492
project_slug = project_slug or os.environ.get("TASKBADGER_PROJECT")
6593
token = token or os.environ.get("TASKBADGER_API_KEY")
6694

95+
if token:
96+
parsed = _parse_token(token)
97+
if parsed:
98+
organization_slug, project_slug, token = parsed
99+
else:
100+
warnings.warn(
101+
"Legacy API keys are deprecated. Please switch to a project API key.",
102+
DeprecationWarning,
103+
stacklevel=3,
104+
)
105+
67106
if before_create and isinstance(before_create, str):
68107
try:
69108
before_create = import_string(before_create)

tests/test_celery_system_integration.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import logging
1212
import sys
13+
import time
1314
import weakref
1415
from http import HTTPStatus
1516
from unittest import mock
@@ -26,6 +27,18 @@
2627
from tests.utils import task_for_test
2728

2829

30+
def _wait_for_mock_calls(mock_obj, expected_count, timeout=5):
31+
"""Wait for a mock to reach the expected call count.
32+
33+
Celery stores the task result before firing task_success, so
34+
``result.get()`` can return before the success signal handler runs.
35+
Without this wait the mock context may exit before the handler fires.
36+
"""
37+
deadline = time.monotonic() + timeout
38+
while mock_obj.call_count < expected_count and time.monotonic() < deadline:
39+
time.sleep(0.05)
40+
41+
2942
@pytest.fixture()
3043
def _bind_settings_with_system():
3144
systems = [CelerySystemIntegration()]
@@ -71,6 +84,7 @@ def add_normal(self, a, b):
7184
result = add_normal.delay(2, 2)
7285
assert result.info.get("taskbadger_task_id") == tb_task.id
7386
assert result.get(timeout=10, propagate=True) == 4
87+
_wait_for_mock_calls(update, 2)
7488

7589
create.assert_called_once()
7690
assert get_task.call_count == 1
@@ -102,6 +116,7 @@ def add_normal(self, a, b):
102116
result = add_normal.delay(2, 2)
103117
assert result.info.get("taskbadger_task_id") == tb_task.id
104118
assert result.get(timeout=10, propagate=True) == 4
119+
_wait_for_mock_calls(update, 2)
105120

106121
create.assert_called_once_with(
107122
"tests.test_celery_system_integration.add_normal",

tests/test_cli_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def test_info_config_env_args():
112112

113113

114114
def test_configure(mock_config_location):
115-
result = runner.invoke(app, ["configure"], input="an-org\na-project\na-token")
115+
result = runner.invoke(app, ["configure"], input="a-token\nan-org\na-project")
116116
assert result.exit_code == 0
117117
assert mock_config_location.is_file()
118118
with mock_config_location.open("rt", encoding="utf-8") as fp:

tests/test_init.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import warnings
2+
13
import pytest
24

35
from taskbadger import Badger, init
@@ -14,16 +16,22 @@ def _reset():
1416

1517

1618
def test_init():
17-
init("org", "project", "token", before_create=lambda x: x)
19+
with warnings.catch_warnings():
20+
warnings.simplefilter("ignore", DeprecationWarning)
21+
init("org", "project", "token", before_create=lambda x: x)
1822

1923

2024
def test_init_import_before_create():
21-
init("org", "project", "token", before_create="tests.test_init._before_create")
25+
with warnings.catch_warnings():
26+
warnings.simplefilter("ignore", DeprecationWarning)
27+
init("org", "project", "token", before_create="tests.test_init._before_create")
2228

2329

2430
def test_init_import_before_create_fail():
25-
with pytest.raises(ConfigurationError):
26-
init("org", "project", "token", before_create="missing")
31+
with warnings.catch_warnings():
32+
warnings.simplefilter("ignore", DeprecationWarning)
33+
with pytest.raises(ConfigurationError):
34+
init("org", "project", "token", before_create="missing")
2735

2836

2937
def _before_create(_):

0 commit comments

Comments
 (0)