dart-tools 0.6.11__py3-none-any.whl

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.

Potentially problematic release.


This version of dart-tools might be problematic. Click here for more details.

Files changed (257) hide show
  1. dart/__init__.py +20 -0
  2. dart/dart.py +1144 -0
  3. dart/exception.py +6 -0
  4. dart/generated/__init__.py +8 -0
  5. dart/generated/api/__init__.py +1 -0
  6. dart/generated/api/attachments/__init__.py +0 -0
  7. dart/generated/api/attachments/attachments_list.py +169 -0
  8. dart/generated/api/comments/__init__.py +0 -0
  9. dart/generated/api/comments/comments_list.py +278 -0
  10. dart/generated/api/dartboards/__init__.py +0 -0
  11. dart/generated/api/dartboards/dartboards_list.py +271 -0
  12. dart/generated/api/dashboards/__init__.py +0 -0
  13. dart/generated/api/dashboards/dashboards_list.py +184 -0
  14. dart/generated/api/docs/__init__.py +0 -0
  15. dart/generated/api/docs/docs_list.py +372 -0
  16. dart/generated/api/folders/__init__.py +0 -0
  17. dart/generated/api/folders/folders_list.py +234 -0
  18. dart/generated/api/form_fields/__init__.py +0 -0
  19. dart/generated/api/form_fields/form_fields_list.py +169 -0
  20. dart/generated/api/forms/__init__.py +0 -0
  21. dart/generated/api/forms/forms_list.py +169 -0
  22. dart/generated/api/layouts/__init__.py +0 -0
  23. dart/generated/api/layouts/layouts_list.py +169 -0
  24. dart/generated/api/links/__init__.py +0 -0
  25. dart/generated/api/links/links_list.py +169 -0
  26. dart/generated/api/options/__init__.py +0 -0
  27. dart/generated/api/options/options_list.py +229 -0
  28. dart/generated/api/properties/__init__.py +0 -0
  29. dart/generated/api/properties/properties_list.py +204 -0
  30. dart/generated/api/reactions/__init__.py +0 -0
  31. dart/generated/api/reactions/reactions_list.py +169 -0
  32. dart/generated/api/relationship_kinds/__init__.py +0 -0
  33. dart/generated/api/relationship_kinds/relationship_kinds_list.py +169 -0
  34. dart/generated/api/relationships/__init__.py +0 -0
  35. dart/generated/api/relationships/relationships_list.py +169 -0
  36. dart/generated/api/spaces/__init__.py +0 -0
  37. dart/generated/api/spaces/spaces_list.py +214 -0
  38. dart/generated/api/statuses/__init__.py +0 -0
  39. dart/generated/api/statuses/statuses_list.py +249 -0
  40. dart/generated/api/task_doc_relationships/__init__.py +0 -0
  41. dart/generated/api/task_doc_relationships/task_doc_relationships_list.py +169 -0
  42. dart/generated/api/task_kinds/__init__.py +0 -0
  43. dart/generated/api/task_kinds/task_kinds_list.py +204 -0
  44. dart/generated/api/tasks/__init__.py +0 -0
  45. dart/generated/api/tasks/tasks_list.py +446 -0
  46. dart/generated/api/tenants/__init__.py +0 -0
  47. dart/generated/api/tenants/tenants_list.py +169 -0
  48. dart/generated/api/transactions/__init__.py +0 -0
  49. dart/generated/api/transactions/transactions_create.py +176 -0
  50. dart/generated/api/user_dartboard_layouts/__init__.py +0 -0
  51. dart/generated/api/user_dartboard_layouts/user_dartboard_layouts_list.py +169 -0
  52. dart/generated/api/user_data/__init__.py +0 -0
  53. dart/generated/api/user_data/user_data_entity_retrieve.py +580 -0
  54. dart/generated/api/users/__init__.py +0 -0
  55. dart/generated/api/users/users_list.py +214 -0
  56. dart/generated/api/views/__init__.py +0 -0
  57. dart/generated/api/views/views_list.py +184 -0
  58. dart/generated/api/webhooks/__init__.py +0 -0
  59. dart/generated/api/webhooks/webhooks_list.py +169 -0
  60. dart/generated/client.py +268 -0
  61. dart/generated/errors.py +16 -0
  62. dart/generated/models/__init__.py +373 -0
  63. dart/generated/models/attachment.py +112 -0
  64. dart/generated/models/attachment_create.py +121 -0
  65. dart/generated/models/attachment_update.py +125 -0
  66. dart/generated/models/bar_chart_adtl.py +72 -0
  67. dart/generated/models/brainstorm.py +149 -0
  68. dart/generated/models/brainstorm_create.py +134 -0
  69. dart/generated/models/brainstorm_state.py +10 -0
  70. dart/generated/models/brainstorm_update.py +153 -0
  71. dart/generated/models/burn_up_chart_adtl.py +103 -0
  72. dart/generated/models/chart.py +208 -0
  73. dart/generated/models/chart_aggregation.py +10 -0
  74. dart/generated/models/chart_type.py +14 -0
  75. dart/generated/models/comment.py +207 -0
  76. dart/generated/models/comment_create.py +146 -0
  77. dart/generated/models/comment_reaction.py +84 -0
  78. dart/generated/models/comment_reaction_create.py +82 -0
  79. dart/generated/models/comment_reaction_update.py +87 -0
  80. dart/generated/models/comment_update.py +148 -0
  81. dart/generated/models/dartboard.py +280 -0
  82. dart/generated/models/dartboard_create.py +285 -0
  83. dart/generated/models/dartboard_kind.py +12 -0
  84. dart/generated/models/dartboard_update.py +287 -0
  85. dart/generated/models/dartboards_list_kind.py +12 -0
  86. dart/generated/models/dashboard.py +185 -0
  87. dart/generated/models/dashboard_create.py +171 -0
  88. dart/generated/models/dashboard_update.py +173 -0
  89. dart/generated/models/discord_integration.py +72 -0
  90. dart/generated/models/doc.py +243 -0
  91. dart/generated/models/doc_create.py +295 -0
  92. dart/generated/models/doc_source_type.py +13 -0
  93. dart/generated/models/doc_update.py +295 -0
  94. dart/generated/models/docs_list_o_item.py +11 -0
  95. dart/generated/models/entity_name.py +22 -0
  96. dart/generated/models/event.py +420 -0
  97. dart/generated/models/event_actor.py +19 -0
  98. dart/generated/models/event_create.py +158 -0
  99. dart/generated/models/event_kind.py +88 -0
  100. dart/generated/models/event_subscription.py +74 -0
  101. dart/generated/models/event_subscription_update.py +173 -0
  102. dart/generated/models/filter_applicability.py +22 -0
  103. dart/generated/models/filter_assignee.py +116 -0
  104. dart/generated/models/filter_connector.py +9 -0
  105. dart/generated/models/filter_group.py +112 -0
  106. dart/generated/models/filter_search.py +82 -0
  107. dart/generated/models/filter_set.py +116 -0
  108. dart/generated/models/folder.py +150 -0
  109. dart/generated/models/folder_create.py +150 -0
  110. dart/generated/models/folder_kind.py +10 -0
  111. dart/generated/models/folder_update.py +152 -0
  112. dart/generated/models/folders_list_kind.py +10 -0
  113. dart/generated/models/form.py +147 -0
  114. dart/generated/models/form_create.py +141 -0
  115. dart/generated/models/form_field.py +144 -0
  116. dart/generated/models/form_field_create.py +129 -0
  117. dart/generated/models/form_field_update.py +132 -0
  118. dart/generated/models/form_update.py +142 -0
  119. dart/generated/models/github_integration.py +163 -0
  120. dart/generated/models/github_integration_tenant_extension_status.py +11 -0
  121. dart/generated/models/google_data.py +94 -0
  122. dart/generated/models/icon_kind.py +10 -0
  123. dart/generated/models/layout.py +167 -0
  124. dart/generated/models/layout_config.py +70 -0
  125. dart/generated/models/layout_create.py +130 -0
  126. dart/generated/models/layout_kind.py +11 -0
  127. dart/generated/models/layout_kind_config_map.py +56 -0
  128. dart/generated/models/layout_update.py +130 -0
  129. dart/generated/models/line_chart_adtl.py +72 -0
  130. dart/generated/models/models_response.py +671 -0
  131. dart/generated/models/notification.py +120 -0
  132. dart/generated/models/notification_update.py +100 -0
  133. dart/generated/models/notion_integration.py +90 -0
  134. dart/generated/models/notion_integration_tenant_extension_status.py +10 -0
  135. dart/generated/models/number_chart_adtl.py +77 -0
  136. dart/generated/models/operation.py +874 -0
  137. dart/generated/models/operation_kind.py +12 -0
  138. dart/generated/models/operation_model_kind.py +36 -0
  139. dart/generated/models/option.py +118 -0
  140. dart/generated/models/option_create.py +105 -0
  141. dart/generated/models/option_update.py +107 -0
  142. dart/generated/models/paginated_attachment_list.py +122 -0
  143. dart/generated/models/paginated_comment_list.py +122 -0
  144. dart/generated/models/paginated_comment_reaction_list.py +122 -0
  145. dart/generated/models/paginated_dartboard_list.py +122 -0
  146. dart/generated/models/paginated_dashboard_list.py +122 -0
  147. dart/generated/models/paginated_doc_list.py +122 -0
  148. dart/generated/models/paginated_folder_list.py +122 -0
  149. dart/generated/models/paginated_form_field_list.py +122 -0
  150. dart/generated/models/paginated_form_list.py +122 -0
  151. dart/generated/models/paginated_layout_list.py +122 -0
  152. dart/generated/models/paginated_option_list.py +122 -0
  153. dart/generated/models/paginated_property_list.py +122 -0
  154. dart/generated/models/paginated_relationship_kind_list.py +122 -0
  155. dart/generated/models/paginated_relationship_list.py +122 -0
  156. dart/generated/models/paginated_space_list.py +122 -0
  157. dart/generated/models/paginated_status_list.py +122 -0
  158. dart/generated/models/paginated_task_doc_relationship_list.py +122 -0
  159. dart/generated/models/paginated_task_kind_list.py +122 -0
  160. dart/generated/models/paginated_task_link_list.py +122 -0
  161. dart/generated/models/paginated_task_list.py +122 -0
  162. dart/generated/models/paginated_tenant_list.py +122 -0
  163. dart/generated/models/paginated_user_dartboard_layout_list.py +122 -0
  164. dart/generated/models/paginated_user_list.py +122 -0
  165. dart/generated/models/paginated_view_list.py +122 -0
  166. dart/generated/models/paginated_webhook_list.py +122 -0
  167. dart/generated/models/pie_chart_adtl.py +69 -0
  168. dart/generated/models/pie_chart_display_metric.py +9 -0
  169. dart/generated/models/priority.py +11 -0
  170. dart/generated/models/properties_list_kind.py +32 -0
  171. dart/generated/models/property_.py +153 -0
  172. dart/generated/models/property_create.py +137 -0
  173. dart/generated/models/property_kind.py +32 -0
  174. dart/generated/models/property_update.py +146 -0
  175. dart/generated/models/relationship.py +74 -0
  176. dart/generated/models/relationship_create.py +93 -0
  177. dart/generated/models/relationship_kind.py +123 -0
  178. dart/generated/models/relationship_kind_create.py +117 -0
  179. dart/generated/models/relationship_kind_kind.py +12 -0
  180. dart/generated/models/relationship_kind_update.py +119 -0
  181. dart/generated/models/report_kind.py +9 -0
  182. dart/generated/models/request_body.py +80 -0
  183. dart/generated/models/response_body.py +72 -0
  184. dart/generated/models/saml_config.py +77 -0
  185. dart/generated/models/saml_config_tenant_extension_status.py +9 -0
  186. dart/generated/models/slack_integration.py +90 -0
  187. dart/generated/models/slack_integration_tenant_extension_status.py +10 -0
  188. dart/generated/models/sort.py +66 -0
  189. dart/generated/models/space.py +325 -0
  190. dart/generated/models/space_create.py +360 -0
  191. dart/generated/models/space_kind.py +10 -0
  192. dart/generated/models/space_update.py +361 -0
  193. dart/generated/models/sprint_mode.py +9 -0
  194. dart/generated/models/status.py +141 -0
  195. dart/generated/models/status_create.py +125 -0
  196. dart/generated/models/status_kind.py +12 -0
  197. dart/generated/models/status_update.py +135 -0
  198. dart/generated/models/statuses_list_kind.py +12 -0
  199. dart/generated/models/subscription.py +9 -0
  200. dart/generated/models/subtask_display_mode.py +10 -0
  201. dart/generated/models/summary_statistic_kind.py +14 -0
  202. dart/generated/models/table_chart_adtl.py +98 -0
  203. dart/generated/models/task.py +531 -0
  204. dart/generated/models/task_create.py +585 -0
  205. dart/generated/models/task_detail_mode.py +10 -0
  206. dart/generated/models/task_doc_relationship.py +96 -0
  207. dart/generated/models/task_doc_relationship_create.py +74 -0
  208. dart/generated/models/task_kind.py +149 -0
  209. dart/generated/models/task_kind_create.py +144 -0
  210. dart/generated/models/task_kind_kind.py +9 -0
  211. dart/generated/models/task_kind_update.py +153 -0
  212. dart/generated/models/task_kinds_list_kind.py +9 -0
  213. dart/generated/models/task_link.py +131 -0
  214. dart/generated/models/task_link_create.py +152 -0
  215. dart/generated/models/task_link_kind.py +19 -0
  216. dart/generated/models/task_link_update.py +155 -0
  217. dart/generated/models/task_notion_document.py +196 -0
  218. dart/generated/models/task_notion_document_block_children_map_type_0.py +43 -0
  219. dart/generated/models/task_notion_document_block_map_type_0.py +43 -0
  220. dart/generated/models/task_notion_document_page_map_type_0.py +43 -0
  221. dart/generated/models/task_properties.py +43 -0
  222. dart/generated/models/task_source_type.py +31 -0
  223. dart/generated/models/task_update.py +585 -0
  224. dart/generated/models/tenant.py +378 -0
  225. dart/generated/models/tenant_update.py +157 -0
  226. dart/generated/models/theme.py +10 -0
  227. dart/generated/models/transaction.py +156 -0
  228. dart/generated/models/transaction_kind.py +74 -0
  229. dart/generated/models/transaction_response.py +96 -0
  230. dart/generated/models/user.py +245 -0
  231. dart/generated/models/user_dartboard_layout.py +66 -0
  232. dart/generated/models/user_dartboard_layout_create.py +74 -0
  233. dart/generated/models/user_data_entity_retrieve_entity_kind.py +32 -0
  234. dart/generated/models/user_role.py +12 -0
  235. dart/generated/models/user_status.py +12 -0
  236. dart/generated/models/user_update.py +210 -0
  237. dart/generated/models/validation_error_response.py +64 -0
  238. dart/generated/models/validation_error_response_items.py +43 -0
  239. dart/generated/models/view.py +215 -0
  240. dart/generated/models/view_create.py +213 -0
  241. dart/generated/models/view_kind.py +11 -0
  242. dart/generated/models/view_update.py +215 -0
  243. dart/generated/models/webhook.py +96 -0
  244. dart/generated/models/webhook_create.py +77 -0
  245. dart/generated/models/webhook_update.py +78 -0
  246. dart/generated/models/zapier_integration.py +66 -0
  247. dart/generated/py.typed +1 -0
  248. dart/generated/types.py +45 -0
  249. dart/order_manager.py +62 -0
  250. dart/webhook.py +21 -0
  251. dart_tools-0.6.11.dist-info/LICENSE +21 -0
  252. dart_tools-0.6.11.dist-info/METADATA +221 -0
  253. dart_tools-0.6.11.dist-info/RECORD +257 -0
  254. dart_tools-0.6.11.dist-info/WHEEL +5 -0
  255. dart_tools-0.6.11.dist-info/dist/dart-tools-0.3.3.tar.gz +0 -0
  256. dart_tools-0.6.11.dist-info/entry_points.txt +2 -0
  257. dart_tools-0.6.11.dist-info/top_level.txt +1 -0
dart/dart.py ADDED
@@ -0,0 +1,1144 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+
4
+ """A CLI to interact with the Dart web app."""
5
+
6
+ # Required for type hinting compatibility when using Python 3.9
7
+ from __future__ import annotations
8
+ from argparse import ArgumentParser
9
+ from functools import wraps
10
+ import json
11
+ import os
12
+ import random
13
+ import re
14
+ import signal
15
+ import string
16
+ import subprocess
17
+ import sys
18
+ from collections import defaultdict
19
+ from datetime import timezone
20
+ from importlib.metadata import version
21
+ from typing import Any, Callable, NoReturn
22
+ from webbrowser import open_new_tab
23
+
24
+ import dateparser
25
+ from pick import pick
26
+ import requests
27
+ import platformdirs
28
+
29
+ from .exception import DartException
30
+ from .generated import Client
31
+ from .generated.models import (
32
+ Dartboard,
33
+ DartboardKind,
34
+ DartboardUpdate,
35
+ Folder,
36
+ FolderKind,
37
+ FolderUpdate,
38
+ Operation,
39
+ OperationKind,
40
+ OperationModelKind,
41
+ Priority,
42
+ PropertyKind,
43
+ RequestBody,
44
+ SpaceKind,
45
+ StatusKind,
46
+ SprintMode,
47
+ Task,
48
+ TaskCreate,
49
+ TaskSourceType,
50
+ TaskUpdate,
51
+ Transaction,
52
+ TransactionKind,
53
+ )
54
+ from .generated.api.dartboards import dartboards_list
55
+ from .generated.api.folders import folders_list
56
+ from .generated.api.transactions import transactions_create
57
+ from .order_manager import get_orders_between
58
+
59
+ _APP = "dart-tools"
60
+ _PROG = "dart"
61
+
62
+ _PROD_HOST = "https://app.itsdart.com"
63
+ _STAG_HOST = "https://stag.itsdart.com"
64
+ _DEV_HOST = "http://localhost:5173"
65
+ _HOST_MAP = {"prod": _PROD_HOST, "stag": _STAG_HOST, "dev": _DEV_HOST}
66
+
67
+ _VERSION_CMD = "--version"
68
+ _SET_HOST_CMD = "sethost"
69
+ _LOGIN_CMD = "login"
70
+ _CREATE_TASK_CMD = "createtask"
71
+ _UPDATE_TASK_CMD = "updatetask"
72
+ _BEGIN_TASK_CMD = "begintask"
73
+
74
+ _PROFILE_SETTINGS_URL_FRAG = "/?settings=account"
75
+ _ROOT_API_URL_FRAG = "/api/v0"
76
+ _USER_STATUS_URL_FRAG = _ROOT_API_URL_FRAG + "/user-status"
77
+ _USER_DATA_URL_FRAG = _ROOT_API_URL_FRAG + "/user-data?mode=auto"
78
+ _COPY_BRANCH_URL_FRAG = _ROOT_API_URL_FRAG + "/vcs/copy-branch-link"
79
+ _REPLICATE_SPACE_URL_FRAG_FMT = _ROOT_API_URL_FRAG + "/spaces/replicate/{duid}"
80
+ _REPLICATE_DARTBOARD_URL_FRAG_FMT = _ROOT_API_URL_FRAG + "/dartboards/replicate/{duid}"
81
+
82
+ _AUTH_TOKEN_ENVVAR_KEY = "DART_TOKEN"
83
+ _CONFIG_FPATH = platformdirs.user_config_path(_APP)
84
+ _CLIENT_DUID_KEY = "clientDuid"
85
+ _HOST_KEY = "host"
86
+ _HOSTS_KEY = "hosts"
87
+ _AUTH_TOKEN_KEY = "authToken"
88
+
89
+ _DUID_CHARS = string.ascii_lowercase + string.ascii_uppercase + string.digits
90
+ _NON_ALPHANUM_RE = re.compile(r"[^a-zA-Z0-9-]+")
91
+ _REPEATED_DASH_RE = re.compile(r"-{2,}")
92
+ _PRIORITY_MAP = {
93
+ 0: Priority.CRITICAL,
94
+ 1: Priority.HIGH,
95
+ 2: Priority.MEDIUM,
96
+ 3: Priority.LOW,
97
+ }
98
+ _SIZES = {1, 2, 3, 5, 8}
99
+ _COMPLETED_STATUS_KINDS = {"Finished", "Canceled"}
100
+
101
+ _VERSION = version(_APP)
102
+ _AUTH_TOKEN_ENVVAR = os.environ.get(_AUTH_TOKEN_ENVVAR_KEY)
103
+
104
+ _is_cli = False
105
+
106
+
107
+ # TODO dedupe these functions with other usages elsewhere
108
+ def _make_duid() -> str:
109
+ return "".join(random.choices(_DUID_CHARS, k=12))
110
+
111
+
112
+ def trim_slug_str(s: str, length: int, max_under: int | None = None) -> str:
113
+ max_under = max_under if max_under is not None else length // 6
114
+ if len(s) <= length:
115
+ return s
116
+ for i in range(1, max_under + 1):
117
+ if s[length - i] == "-":
118
+ return s[: length - i]
119
+ return s[:length]
120
+
121
+
122
+ def slugify_str(s: str, lower: bool = False, trim_kwargs: dict | None = None) -> str:
123
+ lowered = s.lower() if lower else s
124
+ formatted = _NON_ALPHANUM_RE.sub("-", lowered.replace("'", ""))
125
+ formatted = _REPEATED_DASH_RE.sub("-", formatted).strip("-")
126
+ return (
127
+ trim_slug_str(formatted, **trim_kwargs)
128
+ if trim_kwargs is not None
129
+ else formatted
130
+ )
131
+
132
+
133
+ def _run_cmd(cmd: str) -> str:
134
+ return subprocess.check_output(cmd, shell=True).decode()
135
+
136
+
137
+ def _get_space_url(host: str, duid: str) -> str:
138
+ return f"{host}/s/{duid}"
139
+
140
+
141
+ def _get_dartboard_url(host: str, duid: str) -> str:
142
+ return f"{host}/d/{duid}"
143
+
144
+
145
+ def _get_task_url(host: str, duid: str) -> str:
146
+ return f"{host}/t/{duid}"
147
+
148
+
149
+ def _get_folder_url(host: str, duid: str) -> str:
150
+ return f"{host}/f/{duid}"
151
+
152
+
153
+ def _suppress_exception(fn: Callable) -> Callable:
154
+ @wraps(fn)
155
+ def wrapper(*args, **kwargs):
156
+ try:
157
+ return fn(*args, **kwargs)
158
+ except Exception: # pylint: disable=broad-except
159
+ return None
160
+
161
+ return wrapper
162
+
163
+
164
+ def _dart_exit(message: str) -> NoReturn:
165
+ if _is_cli:
166
+ sys.exit(message)
167
+ raise DartException(message)
168
+
169
+
170
+ def _exit_gracefully(_signal_received, _frame) -> None:
171
+ _dart_exit("Quitting.")
172
+
173
+
174
+ def _log(s: str) -> None:
175
+ if not _is_cli:
176
+ return
177
+ print(s)
178
+
179
+
180
+ class _Config:
181
+ def __init__(self):
182
+ self._content = {}
183
+ if os.path.isfile(_CONFIG_FPATH):
184
+ try:
185
+ with open(_CONFIG_FPATH, "r", encoding="UTF-8") as fin:
186
+ self._content = json.load(fin)
187
+ except OSError:
188
+ pass
189
+ self._content = {
190
+ _CLIENT_DUID_KEY: _make_duid(),
191
+ _HOST_KEY: _PROD_HOST,
192
+ _HOSTS_KEY: {},
193
+ } | self._content
194
+ self._content[_HOSTS_KEY] = defaultdict(dict, self._content[_HOSTS_KEY])
195
+ self._write()
196
+
197
+ def _write(self):
198
+ try:
199
+ with open(_CONFIG_FPATH, "w+", encoding="UTF-8") as fout:
200
+ json.dump(self._content, fout, indent=2)
201
+ except OSError:
202
+ pass
203
+
204
+ @property
205
+ def client_duid(self):
206
+ return self._content[_CLIENT_DUID_KEY]
207
+
208
+ @property
209
+ def host(self):
210
+ return self._content[_HOST_KEY]
211
+
212
+ @host.setter
213
+ def host(self, v):
214
+ self._content[_HOST_KEY] = v
215
+ self._write()
216
+
217
+ def get(self, k):
218
+ return self._content[_HOSTS_KEY][self.host].get(k)
219
+
220
+ def set(self, k, v):
221
+ self._content[_HOSTS_KEY][self.host][k] = v
222
+ self._write()
223
+
224
+
225
+ class _Session:
226
+ def __init__(self, config=None):
227
+ self._config = config or _Config()
228
+ self._session = requests.Session()
229
+
230
+ def get_base_url(self):
231
+ return self._config.host
232
+
233
+ def get_client_duid(self):
234
+ return self._config.client_duid
235
+
236
+ def get_auth_token(self):
237
+ result = self._config.get(_AUTH_TOKEN_KEY)
238
+ if result is not None:
239
+ return result
240
+ return _AUTH_TOKEN_ENVVAR
241
+
242
+ def get_headers(self):
243
+ result = {
244
+ "Origin": self._config.host,
245
+ "client-duid": self.get_client_duid(),
246
+ }
247
+ if (auth_token := self.get_auth_token()) is not None:
248
+ result["Authorization"] = f"Bearer {auth_token}"
249
+ return result
250
+
251
+ def get(self, url_frag, *args, **kwargs):
252
+ kwargs["headers"] = self.get_headers() | kwargs.get("headers", {})
253
+ return self._session.get(self._config.host + url_frag, *args, **kwargs)
254
+
255
+ def post(self, url_frag, *args, **kwargs):
256
+ kwargs["headers"] = self.get_headers() | kwargs.get("headers", {})
257
+ result = self._session.post(self._config.host + url_frag, *args, **kwargs)
258
+ if result.status_code != 403:
259
+ return result
260
+ kwargs["headers"] = self.get_headers() | kwargs.get("headers", {})
261
+ return self._session.post(self._config.host + url_frag, *args, **kwargs)
262
+
263
+
264
+ class Dart(Client):
265
+ def __init__(self, session=None):
266
+ self._session = session or _Session()
267
+ super().__init__(
268
+ base_url=self._session.get_base_url(),
269
+ headers=self._session.get_headers(),
270
+ )
271
+
272
+ def transact(self, operations: list[Operation], kind: TransactionKind):
273
+ transaction = Transaction(
274
+ duid=_make_duid(),
275
+ kind=kind,
276
+ operations=operations,
277
+ )
278
+ request_body = RequestBody(
279
+ client_duid=self._session.get_client_duid(),
280
+ items=[transaction],
281
+ )
282
+ return transactions_create.sync(
283
+ client=self,
284
+ body=request_body,
285
+ )
286
+
287
+
288
+ class UserBundle:
289
+ def __init__(self, session):
290
+ _log("Loading active tasks")
291
+ response = session.get(_USER_DATA_URL_FRAG)
292
+ _check_request_response_and_maybe_exit(response)
293
+ self._raw = response.json()
294
+ if not self.is_logged_in:
295
+ _auth_failure_exit()
296
+
297
+ @property
298
+ def is_logged_in(self):
299
+ return self._raw["isLoggedIn"]
300
+
301
+ @property
302
+ def user(self):
303
+ return self._raw["user"]
304
+
305
+ @property
306
+ def users(self):
307
+ return self._raw["users"]
308
+
309
+ @property
310
+ def properties(self):
311
+ return self._raw["properties"]
312
+
313
+ @property
314
+ def task_kinds(self):
315
+ return self._raw["taskKinds"]
316
+
317
+ @property
318
+ def default_dartboard(self):
319
+ space = next(
320
+ (e for e in self.spaces if e["kind"] == SpaceKind.WORKSPACE),
321
+ self.spaces[0],
322
+ )
323
+ space_duid = space["duid"]
324
+ dartboard_kind = (
325
+ DartboardKind.ACTIVE
326
+ if space["sprintMode"] == SprintMode.ANBA
327
+ else DartboardKind.CUSTOM
328
+ )
329
+ return next(
330
+ e
331
+ for e in self.dartboards
332
+ if e["spaceDuid"] == space_duid and e["kind"] == dartboard_kind
333
+ )
334
+
335
+ @property
336
+ def default_statuses(self):
337
+ default_status_property_duid = next(
338
+ e["duid"]
339
+ for e in self.properties
340
+ if e["kind"] == PropertyKind.DEFAULT_STATUS
341
+ )
342
+ return [
343
+ e
344
+ for e in self._raw["statuses"]
345
+ if e["propertyDuid"] == default_status_property_duid
346
+ ]
347
+
348
+ @property
349
+ def default_tags(self):
350
+ default_tags_property_duid = next(
351
+ e["duid"] for e in self.properties if e["kind"] == PropertyKind.DEFAULT_TAGS
352
+ )
353
+ return [
354
+ e
355
+ for e in self._raw["options"]
356
+ if e["propertyDuid"] == default_tags_property_duid
357
+ ]
358
+
359
+ @property
360
+ def spaces(self):
361
+ return self._raw["spaces"]
362
+
363
+ @property
364
+ def dartboards(self):
365
+ return self._raw["dartboards"]
366
+
367
+ @property
368
+ def tasks(self):
369
+ return self._raw["tasks"]
370
+
371
+
372
+ class _Git:
373
+ @staticmethod
374
+ def _cmd_succeeds(cmd):
375
+ try:
376
+ _run_cmd(f"{cmd} 2>&1")
377
+ except subprocess.CalledProcessError as ex:
378
+ if "128" in str(ex):
379
+ return False
380
+ raise ex
381
+ return True
382
+
383
+ @staticmethod
384
+ def make_task_name(email, task):
385
+ username = slugify_str(email.split("@")[0], lower=True)
386
+ title = slugify_str(task.title, lower=True)
387
+ return trim_slug_str(f"{username}/{task.duid}-{title}", length=60)
388
+
389
+ @staticmethod
390
+ def get_current_branch():
391
+ return _run_cmd("git rev-parse --abbrev-ref HEAD").strip()
392
+
393
+ @staticmethod
394
+ def ensure_in_repo():
395
+ if _Git._cmd_succeeds("git rev-parse --is-inside-work-tree"):
396
+ return
397
+ _dart_exit("You are not in a git repo.")
398
+
399
+ @staticmethod
400
+ def ensure_no_unstaged_changes():
401
+ if _run_cmd("git status --porcelain") == "":
402
+ return
403
+ _dart_exit("You have uncommitted changes. Please commit or stash them.")
404
+
405
+ @staticmethod
406
+ def ensure_on_main_or_intended():
407
+ branch = _Git.get_current_branch()
408
+ if branch == "main":
409
+ return
410
+ if (
411
+ pick(
412
+ ["Yes", "No"],
413
+ "You're not on the 'main' branch. Is this intentional?",
414
+ "→",
415
+ )[0]
416
+ == "Yes"
417
+ ):
418
+ return
419
+ _run_cmd("git checkout main")
420
+
421
+ @staticmethod
422
+ def branch_exists(branch):
423
+ return _Git._cmd_succeeds(f"git rev-parse --verify {branch}")
424
+
425
+ @staticmethod
426
+ def checkout_branch(branch):
427
+ if _Git.branch_exists(branch):
428
+ _run_cmd(f"git checkout {branch}")
429
+ return
430
+
431
+ if _Git.branch_exists(f"origin/{branch}"):
432
+ _run_cmd(f"git checkout --track origin/{branch}")
433
+ return
434
+
435
+ _run_cmd(f"git checkout -b {branch}")
436
+
437
+
438
+ def get_host() -> str:
439
+ config = _Config()
440
+
441
+ host = config.host
442
+ _log(f"Host is {host}")
443
+ _log("Done.")
444
+ return host
445
+
446
+
447
+ def set_host(host: str) -> bool:
448
+ config = _Config()
449
+
450
+ new_host = _HOST_MAP.get(host, host)
451
+ config.host = new_host
452
+
453
+ _log(f"Set host to {new_host}")
454
+ _log("Done.")
455
+ return True
456
+
457
+
458
+ def _auth_failure_exit() -> NoReturn:
459
+ _dart_exit(f"Not logged in, run\n\n {_PROG} {_LOGIN_CMD}\n\nto log in.")
460
+
461
+
462
+ def _unknown_failure_exit() -> NoReturn:
463
+ _dart_exit("Unknown failure, email\n\n support@itsdart.com\n\nfor help.")
464
+
465
+
466
+ def _check_request_response_and_maybe_exit(response) -> None:
467
+ try:
468
+ response.raise_for_status()
469
+ except requests.exceptions.HTTPError:
470
+ if response.status_code in {401, 403}:
471
+ _auth_failure_exit()
472
+ _unknown_failure_exit()
473
+
474
+
475
+ def _parse_transaction_response_and_maybe_exit(
476
+ response, model_kind: str, duid: str
477
+ ) -> Any:
478
+ if (
479
+ response is None
480
+ or not hasattr(response, "results")
481
+ or len(response.results) == 0
482
+ or not response.results[0].success
483
+ ):
484
+ _unknown_failure_exit()
485
+ models = getattr(response.results[0].models, f"{model_kind}s")
486
+ model = next((e for e in models if e.duid == duid), None)
487
+ if model is None:
488
+ _unknown_failure_exit()
489
+ return model
490
+
491
+
492
+ def print_version() -> str:
493
+ result = f"dart-tools version {_VERSION}"
494
+ _log(result)
495
+ return result
496
+
497
+
498
+ @_suppress_exception
499
+ def print_version_update_message_maybe() -> None:
500
+ latest = (
501
+ _run_cmd("pip --disable-pip-version-check index versions dart-tools 2>&1")
502
+ .rsplit("LATEST:", maxsplit=1)[-1]
503
+ .split("\n", maxsplit=1)[0]
504
+ .strip()
505
+ )
506
+ if latest == _VERSION or [int(e) for e in latest.split(".")] <= [
507
+ int(e) for e in _VERSION.split(".")
508
+ ]:
509
+ return
510
+
511
+ _log(
512
+ f"A new version of dart-tools is available. Upgrade from {_VERSION} to {latest} with\n\n pip install --upgrade dart-tools\n"
513
+ )
514
+
515
+
516
+ def _get_is_logged_in(session: _Session) -> bool:
517
+ response = session.get(_USER_STATUS_URL_FRAG)
518
+ return response.json().get("isLoggedIn", False)
519
+
520
+
521
+ def is_logged_in(should_raise: bool = False) -> bool:
522
+ session = _Session()
523
+
524
+ result = _get_is_logged_in(session)
525
+
526
+ if not result and should_raise:
527
+ _auth_failure_exit()
528
+ _log(f"You are {'' if result else 'not '}logged in")
529
+ return result
530
+
531
+
532
+ def login(token: str | None = None) -> bool:
533
+ config = _Config()
534
+ session = _Session(config)
535
+
536
+ _log("Log in to Dart")
537
+ if token is None:
538
+ if not _is_cli:
539
+ _dart_exit("Login failed, token is required.")
540
+ _log(
541
+ "Dart is opening in your browser, log in if needed and copy your authentication token from the page"
542
+ )
543
+ open_new_tab(config.host + _PROFILE_SETTINGS_URL_FRAG)
544
+ token = input("Token: ")
545
+
546
+ config.set(_AUTH_TOKEN_KEY, token)
547
+
548
+ worked = _get_is_logged_in(session)
549
+ if not worked:
550
+ _dart_exit("Invalid token.")
551
+
552
+ _log("Logged in.")
553
+ return True
554
+
555
+
556
+ def _begin_task(
557
+ config: _Config, session: _Session, user_email: str, get_task: Callable
558
+ ) -> bool:
559
+ _Git.ensure_in_repo()
560
+ _Git.ensure_no_unstaged_changes()
561
+ _Git.ensure_on_main_or_intended()
562
+
563
+ task = get_task()
564
+
565
+ response = session.post(_COPY_BRANCH_URL_FRAG, json={"duid": task.duid})
566
+ _check_request_response_and_maybe_exit(response)
567
+
568
+ branch_name = _Git.make_task_name(user_email, task)
569
+ _Git.checkout_branch(branch_name)
570
+
571
+ _log(
572
+ f"Started work on\n\n {task.title}\n {_get_task_url(config.host, task.duid)}\n"
573
+ )
574
+ return True
575
+
576
+
577
+ def begin_task() -> bool:
578
+ config = _Config()
579
+ session = _Session(config)
580
+
581
+ user_bundle = UserBundle(session)
582
+ user = user_bundle.user
583
+
584
+ def _get_task():
585
+ user_duid = user["duid"]
586
+ active_duid = user_bundle.default_dartboard["duid"]
587
+ incomplete_status_duids = {
588
+ e["duid"]
589
+ for e in user_bundle.default_statuses
590
+ if e["kind"] not in _COMPLETED_STATUS_KINDS
591
+ }
592
+ filtered_tasks = [
593
+ e
594
+ for e in user_bundle.tasks
595
+ if not e["inTrash"]
596
+ and e["dartboardDuid"] == active_duid
597
+ and user_duid in e["assigneeDuids"]
598
+ and e["statusDuid"] in incomplete_status_duids
599
+ and e["drafterDuid"] is None
600
+ ]
601
+ filtered_tasks.sort(key=lambda e: e["order"])
602
+
603
+ if len(filtered_tasks) == 0:
604
+ _dart_exit("No active, incomplete tasks found.")
605
+
606
+ picked_idx = pick(
607
+ [e["title"] for e in filtered_tasks],
608
+ "Which of your active, incomplete tasks are you beginning work on?",
609
+ "→",
610
+ )[1]
611
+ assert isinstance(picked_idx, int)
612
+ return TaskCreate.from_dict(filtered_tasks[picked_idx])
613
+
614
+ _begin_task(config, session, user["email"], _get_task)
615
+
616
+ _log("Done.")
617
+ return True
618
+
619
+
620
+ def create_task(
621
+ title: str,
622
+ *,
623
+ dartboard_duid: str | None = None,
624
+ dartboard_title: str | None = None,
625
+ kind_title: str | None = None,
626
+ status_title: str | None = None,
627
+ assignee_emails: list[str] | None = None,
628
+ tag_titles: list[str] | None = None,
629
+ priority_int: int | None = None,
630
+ size_int: int | None = None,
631
+ due_at_str: str | None = None,
632
+ should_begin: bool = False,
633
+ ) -> Task:
634
+ config = _Config()
635
+ session = _Session(config)
636
+ dart = Dart(session)
637
+
638
+ user_bundle = UserBundle(session)
639
+
640
+ user = user_bundle.user
641
+ user_duid = user["duid"]
642
+
643
+ dartboards = user_bundle.dartboards
644
+ if dartboard_duid is None:
645
+ if dartboard_title is not None:
646
+ dartboard_title_norm = dartboard_title.strip().lower()
647
+ dartboard = next(
648
+ (
649
+ e
650
+ for e in dartboards
651
+ if dartboard_title_norm in {e["title"].lower(), e["kind"].lower()}
652
+ ),
653
+ None,
654
+ )
655
+ if dartboard is None:
656
+ _dart_exit(f"No dartboard found with title '{dartboard_title}'.")
657
+ else:
658
+ dartboard = user_bundle.default_dartboard
659
+ dartboard_duid = dartboard["duid"]
660
+
661
+ orders = [
662
+ e["order"] for e in user_bundle.tasks if e["dartboardDuid"] == dartboard_duid
663
+ ]
664
+ first_order = min(orders) if len(orders) > 0 else None
665
+ order = get_orders_between(None, first_order, 1)[0]
666
+
667
+ kinds = user_bundle.task_kinds
668
+ if kind_title is not None:
669
+ kind_title_norm = kind_title.strip().lower()
670
+ kind = next((e for e in kinds if e["title"].lower() == kind_title_norm), None)
671
+ if kind is None:
672
+ _dart_exit(f"No status found with title '{status_title}'.")
673
+ else:
674
+ kind = next(e for e in kinds if e["locked"])
675
+ kind_duid = kind["duid"]
676
+
677
+ statuses = user_bundle.default_statuses
678
+ if status_title is not None:
679
+ status_title_norm = status_title.strip().lower()
680
+ status = next(
681
+ (e for e in statuses if e["title"].lower() == status_title_norm), None
682
+ )
683
+ if status is None:
684
+ _dart_exit(f"No status found with title '{status_title}'.")
685
+ else:
686
+ status = next(
687
+ e for e in statuses if e["kind"] == StatusKind.UNSTARTED and e["locked"]
688
+ )
689
+ status_duid = status["duid"]
690
+
691
+ users = user_bundle.users
692
+ user_emails_to_duids = {e["email"]: e["duid"] for e in users}
693
+ assignee_duids = []
694
+ subscriber_duids = []
695
+ if assignee_emails is not None:
696
+ for assignee_email in assignee_emails:
697
+ assignee_email_norm = assignee_email.strip().lower()
698
+ if assignee_email_norm not in user_emails_to_duids:
699
+ _dart_exit(f"No user found with email '{assignee_email}'.")
700
+ assignee_duids.append(user_emails_to_duids[assignee_email_norm])
701
+ subscriber_duids.append(user_emails_to_duids[assignee_email_norm])
702
+ else:
703
+ assignee_duids.append(user_duid)
704
+ assignee_duids = list(set(assignee_duids))
705
+ subscriber_duids.append(user_duid)
706
+ subscriber_duids = list(set(subscriber_duids))
707
+
708
+ tags = user_bundle.default_tags
709
+ tag_titles_to_duids = {e["title"]: e["duid"] for e in tags}
710
+ tag_duids = []
711
+ if tag_titles is not None:
712
+ for tag_title in tag_titles:
713
+ tag_title_norm = tag_title.strip().lower()
714
+ if tag_title_norm not in tag_titles_to_duids:
715
+ _dart_exit(f"No tag found with title '{tag_title}'.")
716
+ tag_duids.append(tag_titles_to_duids[tag_title_norm])
717
+
718
+ priority = None
719
+ if priority_int is not None:
720
+ priority = _PRIORITY_MAP[priority_int]
721
+
722
+ size = size_int
723
+
724
+ due_at = None
725
+ if due_at_str is not None:
726
+ due_at = dateparser.parse(due_at_str)
727
+ if due_at is None:
728
+ _dart_exit(f"Could not parse due date '{due_at_str}'.")
729
+ due_at = due_at.replace(
730
+ hour=9, minute=0, second=0, microsecond=0, tzinfo=timezone.utc
731
+ )
732
+
733
+ task_create = TaskCreate(
734
+ duid=_make_duid(),
735
+ source_type=TaskSourceType.CLI,
736
+ drafter_duid=None,
737
+ dartboard_duid=dartboard_duid,
738
+ order=order,
739
+ kind_duid=kind_duid,
740
+ title=title,
741
+ status_duid=status_duid,
742
+ assignee_duids=assignee_duids,
743
+ subscriber_duids=subscriber_duids,
744
+ tag_duids=tag_duids,
745
+ priority=priority,
746
+ size=size,
747
+ due_at=due_at,
748
+ )
749
+ task_create_op = Operation(
750
+ model=OperationModelKind.TASK,
751
+ kind=OperationKind.CREATE,
752
+ data=task_create,
753
+ )
754
+ response = dart.transact([task_create_op], TransactionKind.TASK_CREATE)
755
+ task = _parse_transaction_response_and_maybe_exit(
756
+ response, OperationModelKind.TASK, task_create.duid
757
+ )
758
+
759
+ _log(f"Created task {task.title} at {_get_task_url(config.host, task.duid)}")
760
+
761
+ if should_begin:
762
+ _begin_task(config, session, user["email"], lambda: task_create)
763
+
764
+ _log("Done.")
765
+ return task
766
+
767
+
768
+ def update_task(
769
+ duid: str,
770
+ *,
771
+ title: str | None = None,
772
+ dartboard_duid: str | None = None,
773
+ dartboard_title: str | None = None,
774
+ status_title: str | None = None,
775
+ assignee_emails: list[str] | None = None,
776
+ tag_titles: list[str] | None = None,
777
+ priority_int: int | None = None,
778
+ size_int: int | None = None,
779
+ due_at_str: str | None = None,
780
+ ) -> Task:
781
+ config = _Config()
782
+ session = _Session(config)
783
+ dart = Dart(session)
784
+
785
+ user_bundle = UserBundle(session)
786
+
787
+ user = user_bundle.user
788
+ user_duid = user["duid"]
789
+
790
+ tasks = user_bundle.tasks
791
+ old_task = next((e for e in tasks if e["duid"] == duid), None)
792
+ if old_task is None:
793
+ _dart_exit(f"No task found with DUID '{duid}'.")
794
+
795
+ task_update_kwargs = {"duid": duid}
796
+
797
+ if title is not None:
798
+ task_update_kwargs["title"] = title
799
+
800
+ dartboards = user_bundle.dartboards
801
+ if dartboard_duid is not None:
802
+ task_update_kwargs["dartboard_duid"] = dartboard_duid
803
+ elif dartboard_title is not None:
804
+ dartboard_title_norm = dartboard_title.strip().lower()
805
+ dartboard = next(
806
+ (
807
+ e
808
+ for e in dartboards
809
+ if dartboard_title_norm in {e["title"].lower(), e["kind"].lower()}
810
+ ),
811
+ None,
812
+ )
813
+ if dartboard is None:
814
+ _dart_exit(f"No dartboard found with title '{dartboard_title}'.")
815
+ dartboard_duid = dartboard["duid"]
816
+ if dartboard_duid != old_task["dartboardDuid"]:
817
+ task_update_kwargs["dartboard_duid"] = dartboard_duid
818
+
819
+ statuses = user_bundle.default_statuses
820
+ if status_title is not None:
821
+ status_title_norm = status_title.strip().lower()
822
+ status = next(
823
+ (e for e in statuses if e["title"].lower() == status_title_norm), None
824
+ )
825
+ if status is None:
826
+ _dart_exit(f"No status found with title '{status_title}'.")
827
+ status_duid = status["duid"]
828
+ if status_duid != old_task["statusDuid"]:
829
+ task_update_kwargs["status_duid"] = status_duid
830
+
831
+ users = user_bundle.users
832
+ user_emails_to_duids = {e["email"]: e["duid"] for e in users}
833
+ subscriber_duids = []
834
+ if assignee_emails is not None:
835
+ assignee_duids = []
836
+ for assignee_email in assignee_emails:
837
+ assignee_email_norm = assignee_email.strip().lower()
838
+ if assignee_email_norm not in user_emails_to_duids:
839
+ _dart_exit(f"No user found with email '{assignee_email}'.")
840
+ assignee_duids.append(user_emails_to_duids[assignee_email_norm])
841
+ subscriber_duids.append(user_emails_to_duids[assignee_email_norm])
842
+ assignee_duids = sorted(set(assignee_duids))
843
+ if assignee_duids != old_task["assigneeDuids"]:
844
+ task_update_kwargs["assignee_duids"] = assignee_duids
845
+
846
+ # TODO do add to list operation rather than replace
847
+ subscriber_duids = list(
848
+ set(old_task["subscriberDuids"]) | set(subscriber_duids) | {user_duid}
849
+ )
850
+ if subscriber_duids != old_task["subscriberDuids"]:
851
+ task_update_kwargs["subscriber_duids"] = subscriber_duids
852
+
853
+ tags = user_bundle.default_tags
854
+ tag_titles_to_duids = {e["title"]: e["duid"] for e in tags}
855
+ if tag_titles is not None:
856
+ tag_duids = []
857
+ for tag_title in tag_titles:
858
+ tag_title_norm = tag_title.strip().lower()
859
+ if tag_title_norm not in tag_titles_to_duids:
860
+ _dart_exit(f"No tag found with title '{tag_title}'.")
861
+ tag_duids.append(tag_titles_to_duids[tag_title_norm])
862
+ task_update_kwargs["tag_duids"] = tag_duids
863
+
864
+ # TODO add a way for optional stuff to be removed
865
+ if priority_int is not None:
866
+ priority = _PRIORITY_MAP[priority_int]
867
+ if priority != old_task["priority"]:
868
+ task_update_kwargs["priority"] = priority
869
+
870
+ if size_int is not None:
871
+ size = size_int
872
+ if size != old_task["size"]:
873
+ task_update_kwargs["size"] = size
874
+
875
+ if due_at_str is not None:
876
+ due_at = dateparser.parse(due_at_str)
877
+ if due_at is None:
878
+ _dart_exit(f"Could not parse due date '{due_at_str}'.")
879
+ due_at = due_at.replace(
880
+ hour=9, minute=0, second=0, microsecond=0, tzinfo=timezone.utc
881
+ )
882
+ due_at = due_at.isoformat()[:-6] + ".000Z"
883
+ if due_at != old_task["dueAt"]:
884
+ task_update_kwargs["due_at"] = due_at
885
+
886
+ task_update = TaskUpdate(**task_update_kwargs)
887
+ task_update_op = Operation(
888
+ model=OperationModelKind.TASK,
889
+ kind=OperationKind.UPDATE,
890
+ data=task_update,
891
+ )
892
+ response = dart.transact([task_update_op], TransactionKind.TASK_UPDATE)
893
+ task = _parse_transaction_response_and_maybe_exit(
894
+ response, OperationModelKind.TASK, task_update.duid
895
+ )
896
+
897
+ _log(f"Updated task {task.title} at {_get_task_url(config.host, task.duid)}")
898
+ _log("Done.")
899
+ return task
900
+
901
+
902
+ def replicate_space(
903
+ duid: str,
904
+ *,
905
+ title: str | None = None,
906
+ abrev: str | None = None,
907
+ color_hex: str | None = None,
908
+ accessible_by_team: bool | None = None,
909
+ accessor_duids: list[str] | None = None,
910
+ ) -> str:
911
+ config = _Config()
912
+ session = _Session(config)
913
+
914
+ content = {}
915
+ if title is not None:
916
+ content["title"] = title
917
+ if abrev is not None:
918
+ content["abrev"] = abrev
919
+ if color_hex is not None:
920
+ content["colorHex"] = color_hex
921
+ if accessible_by_team is not None:
922
+ content["accessibleByTeam"] = accessible_by_team
923
+ if accessor_duids is not None:
924
+ content["accessorIds"] = accessor_duids
925
+ response = session.post(
926
+ _REPLICATE_SPACE_URL_FRAG_FMT.format(duid=duid), json=content
927
+ )
928
+ _check_request_response_and_maybe_exit(response)
929
+
930
+ space_duid = response.json()["duid"]
931
+
932
+ _log(f"Replicated space at {_get_space_url(config.host, space_duid)}")
933
+ _log("Done.")
934
+ return space_duid
935
+
936
+
937
+ def get_dartboards(space_duid: str, include_special: bool = False) -> list[Dartboard]:
938
+ dart = Dart()
939
+
940
+ response = dartboards_list.sync(client=dart, space_duid=space_duid)
941
+ dartboards = response.results if response is not None else []
942
+ if not include_special:
943
+ dartboards = [e for e in dartboards if e.kind == DartboardKind.CUSTOM]
944
+
945
+ _log(f"Got {len(dartboards)} dartboards")
946
+ _log("Done.")
947
+ return dartboards
948
+
949
+
950
+ def replicate_dartboard(duid: str, *, title: str | None = None) -> str:
951
+ config = _Config()
952
+ session = _Session(config)
953
+
954
+ content = {}
955
+ if title is not None:
956
+ content["title"] = title
957
+ response = session.post(
958
+ _REPLICATE_DARTBOARD_URL_FRAG_FMT.format(duid=duid), json=content
959
+ )
960
+ _check_request_response_and_maybe_exit(response)
961
+
962
+ dartboard_duid = response.json()["duid"]
963
+
964
+ _log(f"Replicated dartboard at {_get_dartboard_url(config.host, dartboard_duid)}")
965
+ _log("Done.")
966
+ return dartboard_duid
967
+
968
+
969
+ def update_dartboard(
970
+ duid: str,
971
+ *,
972
+ title: str | None = None,
973
+ color_hex: str | None = None,
974
+ ) -> Dartboard:
975
+ config = _Config()
976
+ session = _Session(config)
977
+ dart = Dart(session)
978
+
979
+ dartboard_update_kwargs = {"duid": duid}
980
+
981
+ if title is not None:
982
+ dartboard_update_kwargs["title"] = title
983
+ if color_hex is not None:
984
+ dartboard_update_kwargs["color_hex"] = color_hex
985
+
986
+ dartboard_update = DartboardUpdate(**dartboard_update_kwargs)
987
+ dartboard_update_op = Operation(
988
+ model=OperationModelKind.DARTBOARD,
989
+ kind=OperationKind.UPDATE,
990
+ data=dartboard_update,
991
+ )
992
+ response = dart.transact([dartboard_update_op], TransactionKind.DARTBOARD_UPDATE)
993
+ dartboard = _parse_transaction_response_and_maybe_exit(
994
+ response, OperationModelKind.DARTBOARD, dartboard_update.duid
995
+ )
996
+
997
+ _log(
998
+ f"Updated dartboard {dartboard.title} at {_get_dartboard_url(config.host, dartboard.duid)}"
999
+ )
1000
+ _log("Done.")
1001
+ return dartboard
1002
+
1003
+
1004
+ def get_folders(space_duid: str, *, include_special: bool = False) -> list[Folder]:
1005
+ dart = Dart()
1006
+
1007
+ response = folders_list.sync(client=dart, space_duid=space_duid)
1008
+ folders = response.results if response is not None else []
1009
+ if not include_special:
1010
+ folders = [e for e in folders if e.kind == FolderKind.OTHER]
1011
+
1012
+ _log(f"Got {len(folders)} folders")
1013
+ _log("Done.")
1014
+ return folders
1015
+
1016
+
1017
+ def update_folder(
1018
+ duid: str,
1019
+ *,
1020
+ title: str | None = None,
1021
+ color_hex: str | None = None,
1022
+ ) -> Folder:
1023
+ config = _Config()
1024
+ session = _Session(config)
1025
+ dart = Dart(session)
1026
+
1027
+ folder_update_kwargs = {"duid": duid}
1028
+
1029
+ if title is not None:
1030
+ folder_update_kwargs["title"] = title
1031
+ if color_hex is not None:
1032
+ folder_update_kwargs["color_hex"] = color_hex
1033
+
1034
+ folder_update = FolderUpdate(**folder_update_kwargs)
1035
+ folder_update_op = Operation(
1036
+ model=OperationModelKind.FOLDER,
1037
+ kind=OperationKind.UPDATE,
1038
+ data=folder_update,
1039
+ )
1040
+ response = dart.transact([folder_update_op], TransactionKind.FOLDER_UPDATE)
1041
+ folder = _parse_transaction_response_and_maybe_exit(
1042
+ response, OperationModelKind.FOLDER, folder_update.duid
1043
+ )
1044
+
1045
+ _log(
1046
+ f"Updated folder {folder.title} at {_get_folder_url(config.host, folder.duid)}"
1047
+ )
1048
+ _log("Done.")
1049
+ return folder
1050
+
1051
+
1052
+ def _add_standard_task_arguments(parser: ArgumentParser) -> None:
1053
+ parser.add_argument(
1054
+ "-d", "--dartboard", dest="dartboard_title", help="dartboard title"
1055
+ )
1056
+ parser.add_argument("-s", "--status", dest="status_title", help="status title")
1057
+ parser.add_argument(
1058
+ "-a",
1059
+ "--assignee",
1060
+ dest="assignee_emails",
1061
+ nargs="*",
1062
+ action="extend",
1063
+ help="assignee email(s)",
1064
+ )
1065
+ parser.add_argument(
1066
+ "-t",
1067
+ "--tag",
1068
+ dest="tag_titles",
1069
+ nargs="*",
1070
+ action="extend",
1071
+ help="tag title(s)",
1072
+ )
1073
+ parser.add_argument(
1074
+ "-p",
1075
+ "--priority",
1076
+ dest="priority_int",
1077
+ type=int,
1078
+ choices=_PRIORITY_MAP.keys(),
1079
+ help="priority",
1080
+ )
1081
+ parser.add_argument(
1082
+ "-i", "--size", dest="size_int", type=int, choices=_SIZES, help="size"
1083
+ )
1084
+ parser.add_argument("-r", "--duedate", dest="due_at_str", help="due date")
1085
+
1086
+
1087
+ def cli() -> None:
1088
+ signal.signal(signal.SIGINT, _exit_gracefully)
1089
+ global _is_cli
1090
+ _is_cli = True
1091
+
1092
+ print_version_update_message_maybe()
1093
+
1094
+ if _VERSION_CMD in sys.argv[1:]:
1095
+ print_version()
1096
+ return
1097
+
1098
+ parser = ArgumentParser(prog=_PROG, description="A CLI to interact with Dart")
1099
+ subparsers = parser.add_subparsers(
1100
+ title="command",
1101
+ required=True,
1102
+ metavar=f"{{{_LOGIN_CMD},{_CREATE_TASK_CMD},{_UPDATE_TASK_CMD},{_BEGIN_TASK_CMD}}}",
1103
+ )
1104
+
1105
+ set_host_parser = subparsers.add_parser(_SET_HOST_CMD, aliases="s")
1106
+ set_host_parser.add_argument("host", help="the new host: {prod|stag|dev|[URL]}")
1107
+ set_host_parser.set_defaults(func=set_host)
1108
+
1109
+ login_parser = subparsers.add_parser(_LOGIN_CMD, aliases="l", help="login")
1110
+ login_parser.add_argument(
1111
+ "-t", "--token", dest="token", help="your authentication token"
1112
+ )
1113
+ login_parser.set_defaults(func=login)
1114
+
1115
+ create_task_parser = subparsers.add_parser(
1116
+ _CREATE_TASK_CMD, aliases="c", help="create a new task"
1117
+ )
1118
+ create_task_parser.add_argument("title", help="title of the task")
1119
+ create_task_parser.add_argument(
1120
+ "-b",
1121
+ "--begin",
1122
+ dest="should_begin",
1123
+ action="store_true",
1124
+ help="begin work on the task after creation",
1125
+ )
1126
+ _add_standard_task_arguments(create_task_parser)
1127
+ create_task_parser.set_defaults(func=create_task)
1128
+
1129
+ update_task_parser = subparsers.add_parser(
1130
+ _UPDATE_TASK_CMD, aliases="u", help="update an existing task"
1131
+ )
1132
+ update_task_parser.add_argument("duid", help="Dart ID (DUID) of the task")
1133
+ update_task_parser.add_argument("-e", "--title", dest="title", help="task title")
1134
+ _add_standard_task_arguments(update_task_parser)
1135
+ update_task_parser.set_defaults(func=update_task)
1136
+
1137
+ begin_task_parser = subparsers.add_parser(
1138
+ _BEGIN_TASK_CMD, aliases="b", help="begin work on a task"
1139
+ )
1140
+ begin_task_parser.set_defaults(func=begin_task)
1141
+
1142
+ args = vars(parser.parse_args())
1143
+ func = args.pop("func")
1144
+ func(**args)