taskbadger 1.6.2__tar.gz → 1.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {taskbadger-1.6.2 → taskbadger-1.7.0}/PKG-INFO +1 -1
  2. {taskbadger-1.6.2 → taskbadger-1.7.0}/pyproject.toml +1 -1
  3. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/celery.py +85 -1
  4. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/cli_main.py +13 -3
  5. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/config.py +14 -4
  6. taskbadger-1.7.0/taskbadger/internal/api/action_endpoints/__init__.py +1 -0
  7. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/api/action_endpoints/action_cancel.py +1 -0
  8. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/api/action_endpoints/action_create.py +2 -2
  9. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/api/action_endpoints/action_get.py +1 -0
  10. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/api/action_endpoints/action_list.py +1 -0
  11. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/api/action_endpoints/action_partial_update.py +2 -2
  12. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/api/action_endpoints/action_update.py +2 -2
  13. taskbadger-1.7.0/taskbadger/internal/api/task_endpoints/__init__.py +1 -0
  14. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/api/task_endpoints/task_cancel.py +1 -0
  15. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/api/task_endpoints/task_create.py +2 -2
  16. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/api/task_endpoints/task_get.py +1 -0
  17. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/api/task_endpoints/task_list.py +1 -0
  18. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/api/task_endpoints/task_partial_update.py +2 -2
  19. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/api/task_endpoints/task_update.py +2 -2
  20. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/models/action.py +3 -2
  21. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/models/action_request.py +3 -2
  22. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/models/paginated_task_list.py +3 -2
  23. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/models/patched_action_request.py +3 -2
  24. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/models/patched_task_request.py +24 -2
  25. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/models/patched_task_request_tags.py +3 -2
  26. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/models/task.py +24 -2
  27. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/models/task_request.py +24 -2
  28. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/models/task_request_tags.py +3 -2
  29. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/models/task_tags.py +3 -2
  30. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/types.py +13 -5
  31. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/sdk.py +41 -2
  32. taskbadger-1.6.2/taskbadger/internal/api/action_endpoints/__init__.py +0 -0
  33. taskbadger-1.6.2/taskbadger/internal/api/task_endpoints/__init__.py +0 -0
  34. {taskbadger-1.6.2 → taskbadger-1.7.0}/.gitignore +0 -0
  35. {taskbadger-1.6.2 → taskbadger-1.7.0}/LICENSE +0 -0
  36. {taskbadger-1.6.2 → taskbadger-1.7.0}/README.md +0 -0
  37. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/__init__.py +0 -0
  38. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/cli/__init__.py +0 -0
  39. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/cli/basics.py +0 -0
  40. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/cli/list_tasks.py +0 -0
  41. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/cli/utils.py +0 -0
  42. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/cli/wrapper.py +0 -0
  43. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/decorators.py +0 -0
  44. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/exceptions.py +0 -0
  45. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/integrations.py +0 -0
  46. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/__init__.py +0 -0
  47. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/api/__init__.py +0 -0
  48. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/client.py +0 -0
  49. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/errors.py +0 -0
  50. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/models/__init__.py +0 -0
  51. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/models/status_enum.py +0 -0
  52. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/internal/py.typed +0 -0
  53. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/mug.py +0 -0
  54. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/process.py +0 -0
  55. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/safe_sdk.py +0 -0
  56. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/systems/__init__.py +0 -0
  57. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/systems/celery.py +0 -0
  58. {taskbadger-1.6.2 → taskbadger-1.7.0}/taskbadger/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: taskbadger
3
- Version: 1.6.2
3
+ Version: 1.7.0
4
4
  Summary: The official Python SDK for Task Badger
5
5
  Project-URL: Changelog, https://github.com/taskbadger/taskbadger-python/releases
6
6
  Project-URL: homepage, https://taskbadger.net/
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "taskbadger"
3
- version = "1.6.2"
3
+ version = "1.7.0"
4
4
  description = "The official Python SDK for Task Badger"
5
5
  requires-python = ">=3.9"
6
6
  authors = []
@@ -131,7 +131,8 @@ class Task(celery.Task):
131
131
 
132
132
  result = super().apply_async(*args, **kwargs)
133
133
 
134
- tb_task_id = result.info.get(TB_TASK_ID) if result.info else None
134
+ info = result.info
135
+ tb_task_id = info.get(TB_TASK_ID) if isinstance(info, dict) else None
135
136
  setattr(result, TB_TASK_ID, tb_task_id)
136
137
 
137
138
  _get_task = functools.partial(get_task, tb_task_id) if tb_task_id else lambda: None
@@ -212,8 +213,91 @@ def task_publish_handler(sender=None, headers=None, body=None, **kwargs):
212
213
  ctask.update_state(task_id=headers["id"], state="PENDING", meta=meta)
213
214
 
214
215
 
216
+ def _maybe_create_task(signal_sender):
217
+ """Create a TaskBadger task if one doesn't exist yet.
218
+
219
+ This handles cases where before_task_publish didn't fire or was skipped:
220
+ - Eager mode (before_task_publish doesn't fire)
221
+ - Canvas primitives like map/starmap/chunks (fire for celery.* tasks)
222
+ """
223
+ # Check if task was already created FIRST (before accessing Badger)
224
+ # This avoids initializing thread-local Badger state for tasks like celery.ping
225
+ task_id = _get_taskbadger_task_id(signal_sender.request)
226
+ if task_id:
227
+ return
228
+
229
+ task_name = signal_sender.name
230
+
231
+ # Skip built-in celery tasks that we don't track (like celery.ping)
232
+ # Only handle celery.map and celery.starmap specially
233
+ if task_name.startswith("celery.") and task_name not in ("celery.map", "celery.starmap"):
234
+ return
235
+
236
+ # For non-canvas tasks, only create if there was an explicit intent to track
237
+ # (indicated by taskbadger_track header). This prevents creating tasks when
238
+ # Badger wasn't configured at publish time but has stale config in worker.
239
+ headers = signal_sender.request.headers or {}
240
+ is_canvas_task = task_name in ("celery.map", "celery.starmap")
241
+ if not is_canvas_task and not headers.get("taskbadger_track"):
242
+ return
243
+
244
+ # NOW it's safe to check Badger configuration
245
+ if not Badger.is_configured():
246
+ return
247
+
248
+ celery_system = Badger.current.settings.get_system_by_id("celery")
249
+ data = None
250
+ inner_task = None
251
+
252
+ # Handle celery.map and celery.starmap - extract the inner task name
253
+ if task_name in ("celery.map", "celery.starmap"):
254
+ canvas_type = task_name.split(".")[-1] # "map" or "starmap"
255
+ inner_task_info = signal_sender.request.kwargs.get("task")
256
+ if inner_task_info:
257
+ # inner_task_info can be a dict (serialized signature) or a Signature object
258
+ if isinstance(inner_task_info, dict):
259
+ task_name = inner_task_info.get("task", task_name)
260
+ elif hasattr(inner_task_info, "name"):
261
+ task_name = inner_task_info.name
262
+ # Get the actual task class to check if it uses Task base
263
+ inner_task = celery.current_app.tasks.get(task_name)
264
+ items = signal_sender.request.kwargs.get("it", [])
265
+ # Convert to list if needed for counting and potential recording
266
+ items_list = list(items) if not isinstance(items, (list, tuple)) else items
267
+ item_count = len(items_list)
268
+ # Append canvas type and item count to task name
269
+ task_name = f"{task_name} ({canvas_type} {item_count})"
270
+ data = {"canvas_type": signal_sender.name, "item_count": item_count}
271
+
272
+ # Include task items if record_task_args is enabled
273
+ if celery_system and celery_system.record_task_args:
274
+ try:
275
+ _, _, value = serialization.dumps({"items": items_list}, serializer="json")
276
+ items_data = json.loads(value)
277
+ data["celery_task_items"] = items_data["items"]
278
+ except Exception:
279
+ log.warning("Error serializing canvas items for task '%s'", task_name)
280
+
281
+ # Check if we should track this task
282
+ auto_track = celery_system and celery_system.track_task(task_name)
283
+ # Check if the task (or inner task for map/starmap) uses our Task base class
284
+ task_to_check = inner_task if inner_task else signal_sender
285
+ manual_track = isinstance(task_to_check, Task)
286
+ if not manual_track and not auto_track:
287
+ return
288
+
289
+ enter_session()
290
+
291
+ task = create_task_safe(task_name, status=StatusEnum.PENDING, data=data)
292
+ if task:
293
+ # Store the task ID in the request so _update_task can find it
294
+ signal_sender.request.update({TB_TASK_ID: task.id})
295
+ safe_get_task.cache.set((task.id,), task)
296
+
297
+
215
298
  @task_prerun.connect
216
299
  def task_prerun_handler(sender=None, **kwargs):
300
+ _maybe_create_task(sender)
217
301
  _update_task(sender, StatusEnum.PROCESSING)
218
302
 
219
303
 
@@ -6,6 +6,7 @@ from rich import print
6
6
  from taskbadger import __version__
7
7
  from taskbadger.cli import create, get, list_tasks_command, run, update
8
8
  from taskbadger.config import get_config, write_config
9
+ from taskbadger.sdk import _parse_token
9
10
 
10
11
  app = typer.Typer(
11
12
  rich_markup_mode="rich",
@@ -30,9 +31,18 @@ def version_callback(value: bool):
30
31
  def configure(ctx: typer.Context):
31
32
  """Update CLI configuration."""
32
33
  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
36
46
  path = write_config(config)
37
47
  print(f"Config written to [green]{path}[/green]")
38
48
 
@@ -7,7 +7,7 @@ import tomlkit
7
7
  import typer
8
8
  from tomlkit import document, table
9
9
 
10
- from taskbadger.sdk import _TB_HOST, _init
10
+ from taskbadger.sdk import _TB_HOST, _init, _parse_token
11
11
 
12
12
  APP_NAME = "taskbadger"
13
13
 
@@ -47,10 +47,20 @@ class Config:
47
47
  """
48
48
  defaults = config_dict.get("defaults", {})
49
49
  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
+
50
60
  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,
54
64
  host=overrides.get("host") or auth.get("host"),
55
65
  tags=config_dict.get("tags", {}),
56
66
  )
@@ -0,0 +1 @@
1
+ """Contains endpoint functions for accessing the API"""
@@ -26,6 +26,7 @@ def _get_kwargs(
26
26
  def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Any]:
27
27
  if response.status_code == 204:
28
28
  return None
29
+
29
30
  if client.raise_on_unexpected_status:
30
31
  raise errors.UnexpectedStatus(response.status_code, response.content)
31
32
  else:
@@ -24,9 +24,8 @@ def _get_kwargs(
24
24
  "url": f"/api/{organization_slug}/{project_slug}/tasks/{task_id}/actions/",
25
25
  }
26
26
 
27
- _body = body.to_dict()
27
+ _kwargs["json"] = body.to_dict()
28
28
 
29
- _kwargs["json"] = _body
30
29
  headers["Content-Type"] = "application/json"
31
30
 
32
31
  _kwargs["headers"] = headers
@@ -38,6 +37,7 @@ def _parse_response(*, client: Union[AuthenticatedClient, Client], response: htt
38
37
  response_201 = Action.from_dict(response.json())
39
38
 
40
39
  return response_201
40
+
41
41
  if client.raise_on_unexpected_status:
42
42
  raise errors.UnexpectedStatus(response.status_code, response.content)
43
43
  else:
@@ -29,6 +29,7 @@ def _parse_response(*, client: Union[AuthenticatedClient, Client], response: htt
29
29
  response_200 = Action.from_dict(response.json())
30
30
 
31
31
  return response_200
32
+
32
33
  if client.raise_on_unexpected_status:
33
34
  raise errors.UnexpectedStatus(response.status_code, response.content)
34
35
  else:
@@ -34,6 +34,7 @@ def _parse_response(
34
34
  response_200.append(response_200_item)
35
35
 
36
36
  return response_200
37
+
37
38
  if client.raise_on_unexpected_status:
38
39
  raise errors.UnexpectedStatus(response.status_code, response.content)
39
40
  else:
@@ -26,9 +26,8 @@ def _get_kwargs(
26
26
  "url": f"/api/{organization_slug}/{project_slug}/tasks/{task_id}/actions/{id}/",
27
27
  }
28
28
 
29
- _body = body.to_dict()
29
+ _kwargs["json"] = body.to_dict()
30
30
 
31
- _kwargs["json"] = _body
32
31
  headers["Content-Type"] = "application/json"
33
32
 
34
33
  _kwargs["headers"] = headers
@@ -40,6 +39,7 @@ def _parse_response(*, client: Union[AuthenticatedClient, Client], response: htt
40
39
  response_200 = Action.from_dict(response.json())
41
40
 
42
41
  return response_200
42
+
43
43
  if client.raise_on_unexpected_status:
44
44
  raise errors.UnexpectedStatus(response.status_code, response.content)
45
45
  else:
@@ -26,9 +26,8 @@ def _get_kwargs(
26
26
  "url": f"/api/{organization_slug}/{project_slug}/tasks/{task_id}/actions/{id}/",
27
27
  }
28
28
 
29
- _body = body.to_dict()
29
+ _kwargs["json"] = body.to_dict()
30
30
 
31
- _kwargs["json"] = _body
32
31
  headers["Content-Type"] = "application/json"
33
32
 
34
33
  _kwargs["headers"] = headers
@@ -40,6 +39,7 @@ def _parse_response(*, client: Union[AuthenticatedClient, Client], response: htt
40
39
  response_200 = Action.from_dict(response.json())
41
40
 
42
41
  return response_200
42
+
43
43
  if client.raise_on_unexpected_status:
44
44
  raise errors.UnexpectedStatus(response.status_code, response.content)
45
45
  else:
@@ -0,0 +1 @@
1
+ """Contains endpoint functions for accessing the API"""
@@ -25,6 +25,7 @@ def _get_kwargs(
25
25
  def _parse_response(*, client: Union[AuthenticatedClient, Client], response: httpx.Response) -> Optional[Any]:
26
26
  if response.status_code == 204:
27
27
  return None
28
+
28
29
  if client.raise_on_unexpected_status:
29
30
  raise errors.UnexpectedStatus(response.status_code, response.content)
30
31
  else:
@@ -27,9 +27,8 @@ def _get_kwargs(
27
27
  "url": f"/api/{organization_slug}/{project_slug}/tasks/",
28
28
  }
29
29
 
30
- _body = body.to_dict()
30
+ _kwargs["json"] = body.to_dict()
31
31
 
32
- _kwargs["json"] = _body
33
32
  headers["Content-Type"] = "application/json"
34
33
 
35
34
  _kwargs["headers"] = headers
@@ -41,6 +40,7 @@ def _parse_response(*, client: Union[AuthenticatedClient, Client], response: htt
41
40
  response_201 = Task.from_dict(response.json())
42
41
 
43
42
  return response_201
43
+
44
44
  if client.raise_on_unexpected_status:
45
45
  raise errors.UnexpectedStatus(response.status_code, response.content)
46
46
  else:
@@ -28,6 +28,7 @@ def _parse_response(*, client: Union[AuthenticatedClient, Client], response: htt
28
28
  response_200 = Task.from_dict(response.json())
29
29
 
30
30
  return response_200
31
+
31
32
  if client.raise_on_unexpected_status:
32
33
  raise errors.UnexpectedStatus(response.status_code, response.content)
33
34
  else:
@@ -40,6 +40,7 @@ def _parse_response(
40
40
  response_200 = PaginatedTaskList.from_dict(response.json())
41
41
 
42
42
  return response_200
43
+
43
44
  if client.raise_on_unexpected_status:
44
45
  raise errors.UnexpectedStatus(response.status_code, response.content)
45
46
  else:
@@ -25,9 +25,8 @@ def _get_kwargs(
25
25
  "url": f"/api/{organization_slug}/{project_slug}/tasks/{id}/",
26
26
  }
27
27
 
28
- _body = body.to_dict()
28
+ _kwargs["json"] = body.to_dict()
29
29
 
30
- _kwargs["json"] = _body
31
30
  headers["Content-Type"] = "application/json"
32
31
 
33
32
  _kwargs["headers"] = headers
@@ -39,6 +38,7 @@ def _parse_response(*, client: Union[AuthenticatedClient, Client], response: htt
39
38
  response_200 = Task.from_dict(response.json())
40
39
 
41
40
  return response_200
41
+
42
42
  if client.raise_on_unexpected_status:
43
43
  raise errors.UnexpectedStatus(response.status_code, response.content)
44
44
  else:
@@ -25,9 +25,8 @@ def _get_kwargs(
25
25
  "url": f"/api/{organization_slug}/{project_slug}/tasks/{id}/",
26
26
  }
27
27
 
28
- _body = body.to_dict()
28
+ _kwargs["json"] = body.to_dict()
29
29
 
30
- _kwargs["json"] = _body
31
30
  headers["Content-Type"] = "application/json"
32
31
 
33
32
  _kwargs["headers"] = headers
@@ -39,6 +38,7 @@ def _parse_response(*, client: Union[AuthenticatedClient, Client], response: htt
39
38
  response_200 = Task.from_dict(response.json())
40
39
 
41
40
  return response_200
41
+
42
42
  if client.raise_on_unexpected_status:
43
43
  raise errors.UnexpectedStatus(response.status_code, response.content)
44
44
  else:
@@ -1,4 +1,5 @@
1
1
  import datetime
2
+ from collections.abc import Mapping
2
3
  from typing import Any, TypeVar, Union
3
4
 
4
5
  from attrs import define as _attrs_define
@@ -70,8 +71,8 @@ class Action:
70
71
  return field_dict
71
72
 
72
73
  @classmethod
73
- def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T:
74
- d = src_dict.copy()
74
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
75
+ d = dict(src_dict)
75
76
  id = d.pop("id")
76
77
 
77
78
  task = d.pop("task")
@@ -1,3 +1,4 @@
1
+ from collections.abc import Mapping
1
2
  from typing import Any, TypeVar, Union
2
3
 
3
4
  from attrs import define as _attrs_define
@@ -43,8 +44,8 @@ class ActionRequest:
43
44
  return field_dict
44
45
 
45
46
  @classmethod
46
- def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T:
47
- d = src_dict.copy()
47
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
48
+ d = dict(src_dict)
48
49
  trigger = d.pop("trigger")
49
50
 
50
51
  integration = d.pop("integration")
@@ -1,3 +1,4 @@
1
+ from collections.abc import Mapping
1
2
  from typing import TYPE_CHECKING, Any, TypeVar, Union, cast
2
3
 
3
4
  from attrs import define as _attrs_define
@@ -59,10 +60,10 @@ class PaginatedTaskList:
59
60
  return field_dict
60
61
 
61
62
  @classmethod
62
- def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T:
63
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
63
64
  from ..models.task import Task
64
65
 
65
- d = src_dict.copy()
66
+ d = dict(src_dict)
66
67
  results = []
67
68
  _results = d.pop("results")
68
69
  for results_item_data in _results:
@@ -1,3 +1,4 @@
1
+ from collections.abc import Mapping
1
2
  from typing import Any, TypeVar, Union
2
3
 
3
4
  from attrs import define as _attrs_define
@@ -42,8 +43,8 @@ class PatchedActionRequest:
42
43
  return field_dict
43
44
 
44
45
  @classmethod
45
- def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T:
46
- d = src_dict.copy()
46
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
47
+ d = dict(src_dict)
47
48
  trigger = d.pop("trigger", UNSET)
48
49
 
49
50
  integration = d.pop("integration", UNSET)
@@ -1,4 +1,5 @@
1
1
  import datetime
2
+ from collections.abc import Mapping
2
3
  from typing import TYPE_CHECKING, Any, TypeVar, Union, cast
3
4
 
4
5
  from attrs import define as _attrs_define
@@ -35,6 +36,8 @@ class PatchedTaskRequest:
35
36
  set via the API.
36
37
  end_time (Union[None, Unset, datetime.datetime]): Datetime when status is set to a terminal value.Can be set via
37
38
  the API.
39
+ time_to_start (Union[None, Unset, str]): Duration between task creation and when status first changes from
40
+ pending. (seconds)
38
41
  max_runtime (Union[None, Unset, int]): Maximum duration the task can be running for before being considered
39
42
  failed. (seconds)
40
43
  stale_timeout (Union[None, Unset, int]): Maximum time to allow between task updates before considering the task
@@ -50,6 +53,7 @@ class PatchedTaskRequest:
50
53
  data: Union[Unset, Any] = UNSET
51
54
  start_time: Union[None, Unset, datetime.datetime] = UNSET
52
55
  end_time: Union[None, Unset, datetime.datetime] = UNSET
56
+ time_to_start: Union[None, Unset, str] = UNSET
53
57
  max_runtime: Union[None, Unset, int] = UNSET
54
58
  stale_timeout: Union[None, Unset, int] = UNSET
55
59
  tags: Union[Unset, "PatchedTaskRequestTags"] = UNSET
@@ -88,6 +92,12 @@ class PatchedTaskRequest:
88
92
  else:
89
93
  end_time = self.end_time
90
94
 
95
+ time_to_start: Union[None, Unset, str]
96
+ if isinstance(self.time_to_start, Unset):
97
+ time_to_start = UNSET
98
+ else:
99
+ time_to_start = self.time_to_start
100
+
91
101
  max_runtime: Union[None, Unset, int]
92
102
  if isinstance(self.max_runtime, Unset):
93
103
  max_runtime = UNSET
@@ -121,6 +131,8 @@ class PatchedTaskRequest:
121
131
  field_dict["start_time"] = start_time
122
132
  if end_time is not UNSET:
123
133
  field_dict["end_time"] = end_time
134
+ if time_to_start is not UNSET:
135
+ field_dict["time_to_start"] = time_to_start
124
136
  if max_runtime is not UNSET:
125
137
  field_dict["max_runtime"] = max_runtime
126
138
  if stale_timeout is not UNSET:
@@ -131,10 +143,10 @@ class PatchedTaskRequest:
131
143
  return field_dict
132
144
 
133
145
  @classmethod
134
- def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T:
146
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
135
147
  from ..models.patched_task_request_tags import PatchedTaskRequestTags
136
148
 
137
- d = src_dict.copy()
149
+ d = dict(src_dict)
138
150
  name = d.pop("name", UNSET)
139
151
 
140
152
  _status = d.pop("status", UNSET)
@@ -191,6 +203,15 @@ class PatchedTaskRequest:
191
203
 
192
204
  end_time = _parse_end_time(d.pop("end_time", UNSET))
193
205
 
206
+ def _parse_time_to_start(data: object) -> Union[None, Unset, str]:
207
+ if data is None:
208
+ return data
209
+ if isinstance(data, Unset):
210
+ return data
211
+ return cast(Union[None, Unset, str], data)
212
+
213
+ time_to_start = _parse_time_to_start(d.pop("time_to_start", UNSET))
214
+
194
215
  def _parse_max_runtime(data: object) -> Union[None, Unset, int]:
195
216
  if data is None:
196
217
  return data
@@ -224,6 +245,7 @@ class PatchedTaskRequest:
224
245
  data=data,
225
246
  start_time=start_time,
226
247
  end_time=end_time,
248
+ time_to_start=time_to_start,
227
249
  max_runtime=max_runtime,
228
250
  stale_timeout=stale_timeout,
229
251
  tags=tags,
@@ -1,3 +1,4 @@
1
+ from collections.abc import Mapping
1
2
  from typing import Any, TypeVar
2
3
 
3
4
  from attrs import define as _attrs_define
@@ -19,8 +20,8 @@ class PatchedTaskRequestTags:
19
20
  return field_dict
20
21
 
21
22
  @classmethod
22
- def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T:
23
- d = src_dict.copy()
23
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
24
+ d = dict(src_dict)
24
25
  patched_task_request_tags = cls()
25
26
 
26
27
  patched_task_request_tags.additional_properties = d
@@ -1,4 +1,5 @@
1
1
  import datetime
2
+ from collections.abc import Mapping
2
3
  from typing import TYPE_CHECKING, Any, TypeVar, Union, cast
3
4
 
4
5
  from attrs import define as _attrs_define
@@ -43,6 +44,8 @@ class Task:
43
44
  set via the API.
44
45
  end_time (Union[None, Unset, datetime.datetime]): Datetime when status is set to a terminal value.Can be set via
45
46
  the API.
47
+ time_to_start (Union[None, Unset, str]): Duration between task creation and when status first changes from
48
+ pending. (seconds)
46
49
  max_runtime (Union[None, Unset, int]): Maximum duration the task can be running for before being considered
47
50
  failed. (seconds)
48
51
  stale_timeout (Union[None, Unset, int]): Maximum time to allow between task updates before considering the task
@@ -65,6 +68,7 @@ class Task:
65
68
  data: Union[Unset, Any] = UNSET
66
69
  start_time: Union[None, Unset, datetime.datetime] = UNSET
67
70
  end_time: Union[None, Unset, datetime.datetime] = UNSET
71
+ time_to_start: Union[None, Unset, str] = UNSET
68
72
  max_runtime: Union[None, Unset, int] = UNSET
69
73
  stale_timeout: Union[None, Unset, int] = UNSET
70
74
  tags: Union[Unset, "TaskTags"] = UNSET
@@ -120,6 +124,12 @@ class Task:
120
124
  else:
121
125
  end_time = self.end_time
122
126
 
127
+ time_to_start: Union[None, Unset, str]
128
+ if isinstance(self.time_to_start, Unset):
129
+ time_to_start = UNSET
130
+ else:
131
+ time_to_start = self.time_to_start
132
+
123
133
  max_runtime: Union[None, Unset, int]
124
134
  if isinstance(self.max_runtime, Unset):
125
135
  max_runtime = UNSET
@@ -163,6 +173,8 @@ class Task:
163
173
  field_dict["start_time"] = start_time
164
174
  if end_time is not UNSET:
165
175
  field_dict["end_time"] = end_time
176
+ if time_to_start is not UNSET:
177
+ field_dict["time_to_start"] = time_to_start
166
178
  if max_runtime is not UNSET:
167
179
  field_dict["max_runtime"] = max_runtime
168
180
  if stale_timeout is not UNSET:
@@ -173,10 +185,10 @@ class Task:
173
185
  return field_dict
174
186
 
175
187
  @classmethod
176
- def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T:
188
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
177
189
  from ..models.task_tags import TaskTags
178
190
 
179
- d = src_dict.copy()
191
+ d = dict(src_dict)
180
192
  id = d.pop("id")
181
193
 
182
194
  organization = d.pop("organization")
@@ -254,6 +266,15 @@ class Task:
254
266
 
255
267
  end_time = _parse_end_time(d.pop("end_time", UNSET))
256
268
 
269
+ def _parse_time_to_start(data: object) -> Union[None, Unset, str]:
270
+ if data is None:
271
+ return data
272
+ if isinstance(data, Unset):
273
+ return data
274
+ return cast(Union[None, Unset, str], data)
275
+
276
+ time_to_start = _parse_time_to_start(d.pop("time_to_start", UNSET))
277
+
257
278
  def _parse_max_runtime(data: object) -> Union[None, Unset, int]:
258
279
  if data is None:
259
280
  return data
@@ -295,6 +316,7 @@ class Task:
295
316
  data=data,
296
317
  start_time=start_time,
297
318
  end_time=end_time,
319
+ time_to_start=time_to_start,
298
320
  max_runtime=max_runtime,
299
321
  stale_timeout=stale_timeout,
300
322
  tags=tags,
@@ -1,4 +1,5 @@
1
1
  import datetime
2
+ from collections.abc import Mapping
2
3
  from typing import TYPE_CHECKING, Any, TypeVar, Union, cast
3
4
 
4
5
  from attrs import define as _attrs_define
@@ -35,6 +36,8 @@ class TaskRequest:
35
36
  set via the API.
36
37
  end_time (Union[None, Unset, datetime.datetime]): Datetime when status is set to a terminal value.Can be set via
37
38
  the API.
39
+ time_to_start (Union[None, Unset, str]): Duration between task creation and when status first changes from
40
+ pending. (seconds)
38
41
  max_runtime (Union[None, Unset, int]): Maximum duration the task can be running for before being considered
39
42
  failed. (seconds)
40
43
  stale_timeout (Union[None, Unset, int]): Maximum time to allow between task updates before considering the task
@@ -49,6 +52,7 @@ class TaskRequest:
49
52
  data: Union[Unset, Any] = UNSET
50
53
  start_time: Union[None, Unset, datetime.datetime] = UNSET
51
54
  end_time: Union[None, Unset, datetime.datetime] = UNSET
55
+ time_to_start: Union[None, Unset, str] = UNSET
52
56
  max_runtime: Union[None, Unset, int] = UNSET
53
57
  stale_timeout: Union[None, Unset, int] = UNSET
54
58
  tags: Union[Unset, "TaskRequestTags"] = UNSET
@@ -87,6 +91,12 @@ class TaskRequest:
87
91
  else:
88
92
  end_time = self.end_time
89
93
 
94
+ time_to_start: Union[None, Unset, str]
95
+ if isinstance(self.time_to_start, Unset):
96
+ time_to_start = UNSET
97
+ else:
98
+ time_to_start = self.time_to_start
99
+
90
100
  max_runtime: Union[None, Unset, int]
91
101
  if isinstance(self.max_runtime, Unset):
92
102
  max_runtime = UNSET
@@ -122,6 +132,8 @@ class TaskRequest:
122
132
  field_dict["start_time"] = start_time
123
133
  if end_time is not UNSET:
124
134
  field_dict["end_time"] = end_time
135
+ if time_to_start is not UNSET:
136
+ field_dict["time_to_start"] = time_to_start
125
137
  if max_runtime is not UNSET:
126
138
  field_dict["max_runtime"] = max_runtime
127
139
  if stale_timeout is not UNSET:
@@ -132,10 +144,10 @@ class TaskRequest:
132
144
  return field_dict
133
145
 
134
146
  @classmethod
135
- def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T:
147
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
136
148
  from ..models.task_request_tags import TaskRequestTags
137
149
 
138
- d = src_dict.copy()
150
+ d = dict(src_dict)
139
151
  name = d.pop("name")
140
152
 
141
153
  _status = d.pop("status", UNSET)
@@ -192,6 +204,15 @@ class TaskRequest:
192
204
 
193
205
  end_time = _parse_end_time(d.pop("end_time", UNSET))
194
206
 
207
+ def _parse_time_to_start(data: object) -> Union[None, Unset, str]:
208
+ if data is None:
209
+ return data
210
+ if isinstance(data, Unset):
211
+ return data
212
+ return cast(Union[None, Unset, str], data)
213
+
214
+ time_to_start = _parse_time_to_start(d.pop("time_to_start", UNSET))
215
+
195
216
  def _parse_max_runtime(data: object) -> Union[None, Unset, int]:
196
217
  if data is None:
197
218
  return data
@@ -225,6 +246,7 @@ class TaskRequest:
225
246
  data=data,
226
247
  start_time=start_time,
227
248
  end_time=end_time,
249
+ time_to_start=time_to_start,
228
250
  max_runtime=max_runtime,
229
251
  stale_timeout=stale_timeout,
230
252
  tags=tags,
@@ -1,3 +1,4 @@
1
+ from collections.abc import Mapping
1
2
  from typing import Any, TypeVar
2
3
 
3
4
  from attrs import define as _attrs_define
@@ -19,8 +20,8 @@ class TaskRequestTags:
19
20
  return field_dict
20
21
 
21
22
  @classmethod
22
- def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T:
23
- d = src_dict.copy()
23
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
24
+ d = dict(src_dict)
24
25
  task_request_tags = cls()
25
26
 
26
27
  task_request_tags.additional_properties = d
@@ -1,3 +1,4 @@
1
+ from collections.abc import Mapping
1
2
  from typing import Any, TypeVar
2
3
 
3
4
  from attrs import define as _attrs_define
@@ -19,8 +20,8 @@ class TaskTags:
19
20
  return field_dict
20
21
 
21
22
  @classmethod
22
- def from_dict(cls: type[T], src_dict: dict[str, Any]) -> T:
23
- d = src_dict.copy()
23
+ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
24
+ d = dict(src_dict)
24
25
  task_tags = cls()
25
26
 
26
27
  task_tags.additional_properties = d
@@ -1,8 +1,8 @@
1
1
  """Contains some shared types for properties"""
2
2
 
3
- from collections.abc import MutableMapping
3
+ from collections.abc import Mapping, MutableMapping
4
4
  from http import HTTPStatus
5
- from typing import BinaryIO, Generic, Literal, Optional, TypeVar
5
+ from typing import IO, BinaryIO, Generic, Literal, Optional, TypeVar, Union
6
6
 
7
7
  from attrs import define
8
8
 
@@ -14,7 +14,15 @@ class Unset:
14
14
 
15
15
  UNSET: Unset = Unset()
16
16
 
17
- FileJsonType = tuple[Optional[str], BinaryIO, Optional[str]]
17
+ # The types that `httpx.Client(files=)` can accept, copied from that library.
18
+ FileContent = Union[IO[bytes], bytes, str]
19
+ FileTypes = Union[
20
+ # (filename, file (or bytes), content_type)
21
+ tuple[Optional[str], FileContent, Optional[str]],
22
+ # (filename, file (or bytes), content_type, headers)
23
+ tuple[Optional[str], FileContent, Optional[str], Mapping[str, str]],
24
+ ]
25
+ RequestFiles = list[tuple[str, FileTypes]]
18
26
 
19
27
 
20
28
  @define
@@ -25,7 +33,7 @@ class File:
25
33
  file_name: Optional[str] = None
26
34
  mime_type: Optional[str] = None
27
35
 
28
- def to_tuple(self) -> FileJsonType:
36
+ def to_tuple(self) -> FileTypes:
29
37
  """Return a tuple representation that httpx will accept for multipart/form-data"""
30
38
  return self.file_name, self.payload, self.mime_type
31
39
 
@@ -43,4 +51,4 @@ class Response(Generic[T]):
43
51
  parsed: Optional[T]
44
52
 
45
53
 
46
- __all__ = ["UNSET", "File", "FileJsonType", "Response", "Unset"]
54
+ __all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"]
@@ -1,3 +1,4 @@
1
+ import base64
1
2
  import datetime
2
3
  import logging
3
4
  import os
@@ -35,6 +36,26 @@ log = logging.getLogger("taskbadger")
35
36
  _TB_HOST = "https://taskbadger.net"
36
37
 
37
38
 
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
+
38
59
  def init(
39
60
  organization_slug: str = None,
40
61
  project_slug: str = None,
@@ -43,9 +64,16 @@ def init(
43
64
  tags: dict[str, str] = None,
44
65
  before_create: Callback = None,
45
66
  ):
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.
47
72
 
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.
49
77
  """
50
78
  _init(_TB_HOST, organization_slug, project_slug, token, systems, tags, before_create)
51
79
 
@@ -64,6 +92,17 @@ def _init(
64
92
  project_slug = project_slug or os.environ.get("TASKBADGER_PROJECT")
65
93
  token = token or os.environ.get("TASKBADGER_API_KEY")
66
94
 
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
+
67
106
  if before_create and isinstance(before_create, str):
68
107
  try:
69
108
  before_create = import_string(before_create)
File without changes
File without changes
File without changes
File without changes