Skip to content

Commit 4564a2e

Browse files
committed
Support project API keys with auto-detection of org/project
Project API keys are base64-encoded strings containing org/project/key. When detected, org and project slugs are extracted automatically, simplifying initialization. Legacy keys continue to work but emit a deprecation warning advising users to switch to project keys. - Add _parse_token() utility for base64 project key detection - Update _init() to decode project keys and extract org/project - Update Config.from_dict() to extract org/project for display/validation - Update CLI configure command to prompt for API key first and skip org/project prompts when a project key is detected - Add comprehensive tests for project key support
1 parent 9d92479 commit 4564a2e

9 files changed

Lines changed: 285 additions & 17 deletions

File tree

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_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(_):

tests/test_project_key.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import base64
2+
import os
3+
from pathlib import Path
4+
from unittest import mock
5+
6+
import pytest
7+
import tomlkit
8+
from typer.testing import CliRunner
9+
10+
from taskbadger.cli_main import app
11+
from taskbadger.config import Config
12+
from taskbadger.mug import Badger, _local
13+
from taskbadger.sdk import _parse_token, init
14+
15+
16+
def _make_project_key(org="myorg", project="myproject", key="secret123"):
17+
return base64.b64encode(f"{org}/{project}/{key}".encode()).decode()
18+
19+
20+
# --- _parse_token tests ---
21+
22+
23+
class TestParseToken:
24+
def test_valid_project_key(self):
25+
token = _make_project_key("org1", "proj1", "apikey")
26+
result = _parse_token(token)
27+
assert result == ("org1", "proj1", "apikey")
28+
29+
def test_legacy_key(self):
30+
result = _parse_token("some-legacy-api-key")
31+
assert result is None
32+
33+
def test_invalid_base64(self):
34+
result = _parse_token("!!!not-base64!!!")
35+
assert result is None
36+
37+
def test_base64_but_wrong_format_two_parts(self):
38+
token = base64.b64encode(b"only/two").decode()
39+
result = _parse_token(token)
40+
assert result is None
41+
42+
def test_base64_but_wrong_format_four_parts(self):
43+
token = base64.b64encode(b"a/b/c/d").decode()
44+
result = _parse_token(token)
45+
assert result is None
46+
47+
def test_base64_with_empty_parts(self):
48+
token = base64.b64encode(b"org//key").decode()
49+
result = _parse_token(token)
50+
assert result is None
51+
52+
def test_empty_string(self):
53+
result = _parse_token("")
54+
assert result is None
55+
56+
57+
# --- init() tests ---
58+
59+
60+
@pytest.fixture(autouse=True)
61+
def _reset_badger():
62+
b_global = Badger.current
63+
_local.set(Badger())
64+
yield
65+
_local.set(b_global)
66+
67+
68+
class TestInitWithProjectKey:
69+
def test_init_with_project_key(self):
70+
token = _make_project_key("org1", "proj1", "apikey")
71+
init(token=token)
72+
settings = Badger.current.settings
73+
assert settings.organization_slug == "org1"
74+
assert settings.project_slug == "proj1"
75+
assert settings.token == "apikey"
76+
77+
def test_init_project_key_overrides_org_project(self):
78+
token = _make_project_key("org1", "proj1", "apikey")
79+
init(organization_slug="ignored", project_slug="ignored", token=token)
80+
settings = Badger.current.settings
81+
assert settings.organization_slug == "org1"
82+
assert settings.project_slug == "proj1"
83+
assert settings.token == "apikey"
84+
85+
def test_init_project_key_via_env(self):
86+
token = _make_project_key("org1", "proj1", "apikey")
87+
with mock.patch.dict(os.environ, {"TASKBADGER_API_KEY": token}):
88+
init()
89+
settings = Badger.current.settings
90+
assert settings.organization_slug == "org1"
91+
assert settings.project_slug == "proj1"
92+
assert settings.token == "apikey"
93+
94+
def test_init_project_key_no_deprecation_warning(self, recwarn):
95+
token = _make_project_key()
96+
init(token=token)
97+
deprecation_warnings = [w for w in recwarn if issubclass(w.category, DeprecationWarning)]
98+
assert len(deprecation_warnings) == 0
99+
100+
def test_init_legacy_key_emits_deprecation_warning(self):
101+
with pytest.warns(DeprecationWarning, match="Legacy API keys are deprecated"):
102+
init("org", "project", "legacy-token")
103+
104+
def test_init_legacy_key_still_works(self):
105+
with pytest.warns(DeprecationWarning):
106+
init("org", "project", "legacy-token")
107+
settings = Badger.current.settings
108+
assert settings.organization_slug == "org"
109+
assert settings.project_slug == "project"
110+
assert settings.token == "legacy-token"
111+
112+
113+
# --- Config.from_dict tests ---
114+
115+
116+
class TestConfigFromDictWithProjectKey:
117+
def test_project_key_in_config(self):
118+
token = _make_project_key("org1", "proj1", "apikey")
119+
config = Config.from_dict({"auth": {"token": token}})
120+
assert config.organization_slug == "org1"
121+
assert config.project_slug == "proj1"
122+
# Token remains as original base64 string (decoded by _init at init time)
123+
assert config.token == token
124+
125+
def test_project_key_overrides_config_org_project(self):
126+
token = _make_project_key("org1", "proj1", "apikey")
127+
config = Config.from_dict(
128+
{
129+
"auth": {"token": token},
130+
"defaults": {"org": "old-org", "project": "old-project"},
131+
}
132+
)
133+
assert config.organization_slug == "org1"
134+
assert config.project_slug == "proj1"
135+
136+
def test_project_key_via_env(self):
137+
token = _make_project_key("org1", "proj1", "apikey")
138+
with mock.patch.dict(os.environ, {"TASKBADGER_API_KEY": token}):
139+
config = Config.from_dict({})
140+
assert config.organization_slug == "org1"
141+
assert config.project_slug == "proj1"
142+
assert config.token == token
143+
144+
def test_project_key_is_valid(self):
145+
token = _make_project_key("org1", "proj1", "apikey")
146+
config = Config.from_dict({"auth": {"token": token}})
147+
assert config.is_valid()
148+
149+
150+
# --- CLI configure tests ---
151+
152+
runner = CliRunner()
153+
154+
155+
@pytest.fixture()
156+
def mock_config_location():
157+
config_path = Path(__file__).parent / "_mock_config_project_key"
158+
with mock.patch("taskbadger.config._get_config_path", return_value=config_path):
159+
yield config_path
160+
if config_path.exists():
161+
os.remove(config_path)
162+
163+
164+
class TestCLIConfigureProjectKey:
165+
def test_configure_with_project_key(self, mock_config_location):
166+
token = _make_project_key("myorg", "myproj", "mykey")
167+
result = runner.invoke(app, ["configure"], input=f"{token}\n")
168+
assert result.exit_code == 0
169+
assert "Project key detected" in result.stdout
170+
assert "myorg" in result.stdout
171+
assert "myproj" in result.stdout
172+
173+
with mock_config_location.open("rt", encoding="utf-8") as fp:
174+
raw_config = tomlkit.load(fp)
175+
config_dict = raw_config.unwrap()
176+
assert config_dict["defaults"]["org"] == "myorg"
177+
assert config_dict["defaults"]["project"] == "myproj"
178+
assert config_dict["auth"]["token"] == token
179+
180+
def test_configure_with_legacy_key(self, mock_config_location):
181+
result = runner.invoke(app, ["configure"], input="a-token\nan-org\na-project\n")
182+
assert result.exit_code == 0
183+
assert "Project key detected" not in result.stdout
184+
185+
with mock_config_location.open("rt", encoding="utf-8") as fp:
186+
raw_config = tomlkit.load(fp)
187+
config_dict = raw_config.unwrap()
188+
assert config_dict["defaults"]["org"] == "an-org"
189+
assert config_dict["defaults"]["project"] == "a-project"
190+
assert config_dict["auth"]["token"] == "a-token"

tests/test_scope.py

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

35
from taskbadger import create_task, init
@@ -46,7 +48,9 @@ def test_scope_context():
4648

4749
@pytest.fixture(autouse=True)
4850
def _init_skd():
49-
init("org", "project", "token")
51+
with warnings.catch_warnings():
52+
warnings.simplefilter("ignore", DeprecationWarning)
53+
init("org", "project", "token")
5054

5155

5256
def test_create_task_with_scope(httpx_mock):

tests/test_sdk.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
import warnings
23
from http import HTTPStatus
34
from unittest import mock
45

@@ -18,7 +19,9 @@
1819

1920
@pytest.fixture(autouse=True)
2021
def _init_skd():
21-
init("org", "project", "token")
22+
with warnings.catch_warnings():
23+
warnings.simplefilter("ignore", DeprecationWarning)
24+
init("org", "project", "token")
2225

2326

2427
@pytest.fixture()

0 commit comments

Comments
 (0)