gcw 0.1.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.
- gcw/__init__.py +12 -0
- gcw/__main__.py +7 -0
- gcw/api/__init__.py +6 -0
- gcw/api/auth.py +62 -0
- gcw/api/client.py +189 -0
- gcw/cache/__init__.py +6 -0
- gcw/cache/refresh.py +104 -0
- gcw/cache/store.py +134 -0
- gcw/cli/__init__.py +4 -0
- gcw/cli/complete.py +209 -0
- gcw/cli/gcloud_syntax.py +153 -0
- gcw/cli/lookup.py +121 -0
- gcw/cli/main.py +591 -0
- gcw/cli/pager.py +94 -0
- gcw/cli/resources.py +107 -0
- gcw/cli/shell/gcw.bash +85 -0
- gcw/cli/shell/gcw.fish +7 -0
- gcw/cli/shell/gcw.zsh +25 -0
- gcw/cli/shell/web-flag.fish +14 -0
- gcw/cli/shell/web-flag.sh +17 -0
- gcw/context/__init__.py +7 -0
- gcw/context/gcloud_config.py +178 -0
- gcw/context/resolve.py +43 -0
- gcw/services/__init__.py +70 -0
- gcw/services/artifacts.py +51 -0
- gcw/services/composer.py +57 -0
- gcw/services/compute.py +48 -0
- gcw/services/container.py +42 -0
- gcw/services/dataproc.py +84 -0
- gcw/services/functions.py +39 -0
- gcw/services/iam.py +36 -0
- gcw/services/landing.py +99 -0
- gcw/services/run.py +60 -0
- gcw/services/secrets.py +39 -0
- gcw/services/sql.py +38 -0
- gcw/services/storage.py +40 -0
- gcw/web/__init__.py +4 -0
- gcw/web/browser.py +57 -0
- gcw/web/route.py +124 -0
- gcw/web/url.py +35 -0
- gcw-0.1.0.dist-info/METADATA +206 -0
- gcw-0.1.0.dist-info/RECORD +45 -0
- gcw-0.1.0.dist-info/WHEEL +4 -0
- gcw-0.1.0.dist-info/entry_points.txt +2 -0
- gcw-0.1.0.dist-info/licenses/LICENSE +202 -0
gcw/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""gcw ("gcloud web"): open Google Cloud console pages from the terminal.
|
|
2
|
+
|
|
3
|
+
Package layout; dependencies point downwards only (``cli`` -> ``services`` ->
|
|
4
|
+
``api`` / ``cache`` / ``context`` / ``web``):
|
|
5
|
+
|
|
6
|
+
cli user-facing entry point: argument parsing, TAB completion, shell integration
|
|
7
|
+
services one module per GCP service: console routes and resource listing
|
|
8
|
+
context active account, project and region resolved from the gcloud configuration
|
|
9
|
+
cache local resource cache that powers completion
|
|
10
|
+
api access tokens and a minimal REST client for Google APIs
|
|
11
|
+
web console URL building and opening the browser
|
|
12
|
+
"""
|
gcw/__main__.py
ADDED
gcw/api/__init__.py
ADDED
gcw/api/auth.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Access tokens for Google APIs, taken from the gcloud CLI.
|
|
2
|
+
|
|
3
|
+
The token comes from the official ``gcloud auth print-access-token`` command
|
|
4
|
+
(ADR-0016), so gcw never reads gcloud's credential store. The command takes
|
|
5
|
+
about a second, which is acceptable because tokens are needed only off the
|
|
6
|
+
completion path: background cache refresh and live lookups.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
|
|
12
|
+
_TIMEOUT_SECONDS = 60
|
|
13
|
+
|
|
14
|
+
# One token per account for the lifetime of the process. A refresh issues a
|
|
15
|
+
# dozen requests within seconds, far below the token's one-hour lifetime.
|
|
16
|
+
_tokens: dict[str | None, str] = {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthError(RuntimeError):
|
|
20
|
+
"""No access token is available; the message tells the user how to fix it."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def access_token(account: str | None = None) -> str:
|
|
24
|
+
"""Return an OAuth access token for ``account``, as gcloud would use it.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
account: Google account email (``core/account`` of the gcloud
|
|
28
|
+
configuration in use); ``None`` lets gcloud pick its active account.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The access token.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
AuthError: gcloud is not installed, has no credentials for the account,
|
|
35
|
+
or the command failed or timed out.
|
|
36
|
+
"""
|
|
37
|
+
if account not in _tokens:
|
|
38
|
+
_tokens[account] = _token_from_gcloud(account)
|
|
39
|
+
return _tokens[account]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _token_from_gcloud(account: str | None) -> str:
|
|
43
|
+
"""Run ``gcloud auth print-access-token`` and return its output."""
|
|
44
|
+
# which() also finds gcloud.cmd on Windows, which a bare "gcloud" would not.
|
|
45
|
+
gcloud = shutil.which("gcloud")
|
|
46
|
+
if gcloud is None:
|
|
47
|
+
raise AuthError("gcloud not found on PATH; install the Google Cloud CLI and run `gcloud auth login`")
|
|
48
|
+
|
|
49
|
+
command = [gcloud, "auth", "print-access-token", "--verbosity=error"]
|
|
50
|
+
if account:
|
|
51
|
+
command += ["--account", account]
|
|
52
|
+
try:
|
|
53
|
+
result = subprocess.run(command, capture_output=True, text=True, timeout=_TIMEOUT_SECONDS)
|
|
54
|
+
except subprocess.TimeoutExpired:
|
|
55
|
+
raise AuthError(f"`gcloud auth print-access-token` timed out after {_TIMEOUT_SECONDS} s") from None
|
|
56
|
+
|
|
57
|
+
token = result.stdout.strip()
|
|
58
|
+
if result.returncode != 0 or not token:
|
|
59
|
+
reason = result.stderr.strip().splitlines()[-1] if result.stderr.strip() else "no token returned"
|
|
60
|
+
who = f" for {account}" if account else ""
|
|
61
|
+
raise AuthError(f"cannot get an access token{who} ({reason}); run `gcloud auth login`")
|
|
62
|
+
return token
|
gcw/api/client.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Minimal REST client for Google APIs on the standard library.
|
|
2
|
+
|
|
3
|
+
Only what resource listing needs (ADR-0008): authenticated GET requests that
|
|
4
|
+
return JSON, pagination over ``nextPageToken``, and running independent
|
|
5
|
+
requests in parallel threads. Imported lazily by ``list_*`` functions, never
|
|
6
|
+
on the completion path, because ``urllib.request`` pulls in ``ssl``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from collections.abc import Callable, Iterator
|
|
11
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
|
+
from typing import Any, TypeVar
|
|
13
|
+
from urllib.error import HTTPError, URLError
|
|
14
|
+
from urllib.parse import urlencode
|
|
15
|
+
from urllib.request import Request, urlopen
|
|
16
|
+
|
|
17
|
+
_TIMEOUT_SECONDS = 30
|
|
18
|
+
_MAX_WORKERS = 8
|
|
19
|
+
|
|
20
|
+
K = TypeVar("K")
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApiError(RuntimeError):
|
|
25
|
+
"""A Google API request failed.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
status: HTTP status code, or ``None`` when no response was received
|
|
29
|
+
(network error, timeout).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str, status: int | None = None):
|
|
33
|
+
"""Store the status code next to the message."""
|
|
34
|
+
super().__init__(message)
|
|
35
|
+
self.status = status
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_json(url: str, token: str, params: dict[str, str] | None = None) -> dict[str, Any]:
|
|
39
|
+
"""Send an authenticated GET request and return the decoded JSON body.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
url: Full API URL, e.g. ``"https://run.googleapis.com/v2/projects/p/locations/-/services"``.
|
|
43
|
+
token: OAuth access token (see ``gcw.api.auth``).
|
|
44
|
+
params: Query parameters to append.
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
The response body; an empty dict for an empty body.
|
|
48
|
+
|
|
49
|
+
Raises:
|
|
50
|
+
ApiError: The server answered with an error status, or no answer arrived.
|
|
51
|
+
"""
|
|
52
|
+
if params:
|
|
53
|
+
url = f"{url}?{urlencode(params)}"
|
|
54
|
+
request = Request(url, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"})
|
|
55
|
+
try:
|
|
56
|
+
with urlopen(request, timeout=_TIMEOUT_SECONDS) as response:
|
|
57
|
+
body = response.read()
|
|
58
|
+
except HTTPError as error:
|
|
59
|
+
raise ApiError(_error_message(error), status=error.code) from None
|
|
60
|
+
except (URLError, TimeoutError) as error:
|
|
61
|
+
raise ApiError(f"request to {url} failed: {getattr(error, 'reason', error)}") from None
|
|
62
|
+
return json.loads(body) if body else {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def iter_pages(url: str, token: str, params: dict[str, str] | None = None) -> Iterator[dict[str, Any]]:
|
|
66
|
+
"""Yield every page of a paginated endpoint, following ``nextPageToken``.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
url: Full URL of the endpoint.
|
|
70
|
+
token: OAuth access token.
|
|
71
|
+
params: Extra query parameters sent with every page.
|
|
72
|
+
|
|
73
|
+
Yields:
|
|
74
|
+
Each decoded page, in order.
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ApiError: Any page request failed.
|
|
78
|
+
"""
|
|
79
|
+
page_params = dict(params or {})
|
|
80
|
+
while True:
|
|
81
|
+
page = get_json(url, token, page_params)
|
|
82
|
+
yield page
|
|
83
|
+
next_token = page.get("nextPageToken")
|
|
84
|
+
if not next_token:
|
|
85
|
+
return
|
|
86
|
+
page_params["pageToken"] = next_token
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def list_all(url: str, token: str, items_key: str, params: dict[str, str] | None = None) -> list[dict[str, Any]]:
|
|
90
|
+
"""Fetch every page of a ``list`` endpoint and return all items.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
url: Full URL of the list endpoint.
|
|
94
|
+
token: OAuth access token.
|
|
95
|
+
items_key: Field holding the items in each page, e.g. ``"services"``.
|
|
96
|
+
params: Extra query parameters sent with every page.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Items from all pages, in order.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
ApiError: Any page request failed.
|
|
103
|
+
"""
|
|
104
|
+
return [item for page in iter_pages(url, token, params) for item in page.get(items_key, [])]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def list_per_location(
|
|
108
|
+
locations_url: str, list_url: str, token: str, items_key: str
|
|
109
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
110
|
+
"""List a collection in every location, for APIs that cannot aggregate with ``locations/-``.
|
|
111
|
+
|
|
112
|
+
First fetches the locations, then lists each one in parallel. A location
|
|
113
|
+
that fails is skipped; if every location fails, the first error is raised.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
locations_url: Endpoint listing the locations; items have ``locationId``.
|
|
117
|
+
list_url: Endpoint listing the collection, with a ``{location}`` placeholder.
|
|
118
|
+
token: OAuth access token.
|
|
119
|
+
items_key: Field holding the items in each page of ``list_url``.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
``(location, item)`` pairs for every item found.
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ApiError: The locations could not be listed, or no location answered.
|
|
126
|
+
"""
|
|
127
|
+
locations = [item["locationId"] for item in list_all(locations_url, token, "locations")]
|
|
128
|
+
results = run_parallel({
|
|
129
|
+
location: (lambda location=location: list_all(list_url.format(location=location), token, items_key))
|
|
130
|
+
for location in locations
|
|
131
|
+
})
|
|
132
|
+
errors = [result for result in results.values() if isinstance(result, Exception)]
|
|
133
|
+
if errors and len(errors) == len(results):
|
|
134
|
+
raise errors[0]
|
|
135
|
+
return [
|
|
136
|
+
(location, item)
|
|
137
|
+
for location, items in results.items()
|
|
138
|
+
if not isinstance(items, Exception)
|
|
139
|
+
for item in items
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def path_segment(resource_name: str, collection: str) -> str | None:
|
|
144
|
+
"""Return the value that follows ``collection`` in a resource name.
|
|
145
|
+
|
|
146
|
+
Example: ``path_segment("projects/p/locations/eu1/services/api", "locations")``
|
|
147
|
+
returns ``"eu1"``. Also works on self links such as
|
|
148
|
+
``"https://.../zones/eu1-b"``.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
The segment, or ``None`` if ``collection`` is not in the name.
|
|
152
|
+
"""
|
|
153
|
+
parts = resource_name.split("/")
|
|
154
|
+
for index, part in enumerate(parts[:-1]):
|
|
155
|
+
if part == collection:
|
|
156
|
+
return parts[index + 1]
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def run_parallel(tasks: dict[K, Callable[[], T]]) -> dict[K, T | Exception]:
|
|
161
|
+
"""Run independent tasks in threads and collect each result or exception.
|
|
162
|
+
|
|
163
|
+
A failing task does not stop the others: its exception is returned in
|
|
164
|
+
place of a result, so the caller can report it and keep the rest.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
tasks: Callables keyed by a name the caller chooses.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
For each key, the task's return value or the exception it raised.
|
|
171
|
+
"""
|
|
172
|
+
if not tasks:
|
|
173
|
+
return {}
|
|
174
|
+
with ThreadPoolExecutor(max_workers=min(_MAX_WORKERS, len(tasks))) as pool:
|
|
175
|
+
futures = {key: pool.submit(task) for key, task in tasks.items()}
|
|
176
|
+
results: dict[K, T | Exception] = {}
|
|
177
|
+
for key, future in futures.items():
|
|
178
|
+
error = future.exception()
|
|
179
|
+
results[key] = error if error is not None else future.result()
|
|
180
|
+
return results
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _error_message(error: HTTPError) -> str:
|
|
184
|
+
"""Return the message from a Google API error body, or the HTTP reason."""
|
|
185
|
+
try:
|
|
186
|
+
detail = json.loads(error.read())["error"]["message"]
|
|
187
|
+
except (ValueError, KeyError, TypeError):
|
|
188
|
+
detail = error.reason
|
|
189
|
+
return f"HTTP {error.code}: {detail}"
|
gcw/cache/__init__.py
ADDED
gcw/cache/refresh.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Refreshing the resource cache: fetching in parallel, and doing it in the background.
|
|
2
|
+
|
|
3
|
+
``refresh`` fetches resources with the listing functions it is given (the
|
|
4
|
+
cache layer does not import ``services``; the CLI passes them in) and writes
|
|
5
|
+
every successful result. ``spawn_background`` runs ``gcw cache refresh`` as a
|
|
6
|
+
detached process, so a stale cache is refreshed without the user waiting
|
|
7
|
+
(ADR-0006). Never used on the completion path, which must not spawn processes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from gcw.cache import store
|
|
18
|
+
from gcw.context.resolve import Context
|
|
19
|
+
|
|
20
|
+
# A background refresh normally takes seconds; a lock older than this belongs
|
|
21
|
+
# to a process that died, so a new refresh may start.
|
|
22
|
+
LOCK_SECONDS = 5 * 60
|
|
23
|
+
|
|
24
|
+
Lister = Callable[[Context, str], list[store.Resource]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def refresh(context: Context, listers: dict[tuple[str, str], Lister]) -> dict[tuple[str, str], int | Exception]:
|
|
28
|
+
"""Fetch resources of the given kinds in parallel and cache each successful result.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
context: Account and project to refresh; ``context.project`` must be set.
|
|
32
|
+
listers: Listing functions keyed by ``(service, kind)``.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
For each key, the number of cached resources, or the exception that
|
|
36
|
+
prevented fetching that kind (e.g. an API disabled in the project).
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
AuthError: No access token is available, so nothing can be fetched.
|
|
40
|
+
"""
|
|
41
|
+
from gcw.api.auth import access_token
|
|
42
|
+
from gcw.api.client import run_parallel
|
|
43
|
+
|
|
44
|
+
token = access_token(context.account)
|
|
45
|
+
fetched = run_parallel({key: (lambda lister=lister: lister(context, token)) for key, lister in listers.items()})
|
|
46
|
+
|
|
47
|
+
results: dict[tuple[str, str], int | Exception] = {}
|
|
48
|
+
for (service, kind), resources in fetched.items():
|
|
49
|
+
if isinstance(resources, Exception):
|
|
50
|
+
results[(service, kind)] = resources
|
|
51
|
+
else:
|
|
52
|
+
store.write(context.account, context.project, service, kind, resources)
|
|
53
|
+
results[(service, kind)] = len(resources)
|
|
54
|
+
return results
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def lock_path(account: str | None, project: str) -> Path:
|
|
58
|
+
"""Return the lock file that marks a background refresh of ``project`` in progress."""
|
|
59
|
+
return store.cache_dir() / (account or store.NO_ACCOUNT) / project / ".refresh.lock"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def release_lock(account: str | None, project: str) -> None:
|
|
63
|
+
"""Remove the background refresh lock; called by the refresh process when it ends."""
|
|
64
|
+
lock_path(account, project).unlink(missing_ok=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def spawn_background(context: Context, service: str | None = None, kind: str | None = None) -> bool:
|
|
68
|
+
"""Start ``gcw cache refresh --background`` as a detached process, unless one is running.
|
|
69
|
+
|
|
70
|
+
The child gets the same gcloud configuration and project, has no terminal
|
|
71
|
+
and outlives this process. It removes the lock when it finishes.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
context: Account, project and configuration to refresh.
|
|
75
|
+
service: Refresh only this service; ``None`` refreshes everything.
|
|
76
|
+
kind: Refresh only this kind of ``service``.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
``True`` if a process was started, ``False`` if a recent one holds the lock
|
|
80
|
+
or the process could not be started.
|
|
81
|
+
"""
|
|
82
|
+
lock = lock_path(context.account, context.project)
|
|
83
|
+
try:
|
|
84
|
+
if time.time() - lock.stat().st_mtime < LOCK_SECONDS:
|
|
85
|
+
return False
|
|
86
|
+
except OSError:
|
|
87
|
+
pass # no lock: nothing is running
|
|
88
|
+
lock.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
lock.touch()
|
|
90
|
+
|
|
91
|
+
command = [sys.executable, "-m", "gcw", "cache", "refresh", "--background",
|
|
92
|
+
"-c", context.config.name, "-p", context.project]
|
|
93
|
+
command += [part for part in (service, kind) if part]
|
|
94
|
+
options: dict = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
|
|
95
|
+
if os.name == "nt":
|
|
96
|
+
options["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
|
|
97
|
+
else:
|
|
98
|
+
options["start_new_session"] = True
|
|
99
|
+
try:
|
|
100
|
+
subprocess.Popen(command, **options)
|
|
101
|
+
except OSError:
|
|
102
|
+
lock.unlink(missing_ok=True)
|
|
103
|
+
return False
|
|
104
|
+
return True
|
gcw/cache/store.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Resource cache entries and their storage on disk.
|
|
2
|
+
|
|
3
|
+
A ``Resource`` is one cached resource: the flat ``{name, location, extra}``
|
|
4
|
+
record of ADR-0006. Service modules produce them in their ``list_*`` functions;
|
|
5
|
+
completion and the CLI read them back.
|
|
6
|
+
|
|
7
|
+
Layout: one JSON file per account, project and resource kind::
|
|
8
|
+
|
|
9
|
+
<cache dir>/<account>/<project>/<service>.<kind>.json
|
|
10
|
+
{"fetched_at": <unix time>, "resources": [{"name": ..., "location": ..., "extra": {...}}]}
|
|
11
|
+
|
|
12
|
+
Entries never expire hard. After ``SOFT_TTL_SECONDS`` an entry is stale: it is
|
|
13
|
+
still used, and the caller starts a background refresh. Writes are atomic, so a
|
|
14
|
+
reader never sees a half-written file. This module is on the completion path,
|
|
15
|
+
so it imports nothing heavy.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
SOFT_TTL_SECONDS = 24 * 60 * 60
|
|
26
|
+
|
|
27
|
+
# Directory name used when the gcloud configuration has no account.
|
|
28
|
+
NO_ACCOUNT = "_"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Resource:
|
|
33
|
+
"""One resource as stored in the cache.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
name: Name the user types, e.g. a Cloud Run service name or a bucket.
|
|
37
|
+
location: Region or zone, or ``None`` for global resources.
|
|
38
|
+
extra: Service-specific details used to build URLs, e.g. a Composer
|
|
39
|
+
environment's ``airflow_uri`` or a repository's ``format``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
location: str | None = None
|
|
44
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def to_json(self) -> dict[str, Any]:
|
|
47
|
+
"""Return the resource as a JSON-serialisable dict."""
|
|
48
|
+
return {"name": self.name, "location": self.location, "extra": self.extra}
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_json(cls, data: dict[str, Any]) -> "Resource":
|
|
52
|
+
"""Build a resource from a dict written by ``to_json``."""
|
|
53
|
+
return cls(data["name"], data.get("location"), data.get("extra") or {})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class CacheEntry:
|
|
58
|
+
"""The cached resources of one kind, with the time they were fetched.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
resources: The resources, in the order the API returned them.
|
|
62
|
+
fetched_at: Unix time of the fetch.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
resources: list[Resource]
|
|
66
|
+
fetched_at: float
|
|
67
|
+
|
|
68
|
+
def is_stale(self, now: float | None = None) -> bool:
|
|
69
|
+
"""Return whether the entry is older than the soft TTL and should be refreshed."""
|
|
70
|
+
return (now if now is not None else time.time()) - self.fetched_at > SOFT_TTL_SECONDS
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cache_dir() -> Path:
|
|
74
|
+
"""Return the root cache directory.
|
|
75
|
+
|
|
76
|
+
``$GCW_CACHE_DIR`` wins; otherwise ``%LOCALAPPDATA%\\gcw`` on Windows and
|
|
77
|
+
``$XDG_CACHE_HOME/gcw`` (default ``~/.cache/gcw``) elsewhere.
|
|
78
|
+
"""
|
|
79
|
+
override = os.environ.get("GCW_CACHE_DIR")
|
|
80
|
+
if override:
|
|
81
|
+
return Path(override)
|
|
82
|
+
if os.name == "nt" and os.environ.get("LOCALAPPDATA"):
|
|
83
|
+
return Path(os.environ["LOCALAPPDATA"]) / "gcw"
|
|
84
|
+
return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "gcw"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def entry_path(account: str | None, project: str, service: str, kind: str) -> Path:
|
|
88
|
+
"""Return the file that holds the resources of one kind."""
|
|
89
|
+
return cache_dir() / (account or NO_ACCOUNT) / project / f"{service}.{kind}.json"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read(account: str | None, project: str, service: str, kind: str) -> CacheEntry | None:
|
|
93
|
+
"""Read the cached resources of one kind.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
The entry, or ``None`` if nothing is cached or the file is unreadable
|
|
97
|
+
(a corrupt file is treated as missing and gets rewritten on refresh).
|
|
98
|
+
"""
|
|
99
|
+
try:
|
|
100
|
+
data = json.loads(entry_path(account, project, service, kind).read_text(encoding="utf-8"))
|
|
101
|
+
return CacheEntry([Resource.from_json(item) for item in data["resources"]], float(data["fetched_at"]))
|
|
102
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def write(account: str | None, project: str, service: str, kind: str, resources: list[Resource]) -> None:
|
|
107
|
+
"""Replace the cached resources of one kind, atomically.
|
|
108
|
+
|
|
109
|
+
The data goes to a temporary file in the same directory, which then
|
|
110
|
+
replaces the entry in one step, so concurrent readers see either the old
|
|
111
|
+
or the new list.
|
|
112
|
+
"""
|
|
113
|
+
# Imported here: tempfile is slow to import and completion only reads.
|
|
114
|
+
import tempfile
|
|
115
|
+
|
|
116
|
+
path = entry_path(account, project, service, kind)
|
|
117
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
body = json.dumps({"fetched_at": time.time(), "resources": [r.to_json() for r in resources]})
|
|
119
|
+
descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
|
|
120
|
+
try:
|
|
121
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as file:
|
|
122
|
+
file.write(body)
|
|
123
|
+
os.replace(temporary, path)
|
|
124
|
+
except BaseException:
|
|
125
|
+
Path(temporary).unlink(missing_ok=True)
|
|
126
|
+
raise
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cached_projects(account: str | None) -> list[str]:
|
|
130
|
+
"""Return the projects that have any cached resources for ``account``, sorted."""
|
|
131
|
+
try:
|
|
132
|
+
return sorted(entry.name for entry in (cache_dir() / (account or NO_ACCOUNT)).iterdir() if entry.is_dir())
|
|
133
|
+
except OSError:
|
|
134
|
+
return []
|
gcw/cli/__init__.py
ADDED