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