dirigent-cli 0.9.0__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.
dirigent_cli/params.py ADDED
@@ -0,0 +1,389 @@
1
+ """Building a run's parameters from the command line, against the pipeline's own schema.
2
+
3
+ Precedence is schema defaults, then ``-P`` files in the order given, then ``-p`` flags in
4
+ the order given. Objects deep-merge; arrays and scalars replace, except that a bracketed
5
+ index in a ``-p`` key sets one element of an array in place.
6
+ """
7
+
8
+ import re
9
+ from collections.abc import Mapping, Sequence
10
+ from pathlib import Path
11
+ from typing import Any, Final, cast
12
+
13
+ import yaml
14
+
15
+ from dirigent_common import JsonMap
16
+ from dirigent_core.documents import safe_load
17
+
18
+ TRUE_WORDS: Final = frozenset({"true", "yes", "y", "on", "1"})
19
+ FALSE_WORDS: Final = frozenset({"false", "no", "n", "off", "0"})
20
+
21
+ STRUCTURED: Final = frozenset({"object", "array"})
22
+
23
+ #: What ``[]`` addresses: one past the last element, wherever the array happens to end.
24
+ APPEND: Final = -1
25
+
26
+ #: One dotted segment: a name, then any number of ``[index]`` or ``[]`` brackets.
27
+ SEGMENT: Final = re.compile(r"(?P<name>[^\[\]]*)(?P<indices>(?:\[[^\[\]]*\])*)")
28
+
29
+ INDEX: Final = re.compile(r"\[([^\[\]]*)\]")
30
+
31
+ #: A step of a parsed key: an object key, an array index, or ``APPEND``.
32
+ type Step = str | int
33
+
34
+
35
+ class ParamError(Exception):
36
+ """A parameter could not be read, addressed, or coerced; the message says which."""
37
+
38
+
39
+ def parse_pair(pair: str) -> tuple[str, str]:
40
+ """Split one ``key=value`` argument, refusing anything that is not one."""
41
+ if "=" not in pair:
42
+ raise ParamError(f"-p expects key=value, not {pair!r}")
43
+ key, _, value = pair.partition("=")
44
+ if not key:
45
+ raise ParamError(f"-p expects key=value, not {pair!r}")
46
+ return key, value
47
+
48
+
49
+ def split_key(key: str) -> list[Step]:
50
+ """Split a key into its path of object keys and array indices."""
51
+ if not key:
52
+ raise ParamError(f"-p {key}: a dotted key names each level, so it may not have an empty segment")
53
+ path: list[Step] = []
54
+ for segment in key.split("."):
55
+ match = SEGMENT.fullmatch(segment)
56
+ if match is None:
57
+ raise ParamError(f"-p {key}: {segment!r} is not a name followed by [index] brackets")
58
+ name = match["name"]
59
+ if not name:
60
+ raise ParamError(f"-p {key}: a dotted key names each level, so it may not have an empty segment")
61
+ if name.isdigit():
62
+ raise ParamError(
63
+ f"-p {key}: a dotted path cannot tell the index {name!r} from an object key named {name!r}. "
64
+ f"Address an element with brackets instead ({path[-1] if path else 'name'}[{name}]=...)"
65
+ )
66
+ path.append(name)
67
+ path.extend(_index(raw, key) for raw in INDEX.findall(match["indices"]))
68
+ return path
69
+
70
+
71
+ def _index(raw: str, key: str) -> int:
72
+ """Read what one pair of brackets addresses: an element, or the end of the array."""
73
+ if not raw:
74
+ return APPEND
75
+ if not raw.isdigit():
76
+ if raw.lstrip("-").isdigit():
77
+ raise ParamError(f"-p {key}: an index counts from the start of the array, so {raw!r} is not one")
78
+ raise ParamError(f"-p {key}: an index is a whole number or nothing at all, not {raw!r}")
79
+ return int(raw)
80
+
81
+
82
+ def render_path(path: Sequence[Step]) -> str:
83
+ """Render a parsed path the way it is written on the command line."""
84
+ written = ""
85
+ for step in path:
86
+ if isinstance(step, str):
87
+ written = f"{written}.{step}" if written else step
88
+ else:
89
+ written += "[]" if step == APPEND else f"[{step}]"
90
+ return written
91
+
92
+
93
+ def _article(word: str) -> str:
94
+ """Prefix a schema's word with the article that reads."""
95
+ return f"an {word}" if word[:1] in "aeiou" else f"a {word}"
96
+
97
+
98
+ def _holds(value: Any) -> str:
99
+ """Name what a value already at a path is, for a message about addressing it wrongly."""
100
+ match value:
101
+ case bool():
102
+ return "a boolean"
103
+ case dict():
104
+ return "an object"
105
+ case list():
106
+ return "an array"
107
+ case int() | float():
108
+ return "a number"
109
+ case str():
110
+ return "a string"
111
+ case _:
112
+ return "a scalar"
113
+
114
+
115
+ def read_params_file(path: Path) -> JsonMap:
116
+ """Read a whole parameter payload from a YAML or JSON file."""
117
+ try:
118
+ loaded = safe_load(path.read_text())
119
+ except (OSError, yaml.YAMLError) as error:
120
+ raise ParamError(f"{path} could not be read: {error}") from error
121
+ if loaded is None:
122
+ return {}
123
+ if not isinstance(loaded, dict):
124
+ raise ParamError(f"{path} should hold a mapping of parameters, not {type(loaded).__name__}")
125
+ return cast("JsonMap", loaded)
126
+
127
+
128
+ def _schema_at(schema: JsonMap, path: Sequence[Step], where: str) -> JsonMap | None:
129
+ """Resolve the subschema a path addresses, or say the path is not declared.
130
+
131
+ ``where`` names the channel a value arrived on -- a flag or a file -- so both are refused
132
+ by this one walk rather than by two rules that can drift apart.
133
+ """
134
+ current: JsonMap | None = schema
135
+ walked: list[Step] = []
136
+ for step in path:
137
+ if current is None:
138
+ return None
139
+ declared = _type_of(current)
140
+ if isinstance(step, int):
141
+ if declared is not None and declared != "array":
142
+ raise ParamError(
143
+ f"{where}: the schema calls {render_path(walked)!r} {_article(declared)}, "
144
+ f"so it takes a key, not an index"
145
+ )
146
+ items: object = current.get("items")
147
+ current = cast("JsonMap", items) if isinstance(items, dict) else None
148
+ walked.append(step)
149
+ continue
150
+ if declared == "array":
151
+ raise ParamError(
152
+ f"{where}: the schema calls {render_path(walked)!r} an array, "
153
+ f"so it takes an index, not the key {step!r}"
154
+ )
155
+ raw: object = current.get("properties")
156
+ properties = cast("JsonMap", raw) if isinstance(raw, dict) else None
157
+ found = properties.get(step) if properties is not None else None
158
+ if found is None:
159
+ if _is_open(current, properties):
160
+ return None
161
+ location = render_path([*walked, step])
162
+ known = _known(current)
163
+ raise ParamError(f"{where}: the pipeline declares no parameter {location!r} ({known})")
164
+ current = cast("JsonMap", found)
165
+ walked.append(step)
166
+ return current
167
+
168
+
169
+ def _is_open(schema: JsonMap, properties: JsonMap | None) -> bool:
170
+ """Report whether a schema level accepts a name it does not list.
171
+
172
+ Stricter than JSON Schema, which allows extra properties unless told otherwise: a
173
+ level that lists any property is treated as closed unless it also says
174
+ ``additionalProperties``.
175
+ """
176
+ if not properties:
177
+ return True
178
+ extra = schema.get("additionalProperties")
179
+ return extra is True or isinstance(extra, dict)
180
+
181
+
182
+ def _known(schema: JsonMap) -> str:
183
+ """Render what a schema level does declare, for an error naming an unknown path."""
184
+ properties: object = schema.get("properties")
185
+ if not isinstance(properties, dict):
186
+ return "it declares no parameters"
187
+ names = ", ".join(sorted(cast("Mapping[str, object]", properties)))
188
+ return f"it declares {names}" if names else "it declares no parameters"
189
+
190
+
191
+ def coerce(value: str, schema: JsonMap | None, key: str) -> Any:
192
+ """Turn one command-line string into the value the schema says belongs at that path."""
193
+ declared = _type_of(schema)
194
+ if declared in STRUCTURED:
195
+ return _structured(value, declared, key)
196
+ match declared:
197
+ case "integer":
198
+ return _integer(value, key)
199
+ case "number":
200
+ return _number(value, key)
201
+ case "boolean":
202
+ return _boolean(value, key)
203
+ case "string":
204
+ return _checked_enum(value, schema, key)
205
+ case _:
206
+ parsed: Any = safe_load(value) if value else value
207
+ return _checked_enum(parsed, schema, key)
208
+
209
+
210
+ def _type_of(schema: JsonMap | None) -> str | None:
211
+ """Read the type a subschema declares, tolerating the list form, ``anyOf`` and enum-only.
212
+
213
+ An optional field is spelled ``anyOf: [{type: string}, {type: null}]`` by the generator
214
+ every connection kind's schema comes from, and a branch left unread there falls through to
215
+ YAML, where ``#ops`` is a comment rather than a channel.
216
+ """
217
+ if schema is None:
218
+ return None
219
+ declared = schema.get("type")
220
+ if isinstance(declared, str):
221
+ return declared
222
+ if isinstance(declared, list):
223
+ strings = [item for item in cast("list[object]", declared) if isinstance(item, str) and item != "null"]
224
+ return strings[0] if strings else None
225
+ if isinstance(schema.get("anyOf"), list):
226
+ for branch in cast("list[object]", schema["anyOf"]):
227
+ if not isinstance(branch, dict):
228
+ continue
229
+ found = _type_of(cast("JsonMap", branch))
230
+ if found is not None and found != "null":
231
+ return found
232
+ if isinstance(schema.get("enum"), list):
233
+ members = cast("list[object]", schema["enum"])
234
+ return "string" if all(isinstance(item, str) for item in members) else None
235
+ return None
236
+
237
+
238
+ def _structured(value: str, declared: str, key: str) -> Any:
239
+ """Read an inline object or array, in either JSON or YAML."""
240
+ try:
241
+ parsed = safe_load(value)
242
+ except yaml.YAMLError as error:
243
+ raise ParamError(f"-p {key}: {value!r} is not valid JSON or YAML ({error})") from error
244
+ if declared == "array" and not isinstance(parsed, list):
245
+ raise ParamError(f"-p {key}: this parameter is an array, and {value!r} is not one")
246
+ if declared == "object" and not isinstance(parsed, dict):
247
+ raise ParamError(f"-p {key}: this parameter is an object, and {value!r} is not one")
248
+ return cast("Any", parsed)
249
+
250
+
251
+ def _integer(value: str, key: str) -> int:
252
+ """Read an integer, refusing anything that only looks like one."""
253
+ try:
254
+ return int(value)
255
+ except ValueError as error:
256
+ raise ParamError(f"-p {key}: this parameter is an integer, and {value!r} is not one") from error
257
+
258
+
259
+ def _number(value: str, key: str) -> float:
260
+ """Read a number."""
261
+ try:
262
+ return float(value)
263
+ except ValueError as error:
264
+ raise ParamError(f"-p {key}: this parameter is a number, and {value!r} is not one") from error
265
+
266
+
267
+ def _boolean(value: str, key: str) -> bool:
268
+ """Read a boolean from the words a person actually types."""
269
+ lowered = value.strip().lower()
270
+ if lowered in TRUE_WORDS:
271
+ return True
272
+ if lowered in FALSE_WORDS:
273
+ return False
274
+ raise ParamError(f"-p {key}: this parameter is a boolean; write true or false, not {value!r}")
275
+
276
+
277
+ def _checked_enum(value: Any, schema: JsonMap | None, key: str) -> Any:
278
+ """Refuse a value the schema's enum does not list, naming what it does list."""
279
+ if schema is None:
280
+ return value
281
+ members = schema.get("enum")
282
+ if isinstance(members, list) and value not in members:
283
+ allowed = ", ".join(repr(item) for item in cast("list[object]", members))
284
+ raise ParamError(f"-p {key}: {value!r} is not one of {allowed}")
285
+ return value
286
+
287
+
288
+ def assign(target: JsonMap, path: Sequence[Step], value: Any, key: str) -> None:
289
+ """Set a value at a parsed path, creating the objects and arrays it passes through."""
290
+ container: Any = target
291
+ for position, step in enumerate(path[:-1]):
292
+ container = _descend(
293
+ container, step, path[: position + 1], wants_list=isinstance(path[position + 1], int), key=key
294
+ )
295
+ _read(container, path[-1], path, key)
296
+ _put(container, path[-1], value)
297
+
298
+
299
+ def _descend(container: Any, step: Step, walked: Sequence[Step], *, wants_list: bool, key: str) -> Any:
300
+ """Read one step further in, creating the container there when nothing is yet."""
301
+ existing = _read(container, step, walked, key)
302
+ if existing is None:
303
+ fresh: Any = [] if wants_list else {}
304
+ _put(container, step, fresh)
305
+ return fresh
306
+ if not _fits(existing, wants_list=wants_list):
307
+ wanted = "an array" if wants_list else "an object"
308
+ raise ParamError(f"-p {key}: {render_path(walked)!r} holds {_holds(existing)}, not {wanted}")
309
+ return existing
310
+
311
+
312
+ def _fits(value: Any, *, wants_list: bool) -> bool:
313
+ """Report whether a value already at a path is the container the next step needs."""
314
+ return isinstance(value, dict | list) and isinstance(value, list) == wants_list
315
+
316
+
317
+ def _read(container: Any, step: Step, walked: Sequence[Step], key: str) -> Any:
318
+ """Read what is at one step, refusing a step the container cannot take."""
319
+ parent = render_path(walked[:-1])
320
+ if isinstance(step, str):
321
+ if isinstance(container, list):
322
+ raise ParamError(f"-p {key}: {parent!r} holds an array, so {step!r} is not a key it takes")
323
+ keys: JsonMap = container
324
+ return keys.get(step)
325
+ if isinstance(container, dict):
326
+ raise ParamError(f"-p {key}: {parent!r} holds an object, so [{step}] is not an element it takes")
327
+ elements: list[Any] = container
328
+ if step == APPEND or step == len(elements):
329
+ return None
330
+ if step > len(elements):
331
+ raise ParamError(
332
+ f"-p {key}: {parent!r} holds {_count(len(elements))}, so [{step}] would leave a gap; "
333
+ f"the next element is [{len(elements)}], which [] also writes"
334
+ )
335
+ return elements[step]
336
+
337
+
338
+ def _count(total: int) -> str:
339
+ """Say how many elements an array holds."""
340
+ return "1 element" if total == 1 else f"{total} elements"
341
+
342
+
343
+ def _put(container: Any, step: Step, value: Any) -> None:
344
+ """Write a value at one step, appending when the index is the end of the array."""
345
+ if isinstance(step, str):
346
+ cast("JsonMap", container)[step] = value
347
+ return
348
+ elements = cast("list[Any]", container)
349
+ if step == APPEND or step == len(elements):
350
+ elements.append(value)
351
+ else:
352
+ elements[step] = value
353
+
354
+
355
+ def deep_merge(base: JsonMap, incoming: Mapping[str, Any]) -> JsonMap:
356
+ """Merge one mapping into another: objects merge, everything else replaces."""
357
+ merged = dict(base)
358
+ for key, value in incoming.items():
359
+ existing = merged.get(key)
360
+ if isinstance(existing, dict) and isinstance(value, dict):
361
+ merged[key] = deep_merge(cast("JsonMap", existing), cast("Mapping[str, Any]", value))
362
+ else:
363
+ merged[key] = value
364
+ return merged
365
+
366
+
367
+ def build_params(
368
+ schema: JsonMap,
369
+ *,
370
+ pairs: Sequence[str] = (),
371
+ files: Sequence[Path] = (),
372
+ ) -> JsonMap:
373
+ """Build one parameter object from files and flags, coerced against the pipeline's schema.
374
+
375
+ A name the schema does not declare is refused whichever channel it arrived on, so a typo
376
+ in a file fails here rather than being stored and ignored.
377
+ """
378
+ params: JsonMap = {}
379
+ for path in files:
380
+ payload = read_params_file(path)
381
+ for name in payload:
382
+ _schema_at(schema, [name], f"params file {path}")
383
+ params = deep_merge(params, payload)
384
+ for pair in pairs:
385
+ key, raw = parse_pair(pair)
386
+ parts = split_key(key)
387
+ subschema = _schema_at(schema, parts, f"-p {key}")
388
+ assign(params, parts, coerce(raw, subschema, key), key)
389
+ return params
@@ -0,0 +1,221 @@
1
+ """Profiles: which server the CLI talks to, and how it gets a token for it.
2
+
3
+ A profile is client-side addressing only and must never hold a database URL: a CLI that
4
+ could reach the database would bypass authentication, attribution, and validation.
5
+ """
6
+
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ from collections.abc import Mapping
11
+ from pathlib import Path
12
+ from typing import Any, Final, cast
13
+
14
+ import yaml
15
+ from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator
16
+
17
+ from dirigent_client import API_PREFIX
18
+ from dirigent_common import EntityName
19
+
20
+ PROJECT_PROFILES: Final = Path(".dirigent") / "profiles.yaml"
21
+ USER_PROFILES: Final = Path.home() / ".config" / "dirigent" / "profiles.yaml"
22
+
23
+ URL_ENV: Final = "DG_URL"
24
+ TOKEN_ENV: Final = "DG_TOKEN"
25
+ PROFILE_ENV: Final = "DG_PROFILE"
26
+
27
+ DEFAULT_URL: Final = "http://127.0.0.1:3333"
28
+
29
+ DATABASE_SCHEMES: Final = ("postgresql", "postgres", "sqlite", "mysql")
30
+
31
+ TOKEN_COMMAND_TIMEOUT: Final = 30.0
32
+
33
+
34
+ class ProfileError(Exception):
35
+ """A profile could not be read, found, or turned into a usable token."""
36
+
37
+
38
+ class Profile(BaseModel):
39
+ """One named server and how to obtain a token for it."""
40
+
41
+ model_config = ConfigDict(frozen=True)
42
+
43
+ name: str = "local"
44
+ url: str = DEFAULT_URL
45
+ token: SecretStr | None = None
46
+ """An inline token, which is fine for a local dev server and nowhere else."""
47
+
48
+ token_env: str | None = None
49
+ token_cmd: str | None = None
50
+
51
+ api_prefix: str = API_PREFIX
52
+ """Where this instance serves its API, for one configured with a different prefix."""
53
+
54
+ @model_validator(mode="after")
55
+ def _refuse_a_database_url(self) -> "Profile":
56
+ """Refuse a profile pointing at a database rather than at an API."""
57
+ scheme = self.url.split("://", 1)[0].split("+", 1)[0].lower()
58
+ if scheme in DATABASE_SCHEMES:
59
+ raise ValueError(
60
+ f"profile {self.name!r} names a database URL. Profiles address a server over HTTP; "
61
+ f"a database URL belongs in DIRIGENT_DATABASE_URL on the host that runs it"
62
+ )
63
+ return self
64
+
65
+ def resolve_token(self, environ: Mapping[str, str] | None = None) -> str | None:
66
+ """Obtain the token this profile describes, by whichever of the three mechanisms it uses."""
67
+ if self.token is not None:
68
+ return self.token.get_secret_value()
69
+ if self.token_env:
70
+ value = (environ if environ is not None else os.environ).get(self.token_env)
71
+ if not value:
72
+ raise ProfileError(f"profile {self.name!r} reads its token from ${self.token_env}, which is not set")
73
+ return value
74
+ if self.token_cmd:
75
+ return self._run_token_command()
76
+ return None
77
+
78
+ def _run_token_command(self) -> str:
79
+ """Run the command that prints the token, and refuse anything that is not a token."""
80
+ command = self.token_cmd or ""
81
+ argv = command.split()
82
+ if not argv or shutil.which(argv[0]) is None:
83
+ raise ProfileError(f"profile {self.name!r} runs {command!r} for its token, which is not on PATH")
84
+ try:
85
+ finished = subprocess.run( # noqa: S603 - the command is the operator's own configuration
86
+ argv,
87
+ capture_output=True,
88
+ text=True,
89
+ timeout=TOKEN_COMMAND_TIMEOUT,
90
+ check=False,
91
+ )
92
+ except subprocess.TimeoutExpired as error:
93
+ raise ProfileError(f"profile {self.name!r}: {command!r} did not finish in time") from error
94
+ if finished.returncode != 0:
95
+ detail = finished.stderr.strip() or f"exit code {finished.returncode}"
96
+ raise ProfileError(f"profile {self.name!r}: {command!r} failed ({detail})")
97
+ token = finished.stdout.strip()
98
+ if not token:
99
+ raise ProfileError(f"profile {self.name!r}: {command!r} printed nothing")
100
+ return token
101
+
102
+
103
+ class ProfileStore(BaseModel):
104
+ """A profiles file: the named servers, and which one is used when none is asked for."""
105
+
106
+ model_config = ConfigDict(frozen=True)
107
+
108
+ default: str | None = None
109
+ profiles: dict[EntityName, Profile] = Field(default_factory=dict[str, Profile])
110
+ path: Path | None = None
111
+
112
+ def select(self, name: str | None) -> Profile | None:
113
+ """Choose a profile by name, by the file's default, or by there being only one."""
114
+ wanted = name or self.default
115
+ if wanted is None:
116
+ return next(iter(self.profiles.values())) if len(self.profiles) == 1 else None
117
+ found = self.profiles.get(wanted)
118
+ if found is None:
119
+ known = ", ".join(sorted(self.profiles)) or "this file defines none"
120
+ where = f" in {self.path}" if self.path else ""
121
+ raise ProfileError(f"no profile named {wanted!r}{where} ({known})")
122
+ return found
123
+
124
+
125
+ def candidate_paths(start: Path | None = None) -> list[Path]:
126
+ """List the profiles files that apply here, most specific first."""
127
+ here = (start or Path.cwd()).resolve()
128
+ found = [directory / PROJECT_PROFILES for directory in [here, *here.parents]]
129
+ return [*found, USER_PROFILES]
130
+
131
+
132
+ def load_store(path: Path) -> ProfileStore:
133
+ """Read one profiles file, naming the profile in every error it raises."""
134
+ try:
135
+ loaded: object = yaml.safe_load(path.read_text())
136
+ except (OSError, yaml.YAMLError) as error:
137
+ raise ProfileError(f"{path} could not be read: {error}") from error
138
+ if loaded is None:
139
+ loaded = {}
140
+ if not isinstance(loaded, dict):
141
+ raise ProfileError(f"{path} should hold a mapping with 'default' and 'profiles' keys")
142
+ raw = cast("dict[str, Any]", loaded)
143
+ declared = raw.get("profiles")
144
+ profiles: dict[str, Profile] = {}
145
+ if isinstance(declared, dict):
146
+ for name, body in cast("dict[str, Any]", declared).items():
147
+ if not isinstance(body, dict):
148
+ raise ProfileError(f"{path}: profile {name!r} is not a mapping")
149
+ try:
150
+ profiles[name] = Profile(name=name, **cast("dict[str, Any]", body))
151
+ except ValueError as error:
152
+ raise ProfileError(f"{path}: {error}") from error
153
+ default = raw.get("default")
154
+ return ProfileStore(
155
+ default=default if isinstance(default, str) else None,
156
+ profiles=profiles,
157
+ path=path,
158
+ )
159
+
160
+
161
+ def find_store(start: Path | None = None) -> ProfileStore:
162
+ """Read the first profiles file that exists, or an empty store when none does."""
163
+ for path in candidate_paths(start):
164
+ if path.is_file():
165
+ return load_store(path)
166
+ return ProfileStore()
167
+
168
+
169
+ class Endpoint(BaseModel):
170
+ """The resolved answer to "which server, with what token"."""
171
+
172
+ model_config = ConfigDict(frozen=True)
173
+
174
+ url: str
175
+ token: str | None = None
176
+ profile: str | None = None
177
+ source: str = "default"
178
+ api_prefix: str = API_PREFIX
179
+
180
+
181
+ def resolve_endpoint(
182
+ *,
183
+ url: str | None = None,
184
+ token: str | None = None,
185
+ profile: str | None = None,
186
+ start: Path | None = None,
187
+ environ: dict[str, str] | None = None,
188
+ needs_token: bool = True,
189
+ ) -> Endpoint:
190
+ """Resolve the server and token: flags first, then ``DG_*``, then the selected profile.
191
+
192
+ With ``needs_token`` off, a profile whose token cannot be resolved yields no token rather
193
+ than refusing, which is what the command that mints one asks for.
194
+ """
195
+ env = environ if environ is not None else dict(os.environ)
196
+ store = find_store(start)
197
+ chosen = store.select(profile or env.get(PROFILE_ENV))
198
+
199
+ if url is not None:
200
+ source = "flag"
201
+ elif env.get(URL_ENV):
202
+ source, url = "environment", env[URL_ENV]
203
+ elif chosen is not None:
204
+ source, url = f"profile {chosen.name}", chosen.url
205
+ else:
206
+ source, url = "default", DEFAULT_URL
207
+
208
+ resolved_token = token or env.get(TOKEN_ENV) or None
209
+ if resolved_token is None and chosen is not None:
210
+ try:
211
+ resolved_token = chosen.resolve_token(env)
212
+ except ProfileError:
213
+ if needs_token:
214
+ raise
215
+ return Endpoint(
216
+ url=url.rstrip("/"),
217
+ token=resolved_token,
218
+ profile=chosen.name if chosen else None,
219
+ source=source,
220
+ api_prefix=chosen.api_prefix if chosen else API_PREFIX,
221
+ )