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 +0 -0
- gcpath/cli.py +298 -0
- gcpath/core.py +490 -0
- gcpath-0.1.0.dist-info/METADATA +164 -0
- gcpath-0.1.0.dist-info/RECORD +8 -0
- gcpath-0.1.0.dist-info/WHEEL +4 -0
- gcpath-0.1.0.dist-info/entry_points.txt +2 -0
- gcpath-0.1.0.dist-info/licenses/LICENSE +21 -0
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,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.
|