-
Notifications
You must be signed in to change notification settings - Fork 14
Expand file tree
/
Copy pathproject_parser.py
More file actions
253 lines (225 loc) · 8.8 KB
/
Copy pathproject_parser.py
File metadata and controls
253 lines (225 loc) · 8.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
from __future__ import annotations
import sys
from collections.abc import Callable
from dataclasses import dataclass
import base_cli
from base_projects.command_helpers import ProjectUsageError
from .project_model import ProjectArguments
HELP_OPTIONS = ("-h", "--help", "help")
PROJECT_VALUE_OPTIONS = (
"--project",
"--owner",
"--repo",
"--schema",
"--config",
"--copy-fields-from",
"--initiative-option",
)
ISSUE_FIELD_OPTIONS = ("--status", "--priority", "--area", "--initiative", "--size")
OPTION_NOT_CONSUMED = 0
SPACED_OPTION_TOKEN_COUNT = 2
@dataclass
class OptionState:
project_title: str | None = None
owner: str | None = None
repo: str | None = None
schema: str = "base-project"
config_path: str | None = None
copy_fields_from_project: str | None = None
initiative_options: list[str] | None = None
replace_project: bool = False
allow_cross_repo: bool = False
dry_run: bool = False
field_values: dict[str, str] | None = None
def print_usage(file=sys.stdout) -> None:
command = base_cli.delegated_display_command("base_github_projects")
print(
"\n".join(
(
"Usage:",
f" {command} project doctor --project <title> "
"[--owner <login>] [--schema base-project]",
f" {command} project configure --project <title> "
"[--owner <login>] [--repo <owner/name>] [--schema base-project] [--config <path>] "
"[--copy-fields-from <title>] [--replace-project] [--initiative-option <name>] [--dry-run]",
f" {command} project issue set-fields <number> "
"--repo <owner/name> --project <title> [--config <path>] "
"[--allow-cross-repo] [field options...]",
f" {command} project issue defaults --config <path>",
)
),
file=file,
)
def parse_args(
arguments: tuple[str, ...],
*,
infer_owner_from_git: Callable[[], str | None] | None = None,
infer_repo_from_git: Callable[[], str | None] | None = None,
) -> ProjectArguments:
infer_owner = infer_owner_from_git or no_inferred_value
infer_repo = infer_repo_from_git or no_inferred_value
if not arguments or arguments[0] in HELP_OPTIONS:
print_usage()
raise SystemExit(0)
if arguments[0] != "project":
raise ProjectUsageError(f"Unknown area '{arguments[0]}'.")
if len(arguments) < 2:
raise ProjectUsageError("The 'project' area requires a command.")
command = arguments[1]
remaining = list(arguments[2:])
if command == "doctor":
state = parse_project_options(
remaining,
allow_fields=False,
allow_issue=False,
infer_owner_from_git=infer_owner,
)
require_project_title(state)
return state_to_args("doctor", state)
if command == "configure":
state = parse_project_options(
remaining,
allow_fields=False,
allow_issue=False,
infer_owner_from_git=infer_owner,
)
require_project_title(state)
return state_to_args("configure", state)
if command == "issue":
if remaining and remaining[0] == "defaults":
state = parse_project_options(
remaining[1:],
allow_fields=False,
allow_issue=False,
infer_owner_from_git=infer_owner,
)
if not state.config_path:
raise ProjectUsageError("The 'project issue defaults' command requires --config.")
return state_to_args("issue-defaults", state)
if len(remaining) < 2 or remaining[0] != "set-fields":
raise ProjectUsageError("Expected 'project issue set-fields <number>'.")
try:
issue_number = int(remaining[1])
except ValueError as exc:
raise ProjectUsageError(f"Invalid issue number '{remaining[1]}'.") from exc
state = parse_project_options(
remaining[2:],
allow_fields=True,
allow_issue=True,
infer_owner_from_git=infer_owner,
)
require_project_title(state)
if not state.repo:
state.repo = infer_repo()
if not state.repo:
raise ProjectUsageError("The 'project issue set-fields' command requires --repo.")
return state_to_args("issue-set-fields", state, issue_number=issue_number)
raise ProjectUsageError(f"Unknown project command '{command}'.")
def parse_project_options(
remaining: list[str],
*,
allow_fields: bool,
allow_issue: bool,
infer_owner_from_git: Callable[[], str | None] | None = None,
) -> OptionState:
state = OptionState(initiative_options=[], field_values={})
index = 0
while index < len(remaining):
arg = remaining[index]
if arg in HELP_OPTIONS:
print_usage()
raise SystemExit(0)
if arg == "--dry-run":
state.dry_run = True
index += 1
continue
if arg == "--replace-project":
state.replace_project = True
index += 1
continue
if allow_issue and arg == "--allow-cross-repo":
state.allow_cross_repo = True
index += 1
continue
if apply_inline_option(state, arg, allow_fields=allow_fields):
index += 1
continue
consumed = apply_spaced_option(state, remaining, index, allow_fields=allow_fields)
if consumed:
index += consumed
continue
if allow_issue:
raise ProjectUsageError(f"Unknown issue field option '{arg}'.")
raise ProjectUsageError(f"Unknown option '{arg}'.")
if state.schema != "base-project":
raise ProjectUsageError("Only project schema 'base-project' is supported.")
if not state.owner and state.repo:
state.owner = state.repo.split("/", 1)[0]
if not state.owner:
infer_owner = infer_owner_from_git or no_inferred_value
state.owner = infer_owner()
return state
def apply_spaced_option(state: OptionState, remaining: list[str], index: int, *, allow_fields: bool) -> int:
"""Apply a spaced option and return the number of consumed tokens."""
option = remaining[index]
if option in PROJECT_VALUE_OPTIONS:
apply_project_option(state, option, option_value(remaining, index))
return SPACED_OPTION_TOKEN_COUNT
if allow_fields and option in ISSUE_FIELD_OPTIONS:
state.field_values[option[2:]] = option_value(remaining, index)
return SPACED_OPTION_TOKEN_COUNT
return OPTION_NOT_CONSUMED
def apply_inline_option(state: OptionState, argument: str, *, allow_fields: bool) -> bool:
"""Apply Click-compatible ``--option=value`` syntax to delegated options."""
if not argument.startswith("--") or "=" not in argument:
return False
option, value = argument.split("=", 1)
if option in PROJECT_VALUE_OPTIONS:
apply_project_option(state, option, value)
return True
if allow_fields and option in ISSUE_FIELD_OPTIONS:
state.field_values[option[2:]] = value
return True
return False
def option_value(remaining: list[str], index: int) -> str:
option = remaining[index]
if index + 1 >= len(remaining):
raise ProjectUsageError(f"Option '{option}' requires an argument.")
return remaining[index + 1]
def apply_project_option(state: OptionState, option: str, value: str) -> None:
if option == "--project":
state.project_title = value
elif option == "--owner":
state.owner = value
elif option == "--repo":
state.repo = value
elif option == "--schema":
state.schema = value
elif option == "--config":
state.config_path = value
elif option == "--copy-fields-from":
state.copy_fields_from_project = value
elif option == "--initiative-option":
state.initiative_options.append(value)
def require_project_title(state: OptionState) -> None:
if not state.project_title:
raise ProjectUsageError("The project command requires --project.")
def state_to_args(command: str, state: OptionState, issue_number: int | None = None) -> ProjectArguments:
return ProjectArguments(
area="project",
command=command,
project_title=state.project_title,
owner=state.owner,
repo=state.repo,
schema=state.schema,
config_path=state.config_path,
copy_fields_from_project=state.copy_fields_from_project,
initiative_options=tuple(state.initiative_options or ()),
replace_project=state.replace_project,
allow_cross_repo=state.allow_cross_repo,
dry_run=state.dry_run,
issue_number=issue_number,
field_values=dict(state.field_values or {}),
)
def no_inferred_value() -> str | None:
return None