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 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
@@ -0,0 +1,7 @@
1
+ """Run gcw as ``python -m gcw``; used by the background cache refresh."""
2
+
3
+ import sys
4
+
5
+ from gcw.cli.main import main
6
+
7
+ sys.exit(main())
gcw/api/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Google API access: access tokens shared with gcloud and a minimal stdlib REST client.
2
+
3
+ Used only off the completion path (background refresh, live lookups).
4
+
5
+ Must not depend on ``cli`` or ``services``.
6
+ """
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
@@ -0,0 +1,6 @@
1
+ """Local resource cache: per account, project and resource type, refreshed in the background.
2
+
3
+ Completion reads only from here and never waits for the network.
4
+
5
+ Must not depend on ``cli`` or ``services``.
6
+ """
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
@@ -0,0 +1,4 @@
1
+ """User-facing layer: argument parsing, command dispatch, TAB completion and shell integration.
2
+
3
+ May depend on every other layer. Nothing else may import from here.
4
+ """