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.
- dart/__init__.py +20 -0
- dart/dart.py +1144 -0
- dart/exception.py +6 -0
- dart/generated/__init__.py +8 -0
- dart/generated/api/__init__.py +1 -0
- dart/generated/api/attachments/__init__.py +0 -0
- dart/generated/api/attachments/attachments_list.py +169 -0
- dart/generated/api/comments/__init__.py +0 -0
- dart/generated/api/comments/comments_list.py +278 -0
- dart/generated/api/dartboards/__init__.py +0 -0
- dart/generated/api/dartboards/dartboards_list.py +271 -0
- dart/generated/api/dashboards/__init__.py +0 -0
- dart/generated/api/dashboards/dashboards_list.py +184 -0
- dart/generated/api/docs/__init__.py +0 -0
- dart/generated/api/docs/docs_list.py +372 -0
- dart/generated/api/folders/__init__.py +0 -0
- dart/generated/api/folders/folders_list.py +234 -0
- dart/generated/api/form_fields/__init__.py +0 -0
- dart/generated/api/form_fields/form_fields_list.py +169 -0
- dart/generated/api/forms/__init__.py +0 -0
- dart/generated/api/forms/forms_list.py +169 -0
- dart/generated/api/layouts/__init__.py +0 -0
- dart/generated/api/layouts/layouts_list.py +169 -0
- dart/generated/api/links/__init__.py +0 -0
- dart/generated/api/links/links_list.py +169 -0
- dart/generated/api/options/__init__.py +0 -0
- dart/generated/api/options/options_list.py +229 -0
- dart/generated/api/properties/__init__.py +0 -0
- dart/generated/api/properties/properties_list.py +204 -0
- dart/generated/api/reactions/__init__.py +0 -0
- dart/generated/api/reactions/reactions_list.py +169 -0
- dart/generated/api/relationship_kinds/__init__.py +0 -0
- dart/generated/api/relationship_kinds/relationship_kinds_list.py +169 -0
- dart/generated/api/relationships/__init__.py +0 -0
- dart/generated/api/relationships/relationships_list.py +169 -0
- dart/generated/api/spaces/__init__.py +0 -0
- dart/generated/api/spaces/spaces_list.py +214 -0
- dart/generated/api/statuses/__init__.py +0 -0
- dart/generated/api/statuses/statuses_list.py +249 -0
- dart/generated/api/task_doc_relationships/__init__.py +0 -0
- dart/generated/api/task_doc_relationships/task_doc_relationships_list.py +169 -0
- dart/generated/api/task_kinds/__init__.py +0 -0
- dart/generated/api/task_kinds/task_kinds_list.py +204 -0
- dart/generated/api/tasks/__init__.py +0 -0
- dart/generated/api/tasks/tasks_list.py +446 -0
- dart/generated/api/tenants/__init__.py +0 -0
- dart/generated/api/tenants/tenants_list.py +169 -0
- dart/generated/api/transactions/__init__.py +0 -0
- dart/generated/api/transactions/transactions_create.py +176 -0
- dart/generated/api/user_dartboard_layouts/__init__.py +0 -0
- dart/generated/api/user_dartboard_layouts/user_dartboard_layouts_list.py +169 -0
- dart/generated/api/user_data/__init__.py +0 -0
- dart/generated/api/user_data/user_data_entity_retrieve.py +580 -0
- dart/generated/api/users/__init__.py +0 -0
- dart/generated/api/users/users_list.py +214 -0
- dart/generated/api/views/__init__.py +0 -0
- dart/generated/api/views/views_list.py +184 -0
- dart/generated/api/webhooks/__init__.py +0 -0
- dart/generated/api/webhooks/webhooks_list.py +169 -0
- dart/generated/client.py +268 -0
- dart/generated/errors.py +16 -0
- dart/generated/models/__init__.py +373 -0
- dart/generated/models/attachment.py +112 -0
- dart/generated/models/attachment_create.py +121 -0
- dart/generated/models/attachment_update.py +125 -0
- dart/generated/models/bar_chart_adtl.py +72 -0
- dart/generated/models/brainstorm.py +149 -0
- dart/generated/models/brainstorm_create.py +134 -0
- dart/generated/models/brainstorm_state.py +10 -0
- dart/generated/models/brainstorm_update.py +153 -0
- dart/generated/models/burn_up_chart_adtl.py +103 -0
- dart/generated/models/chart.py +208 -0
- dart/generated/models/chart_aggregation.py +10 -0
- dart/generated/models/chart_type.py +14 -0
- dart/generated/models/comment.py +207 -0
- dart/generated/models/comment_create.py +146 -0
- dart/generated/models/comment_reaction.py +84 -0
- dart/generated/models/comment_reaction_create.py +82 -0
- dart/generated/models/comment_reaction_update.py +87 -0
- dart/generated/models/comment_update.py +148 -0
- dart/generated/models/dartboard.py +280 -0
- dart/generated/models/dartboard_create.py +285 -0
- dart/generated/models/dartboard_kind.py +12 -0
- dart/generated/models/dartboard_update.py +287 -0
- dart/generated/models/dartboards_list_kind.py +12 -0
- dart/generated/models/dashboard.py +185 -0
- dart/generated/models/dashboard_create.py +171 -0
- dart/generated/models/dashboard_update.py +173 -0
- dart/generated/models/discord_integration.py +72 -0
- dart/generated/models/doc.py +243 -0
- dart/generated/models/doc_create.py +295 -0
- dart/generated/models/doc_source_type.py +13 -0
- dart/generated/models/doc_update.py +295 -0
- dart/generated/models/docs_list_o_item.py +11 -0
- dart/generated/models/entity_name.py +22 -0
- dart/generated/models/event.py +420 -0
- dart/generated/models/event_actor.py +19 -0
- dart/generated/models/event_create.py +158 -0
- dart/generated/models/event_kind.py +88 -0
- dart/generated/models/event_subscription.py +74 -0
- dart/generated/models/event_subscription_update.py +173 -0
- dart/generated/models/filter_applicability.py +22 -0
- dart/generated/models/filter_assignee.py +116 -0
- dart/generated/models/filter_connector.py +9 -0
- dart/generated/models/filter_group.py +112 -0
- dart/generated/models/filter_search.py +82 -0
- dart/generated/models/filter_set.py +116 -0
- dart/generated/models/folder.py +150 -0
- dart/generated/models/folder_create.py +150 -0
- dart/generated/models/folder_kind.py +10 -0
- dart/generated/models/folder_update.py +152 -0
- dart/generated/models/folders_list_kind.py +10 -0
- dart/generated/models/form.py +147 -0
- dart/generated/models/form_create.py +141 -0
- dart/generated/models/form_field.py +144 -0
- dart/generated/models/form_field_create.py +129 -0
- dart/generated/models/form_field_update.py +132 -0
- dart/generated/models/form_update.py +142 -0
- dart/generated/models/github_integration.py +163 -0
- dart/generated/models/github_integration_tenant_extension_status.py +11 -0
- dart/generated/models/google_data.py +94 -0
- dart/generated/models/icon_kind.py +10 -0
- dart/generated/models/layout.py +167 -0
- dart/generated/models/layout_config.py +70 -0
- dart/generated/models/layout_create.py +130 -0
- dart/generated/models/layout_kind.py +11 -0
- dart/generated/models/layout_kind_config_map.py +56 -0
- dart/generated/models/layout_update.py +130 -0
- dart/generated/models/line_chart_adtl.py +72 -0
- dart/generated/models/models_response.py +671 -0
- dart/generated/models/notification.py +120 -0
- dart/generated/models/notification_update.py +100 -0
- dart/generated/models/notion_integration.py +90 -0
- dart/generated/models/notion_integration_tenant_extension_status.py +10 -0
- dart/generated/models/number_chart_adtl.py +77 -0
- dart/generated/models/operation.py +874 -0
- dart/generated/models/operation_kind.py +12 -0
- dart/generated/models/operation_model_kind.py +36 -0
- dart/generated/models/option.py +118 -0
- dart/generated/models/option_create.py +105 -0
- dart/generated/models/option_update.py +107 -0
- dart/generated/models/paginated_attachment_list.py +122 -0
- dart/generated/models/paginated_comment_list.py +122 -0
- dart/generated/models/paginated_comment_reaction_list.py +122 -0
- dart/generated/models/paginated_dartboard_list.py +122 -0
- dart/generated/models/paginated_dashboard_list.py +122 -0
- dart/generated/models/paginated_doc_list.py +122 -0
- dart/generated/models/paginated_folder_list.py +122 -0
- dart/generated/models/paginated_form_field_list.py +122 -0
- dart/generated/models/paginated_form_list.py +122 -0
- dart/generated/models/paginated_layout_list.py +122 -0
- dart/generated/models/paginated_option_list.py +122 -0
- dart/generated/models/paginated_property_list.py +122 -0
- dart/generated/models/paginated_relationship_kind_list.py +122 -0
- dart/generated/models/paginated_relationship_list.py +122 -0
- dart/generated/models/paginated_space_list.py +122 -0
- dart/generated/models/paginated_status_list.py +122 -0
- dart/generated/models/paginated_task_doc_relationship_list.py +122 -0
- dart/generated/models/paginated_task_kind_list.py +122 -0
- dart/generated/models/paginated_task_link_list.py +122 -0
- dart/generated/models/paginated_task_list.py +122 -0
- dart/generated/models/paginated_tenant_list.py +122 -0
- dart/generated/models/paginated_user_dartboard_layout_list.py +122 -0
- dart/generated/models/paginated_user_list.py +122 -0
- dart/generated/models/paginated_view_list.py +122 -0
- dart/generated/models/paginated_webhook_list.py +122 -0
- dart/generated/models/pie_chart_adtl.py +69 -0
- dart/generated/models/pie_chart_display_metric.py +9 -0
- dart/generated/models/priority.py +11 -0
- dart/generated/models/properties_list_kind.py +32 -0
- dart/generated/models/property_.py +153 -0
- dart/generated/models/property_create.py +137 -0
- dart/generated/models/property_kind.py +32 -0
- dart/generated/models/property_update.py +146 -0
- dart/generated/models/relationship.py +74 -0
- dart/generated/models/relationship_create.py +93 -0
- dart/generated/models/relationship_kind.py +123 -0
- dart/generated/models/relationship_kind_create.py +117 -0
- dart/generated/models/relationship_kind_kind.py +12 -0
- dart/generated/models/relationship_kind_update.py +119 -0
- dart/generated/models/report_kind.py +9 -0
- dart/generated/models/request_body.py +80 -0
- dart/generated/models/response_body.py +72 -0
- dart/generated/models/saml_config.py +77 -0
- dart/generated/models/saml_config_tenant_extension_status.py +9 -0
- dart/generated/models/slack_integration.py +90 -0
- dart/generated/models/slack_integration_tenant_extension_status.py +10 -0
- dart/generated/models/sort.py +66 -0
- dart/generated/models/space.py +325 -0
- dart/generated/models/space_create.py +360 -0
- dart/generated/models/space_kind.py +10 -0
- dart/generated/models/space_update.py +361 -0
- dart/generated/models/sprint_mode.py +9 -0
- dart/generated/models/status.py +141 -0
- dart/generated/models/status_create.py +125 -0
- dart/generated/models/status_kind.py +12 -0
- dart/generated/models/status_update.py +135 -0
- dart/generated/models/statuses_list_kind.py +12 -0
- dart/generated/models/subscription.py +9 -0
- dart/generated/models/subtask_display_mode.py +10 -0
- dart/generated/models/summary_statistic_kind.py +14 -0
- dart/generated/models/table_chart_adtl.py +98 -0
- dart/generated/models/task.py +531 -0
- dart/generated/models/task_create.py +585 -0
- dart/generated/models/task_detail_mode.py +10 -0
- dart/generated/models/task_doc_relationship.py +96 -0
- dart/generated/models/task_doc_relationship_create.py +74 -0
- dart/generated/models/task_kind.py +149 -0
- dart/generated/models/task_kind_create.py +144 -0
- dart/generated/models/task_kind_kind.py +9 -0
- dart/generated/models/task_kind_update.py +153 -0
- dart/generated/models/task_kinds_list_kind.py +9 -0
- dart/generated/models/task_link.py +131 -0
- dart/generated/models/task_link_create.py +152 -0
- dart/generated/models/task_link_kind.py +19 -0
- dart/generated/models/task_link_update.py +155 -0
- dart/generated/models/task_notion_document.py +196 -0
- dart/generated/models/task_notion_document_block_children_map_type_0.py +43 -0
- dart/generated/models/task_notion_document_block_map_type_0.py +43 -0
- dart/generated/models/task_notion_document_page_map_type_0.py +43 -0
- dart/generated/models/task_properties.py +43 -0
- dart/generated/models/task_source_type.py +31 -0
- dart/generated/models/task_update.py +585 -0
- dart/generated/models/tenant.py +378 -0
- dart/generated/models/tenant_update.py +157 -0
- dart/generated/models/theme.py +10 -0
- dart/generated/models/transaction.py +156 -0
- dart/generated/models/transaction_kind.py +74 -0
- dart/generated/models/transaction_response.py +96 -0
- dart/generated/models/user.py +245 -0
- dart/generated/models/user_dartboard_layout.py +66 -0
- dart/generated/models/user_dartboard_layout_create.py +74 -0
- dart/generated/models/user_data_entity_retrieve_entity_kind.py +32 -0
- dart/generated/models/user_role.py +12 -0
- dart/generated/models/user_status.py +12 -0
- dart/generated/models/user_update.py +210 -0
- dart/generated/models/validation_error_response.py +64 -0
- dart/generated/models/validation_error_response_items.py +43 -0
- dart/generated/models/view.py +215 -0
- dart/generated/models/view_create.py +213 -0
- dart/generated/models/view_kind.py +11 -0
- dart/generated/models/view_update.py +215 -0
- dart/generated/models/webhook.py +96 -0
- dart/generated/models/webhook_create.py +77 -0
- dart/generated/models/webhook_update.py +78 -0
- dart/generated/models/zapier_integration.py +66 -0
- dart/generated/py.typed +1 -0
- dart/generated/types.py +45 -0
- dart/order_manager.py +62 -0
- dart/webhook.py +21 -0
- dart_tools-0.6.11.dist-info/LICENSE +21 -0
- dart_tools-0.6.11.dist-info/METADATA +221 -0
- dart_tools-0.6.11.dist-info/RECORD +257 -0
- dart_tools-0.6.11.dist-info/WHEEL +5 -0
- dart_tools-0.6.11.dist-info/dist/dart-tools-0.3.3.tar.gz +0 -0
- dart_tools-0.6.11.dist-info/entry_points.txt +2 -0
- 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)
|