gcpath 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.
gcpath/__init__.py ADDED
File without changes
gcpath/cli.py ADDED
@@ -0,0 +1,298 @@
1
+ import typer
2
+ import logging
3
+ from typing import Optional, List, Dict
4
+ from typing_extensions import Annotated
5
+ from rich.console import Console
6
+ from rich import print as rprint
7
+ from google.api_core import exceptions as gcp_exceptions
8
+
9
+ from gcpath.core import Hierarchy, path_escape, Project, GCPathError
10
+
11
+ app = typer.Typer(
12
+ name="gcpath",
13
+ help="Google Cloud Platform resource hierarchy utility",
14
+ add_completion=False,
15
+ )
16
+ console = Console()
17
+ error_console = Console(stderr=True)
18
+
19
+
20
+ def handle_error(e: Exception) -> None:
21
+ """Central error handler for CLI."""
22
+ if isinstance(e, GCPathError):
23
+ error_console.print(f"[red]Error:[/red] {e}")
24
+ elif isinstance(e, gcp_exceptions.PermissionDenied):
25
+ error_console.print(
26
+ "[red]Permission Denied:[/red] Ensure you have the required permissions and are authenticated."
27
+ )
28
+ error_console.print("[dim]Hint: Run 'gcloud auth application-default login'[/dim]")
29
+ elif isinstance(e, gcp_exceptions.ServiceUnavailable):
30
+ error_console.print("[red]Service Unavailable:[/red] The GCP API is currently unreachable.")
31
+ elif isinstance(e, Exception):
32
+ error_console.print(f"[red]Unexpected Error:[/red] {e}")
33
+ logging.exception("Unexpected error occurred")
34
+ raise typer.Exit(code=1)
35
+
36
+
37
+ @app.callback()
38
+ def main(
39
+ ctx: typer.Context,
40
+ use_asset_api: bool = typer.Option(
41
+ True,
42
+ "--use-asset-api/--no-use-asset-api",
43
+ "-u/-U",
44
+ help="Use Cloud Asset API to load folders (faster) or Resource Manager (slower)",
45
+ ),
46
+ debug: bool = typer.Option(False, "--debug", help="Enable debug logging"),
47
+ ) -> None:
48
+ """
49
+ gcpath - Google Cloud Platform resource hierarchy utility
50
+ """
51
+ ctx.ensure_object(dict)
52
+ ctx.obj["use_asset_api"] = use_asset_api
53
+
54
+ if debug:
55
+ logging.basicConfig(level=logging.DEBUG)
56
+ else:
57
+ logging.basicConfig(level=logging.ERROR)
58
+
59
+
60
+ @app.command()
61
+ def ls(
62
+ ctx: typer.Context,
63
+ organizations: Annotated[
64
+ Optional[List[str]],
65
+ typer.Argument(help="Organization display names to filter (optional)"),
66
+ ] = None,
67
+ long: bool = typer.Option(
68
+ False, "--long", "-l", help="Show resource names along with paths"
69
+ ),
70
+ recursive: bool = typer.Option(
71
+ True, "--recursive/--no-recursive", "-R/-r", help="List resources recursively"
72
+ ),
73
+ ) -> None:
74
+ """
75
+ List all folders and projects in your organizations.
76
+ """
77
+ try:
78
+ hierarchy = Hierarchy.load(
79
+ display_names=organizations,
80
+ via_resource_manager=not ctx.obj["use_asset_api"]
81
+ )
82
+
83
+ if not hierarchy.organizations and not hierarchy.projects:
84
+ # Check if it looks like a personal account
85
+ import google.auth
86
+
87
+ account_msg = ""
88
+ try:
89
+ credentials, _ = google.auth.default()
90
+ if hasattr(credentials, "account") and credentials.account:
91
+ if credentials.account.endswith("@gmail.com"):
92
+ account_msg = f" (Account: {credentials.account})"
93
+ except Exception:
94
+ pass
95
+
96
+ rprint(
97
+ f"[yellow]No organizations or projects found accessible to your account{account_msg}.[/yellow]"
98
+ )
99
+ if not account_msg:
100
+ rprint(
101
+ "[dim]Hint: You might not have access to any organizations. Projects without organizations are shown with //_ prefix.[/dim]"
102
+ )
103
+ return
104
+
105
+ items = []
106
+ for org in hierarchy.organizations:
107
+ if recursive:
108
+ # Add org itself
109
+ items.append(
110
+ (
111
+ f"//{path_escape(org.organization.display_name)}",
112
+ org.organization.name,
113
+ )
114
+ )
115
+ for folder in org.folders.values():
116
+ items.append((folder.path, folder.name))
117
+ else:
118
+ # Only top level
119
+ items.append(
120
+ (
121
+ f"//{path_escape(org.organization.display_name)}",
122
+ org.organization.name,
123
+ )
124
+ )
125
+
126
+ for proj in hierarchy.projects:
127
+ if recursive or not proj.folder:
128
+ items.append((proj.path, proj.name))
129
+
130
+ # Sort items for consistent output
131
+ items.sort(key=lambda x: x[0])
132
+
133
+ for path, res_name in items:
134
+ if long:
135
+ print(f"{path:<60} {res_name}")
136
+ else:
137
+ print(path)
138
+
139
+ except Exception as e:
140
+ handle_error(e)
141
+
142
+
143
+ @app.command()
144
+ def tree(
145
+ ctx: typer.Context,
146
+ organizations: Annotated[
147
+ Optional[List[str]],
148
+ typer.Argument(help="Organization display names to filter (optional)"),
149
+ ] = None,
150
+ level: int = typer.Option(
151
+ None, "--level", "-L", help="Max display depth of the tree"
152
+ ),
153
+ show_ids: bool = typer.Option(
154
+ False, "--ids", "-i", help="Show resource names in the tree"
155
+ ),
156
+ ) -> None:
157
+ """
158
+ Display the resource hierarchy in a tree format.
159
+ """
160
+ from rich.tree import Tree
161
+
162
+ try:
163
+ hierarchy = Hierarchy.load(
164
+ display_names=organizations,
165
+ via_resource_manager=not ctx.obj["use_asset_api"]
166
+ )
167
+
168
+ root_tree = Tree("[bold cyan]GCP Hierarchy[/bold cyan]")
169
+
170
+ # Group projects by parent
171
+ projects_by_parent: Dict[str, List[Project]] = {}
172
+ for proj in hierarchy.projects:
173
+ projects_by_parent.setdefault(proj.parent, []).append(proj)
174
+
175
+ def add_folders(tree_node, org_node, parent_name, current_depth):
176
+ if level is not None and current_depth > level:
177
+ return
178
+
179
+ subfolders = []
180
+ for f in org_node.folders.values():
181
+ if len(f.ancestors) == 1 and parent_name == org_node.organization.name:
182
+ subfolders.append(f)
183
+ elif len(f.ancestors) > 1 and f.ancestors[1] == parent_name:
184
+ subfolders.append(f)
185
+
186
+ subfolders.sort(key=lambda x: x.display_name)
187
+
188
+ for f in subfolders:
189
+ label = f"[bold blue]{f.display_name}[/bold blue]"
190
+ if show_ids:
191
+ label += f" [dim]({f.name})[/dim]"
192
+ sub_node = tree_node.add(label)
193
+
194
+ # Add projects in this folder
195
+ add_projects(sub_node, f.name, current_depth + 1)
196
+
197
+ # Recurse
198
+ add_folders(sub_node, org_node, f.name, current_depth + 1)
199
+
200
+ def add_projects(tree_node, parent_name, current_depth):
201
+ if level is not None and current_depth > level:
202
+ return
203
+ projs = projects_by_parent.get(parent_name, [])
204
+ projs.sort(key=lambda x: x.display_name)
205
+ for p in projs:
206
+ label = f"[green]{p.display_name}[/green]"
207
+ if show_ids:
208
+ label += f" [dim]({p.name})[/dim]"
209
+ tree_node.add(label)
210
+
211
+ # Add Organizations
212
+ for org in hierarchy.organizations:
213
+ label = f"[bold magenta]{org.organization.display_name}[/bold magenta]"
214
+ if show_ids:
215
+ label += f" [dim]({org.organization.name})[/dim]"
216
+ org_tree = root_tree.add(label)
217
+
218
+ add_projects(org_tree, org.organization.name, 1)
219
+ add_folders(org_tree, org, org.organization.name, 1)
220
+
221
+ # Add Organizationless projects
222
+ if any(not p.organization for p in hierarchy.projects):
223
+ orgless_node = root_tree.add(
224
+ "[bold yellow](organizationless)[/bold yellow]"
225
+ )
226
+ for p in hierarchy.projects:
227
+ if not p.organization:
228
+ label = f"[green]{p.display_name}[/green]"
229
+ if show_ids:
230
+ label += f" [dim]({p.name})[/dim]"
231
+ orgless_node.add(label)
232
+
233
+ console.print(root_tree)
234
+
235
+ except Exception as e:
236
+ handle_error(e)
237
+
238
+
239
+ @app.command(name="name")
240
+ def get_resource_name(
241
+ ctx: typer.Context,
242
+ paths: Annotated[
243
+ List[str], typer.Argument(help="Paths to resolve, e.g. //example.com/folder")
244
+ ],
245
+ id_only: bool = typer.Option(
246
+ False, "--id", help="Print only the resource ID number"
247
+ ),
248
+ ) -> None:
249
+ """
250
+ Get Google Cloud Platform resource name by path.
251
+ """
252
+ try:
253
+ hierarchy = Hierarchy.load(
254
+ display_names=None,
255
+ via_resource_manager=not ctx.obj["use_asset_api"]
256
+ )
257
+
258
+ for path in paths:
259
+ res_name = hierarchy.get_resource_name(path)
260
+ if id_only:
261
+ parts = res_name.split("/")
262
+ res_name = parts[-1]
263
+ print(res_name)
264
+
265
+ except Exception as e:
266
+ handle_error(e)
267
+
268
+
269
+ @app.command(name="path")
270
+ def get_path_command(
271
+ ctx: typer.Context,
272
+ resource_names: Annotated[
273
+ List[str], typer.Argument(help="Resource names to resolve, e.g. folders/123")
274
+ ],
275
+ ) -> None:
276
+ """
277
+ Get path of a resource name.
278
+ """
279
+ try:
280
+ hierarchy = Hierarchy.load(
281
+ display_names=None,
282
+ via_resource_manager=not ctx.obj["use_asset_api"]
283
+ )
284
+
285
+ for name in resource_names:
286
+ p = hierarchy.get_path_by_resource_name(name)
287
+ print(p)
288
+
289
+ except Exception as e:
290
+ handle_error(e)
291
+
292
+
293
+ def run() -> None:
294
+ app()
295
+
296
+
297
+ if __name__ == "__main__":
298
+ app()
gcpath/core.py ADDED
@@ -0,0 +1,490 @@
1
+ import logging
2
+ import urllib.parse
3
+ from dataclasses import dataclass, field
4
+ from typing import Dict, List, Optional
5
+
6
+ from google.cloud import resourcemanager_v3, asset_v1 # type: ignore
7
+ from google.api_core import exceptions
8
+
9
+ # We use a logger but don't configure it here.
10
+ # Configuration should happen at the application entry point.
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class GCPathError(Exception):
15
+ """Base exception for gcpath."""
16
+ pass
17
+
18
+
19
+ class ResourceNotFoundError(GCPathError, ValueError):
20
+ """Raised when a resource is not found."""
21
+ pass
22
+
23
+
24
+ class PathParsingError(GCPathError, ValueError):
25
+ """Raised when a path cannot be parsed."""
26
+ pass
27
+
28
+
29
+ def path_escape(display_name: str) -> str:
30
+ """Escape display names for use in paths."""
31
+ return urllib.parse.quote(display_name, safe="")
32
+
33
+
34
+ def _clean_asset_name(name: str) -> str:
35
+ """Strips the Asset API prefix from resource names."""
36
+ prefix = "//cloudresourcemanager.googleapis.com/"
37
+ if name.startswith(prefix):
38
+ return name[len(prefix) :]
39
+ return name
40
+
41
+
42
+ @dataclass
43
+ class OrganizationNode:
44
+ organization: resourcemanager_v3.Organization
45
+ folders: Dict[str, "Folder"] = field(default_factory=dict)
46
+
47
+ def paths(self) -> List[str]:
48
+ return [f.path for f in self.folders.values()]
49
+
50
+ def get_resource_name(self, path: str) -> str:
51
+ # path e.g. / or /a/b
52
+ clean_path = path.strip("/")
53
+ if not clean_path:
54
+ return self.organization.name
55
+
56
+ parts = clean_path.split("/")
57
+ matches = []
58
+ for folder in self.folders.values():
59
+ if folder.is_path_match(parts):
60
+ matches.append(folder)
61
+
62
+ if len(matches) == 0:
63
+ raise ResourceNotFoundError(
64
+ f"No folder found with path '{path}' in '{self.organization.display_name}'"
65
+ )
66
+ if len(matches) > 1:
67
+ raise ResourceNotFoundError(
68
+ f"Multiple folders found with path '{path}' in '{self.organization.display_name}'"
69
+ )
70
+
71
+ return matches[0].name
72
+
73
+
74
+ @dataclass
75
+ class Folder:
76
+ name: str
77
+ display_name: str
78
+ ancestors: List[str]
79
+ organization: "OrganizationNode"
80
+
81
+ def is_path_match(self, path_parts: List[str]) -> bool:
82
+ # path matching logic
83
+ if len(path_parts) + 1 != len(self.ancestors):
84
+ return False
85
+
86
+ # Determine ancestors to check against path.
87
+ for i, part in enumerate(path_parts):
88
+ ancestor_resource_name = self.ancestors[len(path_parts) - i - 1]
89
+ folder = self.organization.folders.get(ancestor_resource_name)
90
+ if not folder:
91
+ return False
92
+
93
+ if folder.display_name != part:
94
+ return False
95
+
96
+ return True
97
+
98
+ @property
99
+ def path(self) -> str:
100
+ # Reconstruct path
101
+ path_str = "//" + path_escape(self.organization.organization.display_name)
102
+
103
+ # We iterate from Top to Bottom: [Leaf, Parent, ..., Org]
104
+ if len(self.ancestors) >= 2:
105
+ for i in range(len(self.ancestors) - 2, -1, -1):
106
+ res_name = self.ancestors[i]
107
+ parent = self.organization.folders.get(res_name)
108
+ if parent:
109
+ path_str += "/" + path_escape(parent.display_name)
110
+ else:
111
+ logger.warning(f"Ancestor {res_name} not found in folders map")
112
+ return path_str
113
+
114
+
115
+ @dataclass
116
+ class Project:
117
+ name: str
118
+ project_id: str
119
+ display_name: str
120
+ parent: str
121
+ organization: Optional["OrganizationNode"]
122
+ folder: Optional[Folder]
123
+
124
+ @property
125
+ def path(self) -> str:
126
+ if self.folder:
127
+ return f"{self.folder.path}/{path_escape(self.display_name)}"
128
+ if self.organization:
129
+ return f"//{path_escape(self.organization.organization.display_name)}/{path_escape(self.display_name)}"
130
+ # Organizationless project
131
+ return f"//_/{path_escape(self.display_name)}"
132
+
133
+
134
+ class Hierarchy:
135
+ def __init__(self, organizations: List[OrganizationNode], projects: List[Project]):
136
+ self.organizations = organizations
137
+ self.projects = projects
138
+
139
+ # Build lookup maps for O(1) resource name resolution
140
+ self._orgs_by_name: Dict[str, OrganizationNode] = {
141
+ o.organization.name: o for o in organizations
142
+ }
143
+ self._folders_by_name: Dict[str, Folder] = {}
144
+ for org in organizations:
145
+ self._folders_by_name.update(org.folders)
146
+
147
+ # Public list of all folders for convenience
148
+ self.folders = list(self._folders_by_name.values())
149
+
150
+ self._projects_by_name: Dict[str, Project] = {p.name: p for p in projects}
151
+
152
+ @classmethod
153
+ def load(
154
+ cls,
155
+ ctx_ignored=None,
156
+ display_names: Optional[List[str]] = None,
157
+ via_resource_manager: bool = True,
158
+ ) -> "Hierarchy":
159
+ org_client = resourcemanager_v3.OrganizationsClient()
160
+ project_client = resourcemanager_v3.ProjectsClient()
161
+
162
+ # Load Orgs
163
+ org_nodes = []
164
+ try:
165
+ page_result = org_client.search_organizations(
166
+ request=resourcemanager_v3.SearchOrganizationsRequest()
167
+ )
168
+ for org in page_result:
169
+ if display_names and org.display_name not in display_names:
170
+ continue
171
+
172
+ node = OrganizationNode(organization=org)
173
+ org_nodes.append(node)
174
+
175
+ if via_resource_manager:
176
+ cls._load_folders_rm(node)
177
+ else:
178
+ cls._load_folders_asset(node)
179
+ except exceptions.PermissionDenied:
180
+ logger.warning("Permission denied searching organizations")
181
+ except Exception as e:
182
+ logger.error(f"Error searching organizations: {e}")
183
+
184
+ # Load Projects
185
+ all_projects = []
186
+ if via_resource_manager:
187
+ try:
188
+ # search_projects() lists all projects the user has access to
189
+ projects_pager = project_client.search_projects(
190
+ request=resourcemanager_v3.SearchProjectsRequest()
191
+ )
192
+ for p_proto in projects_pager:
193
+ # Find parent
194
+ parent_org = None
195
+ parent_folder = None
196
+
197
+ if p_proto.parent.startswith("organizations/"):
198
+ parent_org = next(
199
+ (
200
+ o
201
+ for o in org_nodes
202
+ if o.organization.name == p_proto.parent
203
+ ),
204
+ None,
205
+ )
206
+ elif p_proto.parent.startswith("folders/"):
207
+ for o in org_nodes:
208
+ if p_proto.parent in o.folders:
209
+ parent_folder = o.folders[p_proto.parent]
210
+ parent_org = o
211
+ break
212
+
213
+ proj = Project(
214
+ name=p_proto.name,
215
+ project_id=p_proto.project_id,
216
+ display_name=p_proto.display_name or p_proto.project_id,
217
+ parent=p_proto.parent,
218
+ organization=parent_org,
219
+ folder=parent_folder,
220
+ )
221
+ all_projects.append(proj)
222
+ except exceptions.PermissionDenied:
223
+ logger.warning("Permission denied searching projects")
224
+ except Exception as e:
225
+ logger.error(f"Error searching projects: {e}")
226
+ else:
227
+ # Asset API mode
228
+ for org_node in org_nodes:
229
+ all_projects.extend(cls._load_projects_asset(org_node))
230
+
231
+ # Asset API Query REQUIRES a parent (like organization).
232
+ # To find organizationless projects, we always fallback to Resource Manager search_projects.
233
+ existing_project_names = {p.name for p in all_projects}
234
+ try:
235
+ projects_pager = project_client.search_projects(
236
+ request=resourcemanager_v3.SearchProjectsRequest()
237
+ )
238
+ for p_proto in projects_pager:
239
+ if p_proto.name in existing_project_names:
240
+ continue
241
+
242
+ # A project is organizationless if it's not under an organization or folder.
243
+ is_orgless = not p_proto.parent.startswith(
244
+ "organizations/"
245
+ ) and not p_proto.parent.startswith("folders/")
246
+
247
+ if is_orgless:
248
+ proj = Project(
249
+ name=p_proto.name,
250
+ project_id=p_proto.project_id,
251
+ display_name=p_proto.display_name or p_proto.project_id,
252
+ parent=p_proto.parent,
253
+ organization=None,
254
+ folder=None,
255
+ )
256
+ all_projects.append(proj)
257
+ except exceptions.PermissionDenied:
258
+ logger.warning("Permission denied searching organizationless projects")
259
+ except Exception as e:
260
+ logger.error(f"Error searching organizationless projects: {e}")
261
+
262
+ return cls(organizations=org_nodes, projects=all_projects)
263
+
264
+ @staticmethod
265
+ def _load_folders_rm(node: OrganizationNode):
266
+ folders_client = resourcemanager_v3.FoldersClient()
267
+
268
+ def recurse(parent_name: str, ancestors: List[str]):
269
+ request = resourcemanager_v3.ListFoldersRequest(parent=parent_name)
270
+ try:
271
+ page = folders_client.list_folders(request=request)
272
+ for folder_proto in page:
273
+ # ancestors list includes: [folder.Name, parent..., OrgName]
274
+ new_ancestors = [folder_proto.name] + ancestors
275
+
276
+ f = Folder(
277
+ name=folder_proto.name,
278
+ display_name=folder_proto.display_name,
279
+ ancestors=new_ancestors,
280
+ organization=node,
281
+ )
282
+ node.folders[f.name] = f
283
+ recurse(f.name, new_ancestors)
284
+ except exceptions.PermissionDenied:
285
+ logger.warning(f"Permission denied listing folders for {parent_name}")
286
+
287
+ # Start recursion with Org
288
+ # ancestors initially just the Org
289
+ recurse(node.organization.name, [node.organization.name])
290
+
291
+ @staticmethod
292
+ def _load_folders_asset(node: OrganizationNode):
293
+ asset_client = asset_v1.AssetServiceClient()
294
+ # "SELECT name, resource.data.displayName, ancestors FROM `cloudresourcemanager_googleapis_com_Folder`"
295
+
296
+ statement = "SELECT name, resource.data.displayName, ancestors FROM `cloudresourcemanager_googleapis_com_Folder`"
297
+ query_request = asset_v1.QueryAssetsRequest(
298
+ parent=node.organization.name,
299
+ query=asset_v1.QueryAssetsRequest.Statement(statement=statement),
300
+ )
301
+
302
+ response = asset_client.query_assets(request=query_request)
303
+
304
+ for page in response.pages:
305
+ if not page.query_result or not page.query_result.rows:
306
+ continue
307
+
308
+ for row in page.query_result.rows:
309
+ # The Asset API SQL results are returned in a Struct where the values
310
+ # are in a list named 'f', similar to BigQuery JSON output.
311
+ # 0: name, 1: displayName, 2: ancestors
312
+
313
+ row_dict = dict(row.fields)
314
+ if "f" not in row_dict:
315
+ continue
316
+
317
+ try:
318
+ f_list = row_dict["f"].list_value.values
319
+ if len(f_list) < 3:
320
+ logger.warning(f"Unexpected number of columns in Asset API row: {len(f_list)}")
321
+ continue
322
+
323
+ name_val = f_list[0].struct_value.fields["v"].string_value
324
+ display_name = f_list[1].struct_value.fields["v"].string_value
325
+ ancestors_val = f_list[2].struct_value.fields["v"].list_value.values
326
+
327
+ if not name_val or not display_name:
328
+ continue
329
+
330
+ name = _clean_asset_name(name_val)
331
+ raw_ancestors = [_clean_asset_name(item.struct_value.fields["v"].string_value) for item in ancestors_val]
332
+
333
+ # Ensure consistency with _load_folders_rm structure: [self, parent, ..., org]
334
+ if not raw_ancestors or raw_ancestors[0] != name:
335
+ ancestors = [name] + raw_ancestors
336
+ else:
337
+ ancestors = raw_ancestors
338
+
339
+ f = Folder(
340
+ name=name,
341
+ display_name=display_name,
342
+ ancestors=ancestors,
343
+ organization=node
344
+ )
345
+ node.folders[f.name] = f
346
+ except (AttributeError, KeyError, IndexError) as e:
347
+ logger.warning(f"Failed to parse Asset API row: {e}")
348
+ continue
349
+
350
+ @staticmethod
351
+ def _load_projects_asset(node: OrganizationNode) -> List[Project]:
352
+ asset_client = asset_v1.AssetServiceClient()
353
+ projects = []
354
+ # Query Projects
355
+ statement = "SELECT name, resource.data.projectNumber, resource.data.projectId, resource.data.displayName, ancestors FROM `cloudresourcemanager_googleapis_com_Project`"
356
+ query_request = asset_v1.QueryAssetsRequest(
357
+ parent=node.organization.name,
358
+ query=asset_v1.QueryAssetsRequest.Statement(statement=statement),
359
+ )
360
+
361
+ try:
362
+ response = asset_client.query_assets(request=query_request)
363
+ for page in response.pages:
364
+ if not page.query_result or not page.query_result.rows:
365
+ continue
366
+
367
+ for row in page.query_result.rows:
368
+ row_dict = dict(row.fields)
369
+ if "f" not in row_dict:
370
+ continue
371
+
372
+ f_list = row_dict["f"].list_value.values
373
+ if len(f_list) < 5:
374
+ logger.warning(
375
+ f"Unexpected number of columns in Asset API project row: {len(f_list)}"
376
+ )
377
+ continue
378
+
379
+ # 0: name, 1: projectNumber, 2: projectId, 3: displayName, 4: ancestors
380
+ name_val = f_list[0].struct_value.fields["v"].string_value
381
+ project_id = f_list[2].struct_value.fields["v"].string_value
382
+ display_name = (
383
+ f_list[3].struct_value.fields["v"].string_value or project_id
384
+ )
385
+ ancestors_val = f_list[4].struct_value.fields["v"].list_value.values
386
+
387
+ name = _clean_asset_name(name_val)
388
+ raw_ancestors = [
389
+ _clean_asset_name(item.struct_value.fields["v"].string_value)
390
+ for item in ancestors_val
391
+ ]
392
+
393
+ # For projects, we want the first ancestor that is NOT the project itself.
394
+ # Typically raw_ancestors[0] is the parent, but if it's the project itself, pick [1].
395
+ if raw_ancestors and raw_ancestors[0] == name:
396
+ parent_res = (
397
+ raw_ancestors[1]
398
+ if len(raw_ancestors) > 1
399
+ else node.organization.name
400
+ )
401
+ else:
402
+ parent_res = (
403
+ raw_ancestors[0]
404
+ if raw_ancestors
405
+ else node.organization.name
406
+ )
407
+
408
+ parent_folder = None
409
+ if parent_res.startswith("folders/"):
410
+ parent_folder = node.folders.get(parent_res)
411
+
412
+ proj = Project(
413
+ name=name,
414
+ project_id=project_id,
415
+ display_name=display_name,
416
+ parent=parent_res,
417
+ organization=node,
418
+ folder=parent_folder,
419
+ )
420
+ projects.append(proj)
421
+ except Exception as e:
422
+ logger.error(f"Error querying projects via Asset API: {e}")
423
+
424
+ return projects
425
+
426
+ @staticmethod
427
+ def _parse_path(path: str) -> tuple[str, str]:
428
+ """Parse //org_name/path format without being fragile to urlparse semantics."""
429
+ if not path.startswith("//"):
430
+ raise PathParsingError("Path must start with //")
431
+
432
+ trimmed = path[2:]
433
+ if not trimmed:
434
+ raise PathParsingError("Path must contain an organization name (e.g., //example.com)")
435
+
436
+ parts = trimmed.split("/", 1)
437
+ org_name = parts[0]
438
+ resource_path = "/" + parts[1] if len(parts) > 1 else "/"
439
+ return org_name, resource_path
440
+
441
+ def get_resource_name(self, path: str) -> str:
442
+ org_name, resource_path = self._parse_path(path)
443
+
444
+ # Reserved for organizationless scope
445
+ if org_name == "_":
446
+ # Search in organizationless projects
447
+ for proj in self.projects:
448
+ if not proj.organization and proj.path == path:
449
+ return proj.name
450
+ raise ResourceNotFoundError(f"Project path '{path}' not found in organizationless scope")
451
+
452
+ org_node = next(
453
+ (o for o in self.organizations if o.organization.display_name == org_name),
454
+ None,
455
+ )
456
+ if not org_node:
457
+ raise ResourceNotFoundError(f"Organization '{org_name}' not found")
458
+
459
+ if resource_path == "/":
460
+ return org_node.organization.name
461
+
462
+ try:
463
+ return org_node.get_resource_name(resource_path)
464
+ except ResourceNotFoundError:
465
+ # Maybe it's a project at the end of the path?
466
+ for proj in self.projects:
467
+ if proj.organization == org_node and proj.path == path:
468
+ return proj.name
469
+ raise
470
+
471
+ def get_path_by_resource_name(self, resource_name: str) -> str:
472
+ if resource_name.startswith("organizations/"):
473
+ org = self._orgs_by_name.get(resource_name)
474
+ if org:
475
+ return "//" + path_escape(org.organization.display_name)
476
+ raise ResourceNotFoundError(f"Organization '{resource_name}' not found")
477
+
478
+ if resource_name.startswith("folders/"):
479
+ folder = self._folders_by_name.get(resource_name)
480
+ if folder:
481
+ return folder.path
482
+ raise ResourceNotFoundError(f"Folder '{resource_name}' not found")
483
+
484
+ if resource_name.startswith("projects/"):
485
+ proj = self._projects_by_name.get(resource_name)
486
+ if proj:
487
+ return proj.path
488
+ raise ResourceNotFoundError(f"Project '{resource_name}' not found")
489
+
490
+ raise ResourceNotFoundError(f"Unsupported resource name '{resource_name}'")
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: gcpath
3
+ Version: 0.1.0
4
+ Summary: A CLI utility to manage Google Cloud Platform resource hierarchy paths.
5
+ Project-URL: Homepage, https://github.com/tardigrde/gcpath
6
+ Project-URL: Repository, https://github.com/tardigrde/gcpath
7
+ Project-URL: Issues, https://github.com/tardigrde/gcpath/issues
8
+ Author-email: Levente <leventetsk@proton.me>
9
+ License-File: LICENSE
10
+ Keywords: folders,gcp,google-cloud,hierarchy,projects
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: google-cloud-asset>=4.1.0
20
+ Requires-Dist: google-cloud-resource-manager>=1.15.0
21
+ Requires-Dist: rich>=14.2.0
22
+ Requires-Dist: typer>=0.20.1
23
+ Description-Content-Type: text/markdown
24
+
25
+ # gcpath
26
+
27
+ `gcpath` is a CLI utility to manage Google Cloud Platform resource hierarchy paths.
28
+ It helps you translate between GCP resource names (e.g., `folders/12345`) and human-readable paths (e.g., `//example.com/department/team`).
29
+
30
+ ## Features
31
+
32
+ - **Recursive Listing**: List all folders in your organization as paths.
33
+ - **Path Resolution**: Get the resource name (ID) for a given path.
34
+ - **Reverse Lookup**: Get the path for a given resource name (ID).
35
+ - **Dual Mode**:
36
+ - **Cloud Asset API (Default)**: Fast, bulk loading using GCP Cloud Asset Inventory.
37
+ - **Resource Manager API**: Iterative loading using standard Resource Manager API (slower, but different permissions).
38
+
39
+ ## Quick Start
40
+
41
+ After installation, ensure you are authenticated with Google Cloud and have the necessary permissions.
42
+
43
+ ```bash
44
+ # List all resources
45
+ gcpath ls
46
+
47
+ # Find ID of a specific path
48
+ gcpath name //example.com/engineering
49
+
50
+ # Find path of a specific resource ID
51
+ gcpath path folders/123456789
52
+ ```
53
+
54
+ ## Authentication
55
+
56
+ `gcpath` uses [Google Cloud Application Default Credentials (ADC)](https://cloud.google.com/docs/authentication/provide-credentials-adc).
57
+
58
+ ### Setup
59
+ 1. Install [gcloud CLI](https://cloud.google.com/sdk/docs/install).
60
+ 2. Authenticate:
61
+ ```bash
62
+ gcloud auth application-default login
63
+ ```
64
+
65
+ For service accounts in CI/CD environments:
66
+ ```bash
67
+ export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
68
+ ```
69
+
70
+ ## Usage
71
+
72
+ ### List Folders
73
+
74
+ Recursively list all folders in your organization(s):
75
+
76
+ ```bash
77
+ gcpath ls
78
+ ```
79
+
80
+ Output:
81
+ ```
82
+ //example.com/engineering
83
+ //example.com/engineering/backend
84
+ //example.com/sales
85
+ ...
86
+ ```
87
+
88
+ You can also filter by organization display name:
89
+
90
+ ```bash
91
+ gcpath ls "example.com"
92
+ ```
93
+
94
+ ### Get Resource Name
95
+
96
+ Get the GCP resource name (e.g., `folders/123`) from a path:
97
+
98
+ ```bash
99
+ gcpath name //example.com/engineering/backend
100
+ # Output: folders/987654321
101
+ ```
102
+
103
+ To get just the ID:
104
+
105
+ ```bash
106
+ gcpath name --id //example.com/engineering/backend
107
+ # Output: 987654321
108
+ ```
109
+
110
+ ### Get Path
111
+
112
+ Get the path from a resource name:
113
+
114
+ ```bash
115
+ gcpath path folders/987654321
116
+ # Output: //example.com/engineering/backend
117
+ ```
118
+
119
+ ### Tree View
120
+
121
+ Visualize the hierarchy in a tree format:
122
+
123
+ ```bash
124
+ gcpath tree
125
+ ```
126
+
127
+ Options:
128
+ - `-L, --level N`: Limit depth of the tree.
129
+ - `-i, --ids`: Include resource IDs in the output.
130
+
131
+ ### Modes
132
+
133
+ By default, `gcpath` uses the Cloud Asset API which is faster for large hierarchies.
134
+ To force using the Resource Manager API (iterative), use the `-U` / `--no-use-asset-api` flag:
135
+
136
+ ```bash
137
+ gcpath ls -U
138
+ ```
139
+
140
+ ## Permissions
141
+
142
+ ### Cloud Asset API (Default)
143
+ Requires `cloudasset.assets.searchAllResources` permission on the Organization.
144
+
145
+ ### Resource Manager API
146
+ Requires `resourcemanager.folders.list` on the Organization and folders.
147
+
148
+ ## Development
149
+
150
+ Prerequisites: `uv` (https://github.com/astral-sh/uv).
151
+
152
+ 1. Clone the repository.
153
+ 2. Install dependencies:
154
+ ```bash
155
+ uv sync
156
+ ```
157
+ 3. Run tests:
158
+ ```bash
159
+ uv run pytest
160
+ ```
161
+
162
+ ## License
163
+
164
+ MIT
@@ -0,0 +1,8 @@
1
+ gcpath/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ gcpath/cli.py,sha256=nL9JosbKp0MECb4wI2ob7CAsTJOF0FCdsxo2KdoLLbw,9546
3
+ gcpath/core.py,sha256=m-1XSVWzeXLRyzTJ0kejDiMixeG858h-PD1CWencFqs,19328
4
+ gcpath-0.1.0.dist-info/METADATA,sha256=ktf5VCOKVLN5YcFv6ukL8ZQT23MqaHucMxwxD7vzIzc,3945
5
+ gcpath-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ gcpath-0.1.0.dist-info/entry_points.txt,sha256=lnMH6BCByB_hACZeIF7qZgIvAqx7Oi-7rjY1mtgGf50,42
7
+ gcpath-0.1.0.dist-info/licenses/LICENSE,sha256=j5EIx0ailzm4TAREZutbkNBko7nLs59vs8D0nGScos0,1071
8
+ gcpath-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gcpath = gcpath.cli:run
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Levente Csőke
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.