gitmap-core 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.
- gitmap_core/README.md +46 -0
- gitmap_core/__init__.py +100 -0
- gitmap_core/communication.py +346 -0
- gitmap_core/compat.py +408 -0
- gitmap_core/connection.py +232 -0
- gitmap_core/context.py +709 -0
- gitmap_core/diff.py +283 -0
- gitmap_core/maps.py +385 -0
- gitmap_core/merge.py +449 -0
- gitmap_core/models.py +332 -0
- gitmap_core/py.typed +0 -0
- gitmap_core/pyproject.toml +48 -0
- gitmap_core/remote.py +728 -0
- gitmap_core/repository.py +1632 -0
- gitmap_core/tests/__init__.py +1 -0
- gitmap_core/tests/test_communication.py +695 -0
- gitmap_core/tests/test_compat.py +310 -0
- gitmap_core/tests/test_connection.py +314 -0
- gitmap_core/tests/test_context.py +814 -0
- gitmap_core/tests/test_diff.py +567 -0
- gitmap_core/tests/test_init.py +153 -0
- gitmap_core/tests/test_maps.py +642 -0
- gitmap_core/tests/test_merge.py +694 -0
- gitmap_core/tests/test_models.py +410 -0
- gitmap_core/tests/test_remote.py +3014 -0
- gitmap_core/tests/test_repository.py +1639 -0
- gitmap_core/tests/test_visualize.py +902 -0
- gitmap_core/visualize.py +1217 -0
- gitmap_core-0.1.0.dist-info/METADATA +961 -0
- gitmap_core-0.1.0.dist-info/RECORD +32 -0
- gitmap_core-0.1.0.dist-info/WHEEL +4 -0
- gitmap_core-0.1.0.dist-info/licenses/LICENSE +21 -0
gitmap_core/compat.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""ArcGIS API compatibility layer.
|
|
2
|
+
|
|
3
|
+
Provides version detection and shims for different arcgis package versions.
|
|
4
|
+
Supports arcgis 2.2.x through 2.4.x with graceful fallbacks.
|
|
5
|
+
|
|
6
|
+
Execution Context:
|
|
7
|
+
Library module - imported by modules that interact with arcgis
|
|
8
|
+
|
|
9
|
+
Metadata:
|
|
10
|
+
Version: 0.1.0
|
|
11
|
+
Author: GitMap Team
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
from functools import lru_cache
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from arcgis.gis import GIS
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---- Version Detection --------------------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@lru_cache(maxsize=1)
|
|
30
|
+
def get_arcgis_version() -> tuple[int, int, int]:
|
|
31
|
+
"""Get the installed arcgis package version.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Tuple of (major, minor, patch) version numbers.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ImportError: If arcgis package is not installed.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
import arcgis
|
|
41
|
+
version_str = getattr(arcgis, "__version__", "0.0.0")
|
|
42
|
+
parts = version_str.split(".")
|
|
43
|
+
major = int(parts[0]) if len(parts) > 0 else 0
|
|
44
|
+
minor = int(parts[1]) if len(parts) > 1 else 0
|
|
45
|
+
patch = int(parts[2].split("-")[0].split("+")[0]) if len(parts) > 2 else 0
|
|
46
|
+
return (major, minor, patch)
|
|
47
|
+
except ImportError as e:
|
|
48
|
+
raise ImportError("arcgis package is not installed") from e
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def get_arcgis_version_string() -> str:
|
|
52
|
+
"""Get the installed arcgis package version as a string.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Version string (e.g., "2.4.0").
|
|
56
|
+
"""
|
|
57
|
+
major, minor, patch = get_arcgis_version()
|
|
58
|
+
return f"{major}.{minor}.{patch}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_minimum_version(min_major: int, min_minor: int = 0, min_patch: int = 0) -> bool:
|
|
62
|
+
"""Check if installed arcgis meets minimum version requirement.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
min_major: Minimum major version.
|
|
66
|
+
min_minor: Minimum minor version.
|
|
67
|
+
min_patch: Minimum patch version.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
True if installed version meets or exceeds minimum.
|
|
71
|
+
"""
|
|
72
|
+
major, minor, patch = get_arcgis_version()
|
|
73
|
+
installed = (major, minor, patch)
|
|
74
|
+
required = (min_major, min_minor, min_patch)
|
|
75
|
+
return installed >= required
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_maximum_version(max_major: int, max_minor: int = 99, max_patch: int = 99) -> bool:
|
|
79
|
+
"""Check if installed arcgis is at or below maximum version.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
max_major: Maximum major version.
|
|
83
|
+
max_minor: Maximum minor version.
|
|
84
|
+
max_patch: Maximum patch version.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True if installed version is at or below maximum.
|
|
88
|
+
"""
|
|
89
|
+
major, minor, patch = get_arcgis_version()
|
|
90
|
+
installed = (major, minor, patch)
|
|
91
|
+
maximum = (max_major, max_minor, max_patch)
|
|
92
|
+
return installed <= maximum
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---- Version Constants --------------------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Minimum supported version
|
|
99
|
+
MIN_SUPPORTED_VERSION = (2, 2, 0)
|
|
100
|
+
|
|
101
|
+
# Maximum tested version (warn above this)
|
|
102
|
+
MAX_TESTED_VERSION = (2, 4, 99)
|
|
103
|
+
|
|
104
|
+
# Version where folders API changed
|
|
105
|
+
FOLDERS_API_CHANGE_VERSION = (2, 3, 0)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---- Compatibility Check ------------------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def check_compatibility() -> dict[str, Any]:
|
|
112
|
+
"""Check arcgis package compatibility and return status.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Dict with keys:
|
|
116
|
+
- compatible: bool - True if version is supported
|
|
117
|
+
- version: str - Installed version string
|
|
118
|
+
- warnings: list[str] - Any compatibility warnings
|
|
119
|
+
- errors: list[str] - Any compatibility errors
|
|
120
|
+
"""
|
|
121
|
+
result = {
|
|
122
|
+
"compatible": True,
|
|
123
|
+
"version": "unknown",
|
|
124
|
+
"warnings": [],
|
|
125
|
+
"errors": [],
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
version = get_arcgis_version()
|
|
130
|
+
result["version"] = get_arcgis_version_string()
|
|
131
|
+
|
|
132
|
+
# Check minimum version
|
|
133
|
+
if not check_minimum_version(*MIN_SUPPORTED_VERSION):
|
|
134
|
+
result["compatible"] = False
|
|
135
|
+
min_ver = ".".join(str(v) for v in MIN_SUPPORTED_VERSION)
|
|
136
|
+
result["errors"].append(
|
|
137
|
+
f"arcgis version {result['version']} is below minimum supported version {min_ver}. "
|
|
138
|
+
f"Please upgrade: pip install --upgrade arcgis"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
# Check if above tested version (warning only)
|
|
142
|
+
if not check_maximum_version(*MAX_TESTED_VERSION):
|
|
143
|
+
max_ver = ".".join(str(v) for v in MAX_TESTED_VERSION[:2]) + ".x"
|
|
144
|
+
result["warnings"].append(
|
|
145
|
+
f"arcgis version {result['version']} is newer than tested version {max_ver}. "
|
|
146
|
+
f"GitMap may work but has not been validated against this version."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
# Check for major version 3+ (future breaking changes)
|
|
150
|
+
if version[0] >= 3:
|
|
151
|
+
result["warnings"].append(
|
|
152
|
+
f"arcgis version {result['version']} is a major version upgrade. "
|
|
153
|
+
f"Some APIs may have changed. Please report any issues."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
except ImportError as e:
|
|
157
|
+
result["compatible"] = False
|
|
158
|
+
result["errors"].append(str(e))
|
|
159
|
+
|
|
160
|
+
return result
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def validate_or_warn() -> None:
|
|
164
|
+
"""Validate arcgis compatibility and log warnings/errors.
|
|
165
|
+
|
|
166
|
+
Call this at module import time to surface compatibility issues early.
|
|
167
|
+
Does not raise - just logs warnings.
|
|
168
|
+
"""
|
|
169
|
+
status = check_compatibility()
|
|
170
|
+
|
|
171
|
+
for error in status["errors"]:
|
|
172
|
+
logger.error(f"ArcGIS compatibility: {error}")
|
|
173
|
+
|
|
174
|
+
for warning in status["warnings"]:
|
|
175
|
+
logger.warning(f"ArcGIS compatibility: {warning}")
|
|
176
|
+
|
|
177
|
+
if status["compatible"]:
|
|
178
|
+
logger.debug(f"ArcGIS version {status['version']} is compatible")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---- Folder API Shims ---------------------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def create_folder(gis: GIS, folder_name: str) -> dict[str, Any] | None:
|
|
185
|
+
"""Create a folder in Portal with version-appropriate API.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
gis: Authenticated GIS connection.
|
|
189
|
+
folder_name: Name for the new folder.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Dict with folder info including 'id', or None if creation failed.
|
|
193
|
+
|
|
194
|
+
Note:
|
|
195
|
+
- arcgis >= 2.3.0: Uses gis.content.folders.create()
|
|
196
|
+
- arcgis < 2.3.0: Uses gis.content.create_folder()
|
|
197
|
+
|
|
198
|
+
The function will search for the folder after creation if the API
|
|
199
|
+
returns an empty result (common on some Portal versions).
|
|
200
|
+
"""
|
|
201
|
+
def _extract_folder_info(result: Any) -> dict[str, Any] | None:
|
|
202
|
+
"""Extract folder info from API result."""
|
|
203
|
+
if not result:
|
|
204
|
+
return None
|
|
205
|
+
if isinstance(result, dict):
|
|
206
|
+
folder_id = result.get("id") or result.get("folderId") or result.get("folder_id")
|
|
207
|
+
if folder_id:
|
|
208
|
+
return {"id": folder_id, "title": result.get("title", folder_name)}
|
|
209
|
+
else:
|
|
210
|
+
# Object with attributes - try multiple attribute names
|
|
211
|
+
folder_id = (
|
|
212
|
+
getattr(result, "id", None) or
|
|
213
|
+
getattr(result, "folderId", None) or
|
|
214
|
+
getattr(result, "folder_id", None)
|
|
215
|
+
)
|
|
216
|
+
if folder_id:
|
|
217
|
+
return {"id": folder_id, "title": getattr(result, "title", folder_name)}
|
|
218
|
+
return None
|
|
219
|
+
|
|
220
|
+
def _search_for_folder() -> dict[str, Any] | None:
|
|
221
|
+
"""Search for folder in user's folders after creation."""
|
|
222
|
+
try:
|
|
223
|
+
user = gis.users.me
|
|
224
|
+
# Refresh folder list
|
|
225
|
+
folders = user.folders
|
|
226
|
+
for folder in folders:
|
|
227
|
+
if isinstance(folder, dict):
|
|
228
|
+
if folder.get("title") == folder_name:
|
|
229
|
+
return {"id": folder.get("id"), "title": folder_name}
|
|
230
|
+
else:
|
|
231
|
+
if getattr(folder, "title", None) == folder_name:
|
|
232
|
+
return {"id": getattr(folder, "id", None), "title": folder_name}
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
result = None
|
|
239
|
+
|
|
240
|
+
if check_minimum_version(*FOLDERS_API_CHANGE_VERSION):
|
|
241
|
+
# New API (2.3.0+)
|
|
242
|
+
result = gis.content.folders.create(folder_name)
|
|
243
|
+
else:
|
|
244
|
+
# Legacy API (< 2.3.0)
|
|
245
|
+
result = gis.content.create_folder(folder_name)
|
|
246
|
+
|
|
247
|
+
# Try to extract folder info from result
|
|
248
|
+
folder_info = _extract_folder_info(result)
|
|
249
|
+
if folder_info and folder_info.get("id"):
|
|
250
|
+
return folder_info
|
|
251
|
+
|
|
252
|
+
# If result was empty/missing ID, search for the folder
|
|
253
|
+
# (some Portal versions create the folder but return empty result)
|
|
254
|
+
logger.debug(f"Folder creation returned no ID, searching for folder '{folder_name}'...")
|
|
255
|
+
folder_info = _search_for_folder()
|
|
256
|
+
if folder_info and folder_info.get("id"):
|
|
257
|
+
logger.debug(f"Found folder '{folder_name}' with ID {folder_info['id']}")
|
|
258
|
+
return folder_info
|
|
259
|
+
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
except Exception as e:
|
|
263
|
+
error_msg = str(e).lower()
|
|
264
|
+
# If folder already exists, try to find it
|
|
265
|
+
if "not available" in error_msg or "already exists" in error_msg or "unable to create" in error_msg:
|
|
266
|
+
logger.debug(f"Folder '{folder_name}' may already exist, searching...")
|
|
267
|
+
folder_info = _search_for_folder()
|
|
268
|
+
if folder_info and folder_info.get("id"):
|
|
269
|
+
return folder_info
|
|
270
|
+
logger.debug(f"Folder creation failed: {e}")
|
|
271
|
+
raise
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def get_user_folders(gis: GIS) -> list[dict[str, Any]]:
|
|
275
|
+
"""Get user's folders with version-appropriate API.
|
|
276
|
+
|
|
277
|
+
Tries multiple approaches to ensure folder discovery works across
|
|
278
|
+
different Portal versions and configurations.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
gis: Authenticated GIS connection.
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
List of folder dicts with 'id' and 'title' keys.
|
|
285
|
+
"""
|
|
286
|
+
result = []
|
|
287
|
+
seen_ids: set[str] = set()
|
|
288
|
+
|
|
289
|
+
def _add_folder(folder: Any) -> None:
|
|
290
|
+
"""Extract and add folder info if not already seen."""
|
|
291
|
+
if isinstance(folder, dict):
|
|
292
|
+
fid = folder.get("id") or folder.get("folderId")
|
|
293
|
+
title = folder.get("title") or folder.get("name")
|
|
294
|
+
else:
|
|
295
|
+
fid = getattr(folder, "id", None) or getattr(folder, "folderId", None)
|
|
296
|
+
title = getattr(folder, "title", None) or getattr(folder, "name", None)
|
|
297
|
+
|
|
298
|
+
if fid and fid not in seen_ids:
|
|
299
|
+
seen_ids.add(fid)
|
|
300
|
+
result.append({"id": fid, "title": title})
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
user = gis.users.me
|
|
304
|
+
|
|
305
|
+
# Method 1: user.folders (standard approach)
|
|
306
|
+
try:
|
|
307
|
+
folders = user.folders
|
|
308
|
+
for folder in folders:
|
|
309
|
+
_add_folder(folder)
|
|
310
|
+
except Exception:
|
|
311
|
+
pass
|
|
312
|
+
|
|
313
|
+
# Method 2: gis.content.folders.list() (newer API, 2.3.0+)
|
|
314
|
+
if check_minimum_version(*FOLDERS_API_CHANGE_VERSION):
|
|
315
|
+
try:
|
|
316
|
+
folders = gis.content.folders.list()
|
|
317
|
+
for folder in folders:
|
|
318
|
+
_add_folder(folder)
|
|
319
|
+
except Exception:
|
|
320
|
+
pass
|
|
321
|
+
|
|
322
|
+
# Method 3: Search through user's items to discover folders
|
|
323
|
+
try:
|
|
324
|
+
user_items = user.items()
|
|
325
|
+
for item in user_items:
|
|
326
|
+
owner_folder = getattr(item, "ownerFolder", None)
|
|
327
|
+
if owner_folder and owner_folder not in seen_ids:
|
|
328
|
+
# Try to get folder info
|
|
329
|
+
try:
|
|
330
|
+
folder_info = gis.content.get_folder(owner_folder, user.username)
|
|
331
|
+
if folder_info:
|
|
332
|
+
_add_folder(folder_info)
|
|
333
|
+
except Exception:
|
|
334
|
+
# If we can't get info, just add the ID
|
|
335
|
+
seen_ids.add(owner_folder)
|
|
336
|
+
result.append({"id": owner_folder, "title": None})
|
|
337
|
+
except Exception:
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
return result
|
|
341
|
+
|
|
342
|
+
except Exception as e:
|
|
343
|
+
logger.debug(f"Failed to get folders: {e}")
|
|
344
|
+
return result
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# ---- Content API Shims --------------------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def search_content(
|
|
351
|
+
gis: GIS,
|
|
352
|
+
query: str,
|
|
353
|
+
max_items: int = 100,
|
|
354
|
+
item_type: str | None = None,
|
|
355
|
+
) -> list[Any]:
|
|
356
|
+
"""Search Portal content with version-appropriate API.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
gis: Authenticated GIS connection.
|
|
360
|
+
query: Search query string.
|
|
361
|
+
max_items: Maximum results to return.
|
|
362
|
+
item_type: Optional item type filter.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
List of Item objects matching the query.
|
|
366
|
+
"""
|
|
367
|
+
try:
|
|
368
|
+
search_params = {
|
|
369
|
+
"query": query,
|
|
370
|
+
"max_items": max_items,
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
# item_type parameter name varies by version
|
|
374
|
+
if item_type:
|
|
375
|
+
if check_minimum_version(2, 3, 0):
|
|
376
|
+
search_params["item_type"] = item_type
|
|
377
|
+
else:
|
|
378
|
+
# Append to query for older versions
|
|
379
|
+
search_params["query"] = f"{query} type:\"{item_type}\""
|
|
380
|
+
|
|
381
|
+
return gis.content.search(**search_params)
|
|
382
|
+
except Exception as e:
|
|
383
|
+
logger.debug(f"Content search failed: {e}")
|
|
384
|
+
return []
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def get_item_data(item: Any) -> dict[str, Any] | None:
|
|
388
|
+
"""Get item data with consistent error handling.
|
|
389
|
+
|
|
390
|
+
Args:
|
|
391
|
+
item: Portal Item object.
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
Item data dict, or None if retrieval failed.
|
|
395
|
+
"""
|
|
396
|
+
try:
|
|
397
|
+
data = item.get_data()
|
|
398
|
+
return data if isinstance(data, dict) else None
|
|
399
|
+
except Exception as e:
|
|
400
|
+
logger.debug(f"Failed to get item data: {e}")
|
|
401
|
+
return None
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
# ---- Initialize on import -----------------------------------------------------------------------------------
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
# Run compatibility check when module is imported
|
|
408
|
+
validate_or_warn()
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Portal and ArcGIS Online authentication module.
|
|
2
|
+
|
|
3
|
+
Handles authentication to ArcGIS Portal and ArcGIS Online (AGOL)
|
|
4
|
+
using the ArcGIS API for Python.
|
|
5
|
+
|
|
6
|
+
Execution Context:
|
|
7
|
+
Library module - imported by remote operations
|
|
8
|
+
|
|
9
|
+
Dependencies:
|
|
10
|
+
- arcgis: GIS authentication and Portal interaction
|
|
11
|
+
- python-dotenv: Load environment variables from .env file
|
|
12
|
+
|
|
13
|
+
Metadata:
|
|
14
|
+
Version: 0.1.0
|
|
15
|
+
Author: GitMap Team
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from dotenv import load_dotenv
|
|
26
|
+
except ImportError:
|
|
27
|
+
load_dotenv = None
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from arcgis.gis import GIS
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---- Environment Loading ---------------------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _load_env_file(
|
|
37
|
+
env_path: Path | None = None,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Load environment variables from .env file.
|
|
40
|
+
|
|
41
|
+
Searches for .env file in:
|
|
42
|
+
1. Specified path (if provided)
|
|
43
|
+
2. Current working directory
|
|
44
|
+
3. Parent directories up to workspace root
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
env_path: Explicit path to .env file (optional).
|
|
48
|
+
"""
|
|
49
|
+
if load_dotenv is None:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if env_path:
|
|
53
|
+
if env_path.exists():
|
|
54
|
+
load_dotenv(env_path, override=True)
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
# Try current directory
|
|
58
|
+
cwd_env = Path.cwd() / ".env"
|
|
59
|
+
if cwd_env.exists():
|
|
60
|
+
load_dotenv(cwd_env, override=True)
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
# Try parent directories (up to 3 levels)
|
|
64
|
+
current = Path.cwd()
|
|
65
|
+
for _ in range(3):
|
|
66
|
+
parent = current.parent
|
|
67
|
+
parent_env = parent / ".env"
|
|
68
|
+
if parent_env.exists():
|
|
69
|
+
load_dotenv(parent_env, override=True)
|
|
70
|
+
return
|
|
71
|
+
current = parent
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ---- Connection Classes -------------------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class PortalConnection:
|
|
79
|
+
"""Manages authenticated connection to ArcGIS Portal or AGOL.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
url: Portal URL (use 'https://www.arcgis.com' for AGOL).
|
|
83
|
+
username: Portal username.
|
|
84
|
+
_gis: Cached GIS connection object.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
url: str
|
|
88
|
+
username: str | None = None
|
|
89
|
+
_gis: GIS | None = None
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def gis(
|
|
93
|
+
self,
|
|
94
|
+
) -> GIS:
|
|
95
|
+
"""Get authenticated GIS connection.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Authenticated GIS object.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
RuntimeError: If connection fails.
|
|
102
|
+
"""
|
|
103
|
+
if self._gis is None:
|
|
104
|
+
msg = "Not connected. Call connect() first."
|
|
105
|
+
raise RuntimeError(msg)
|
|
106
|
+
return self._gis
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def is_connected(
|
|
110
|
+
self,
|
|
111
|
+
) -> bool:
|
|
112
|
+
"""Check if connected to Portal.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
True if connected, False otherwise.
|
|
116
|
+
"""
|
|
117
|
+
return self._gis is not None
|
|
118
|
+
|
|
119
|
+
def connect(
|
|
120
|
+
self,
|
|
121
|
+
password: str | None = None,
|
|
122
|
+
) -> GIS:
|
|
123
|
+
"""Establish connection to Portal.
|
|
124
|
+
|
|
125
|
+
Attempts connection in order:
|
|
126
|
+
1. Username/password if provided
|
|
127
|
+
2. Environment variables (ARCGIS_USERNAME, ARCGIS_PASSWORD) from .env file
|
|
128
|
+
3. Pro authentication (if running in ArcGIS Pro)
|
|
129
|
+
4. Anonymous access
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
password: Portal password (optional).
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Authenticated GIS object.
|
|
136
|
+
|
|
137
|
+
Raises:
|
|
138
|
+
RuntimeError: If connection fails.
|
|
139
|
+
"""
|
|
140
|
+
try:
|
|
141
|
+
from arcgis.gis import GIS
|
|
142
|
+
|
|
143
|
+
# Load .env file if available
|
|
144
|
+
_load_env_file()
|
|
145
|
+
|
|
146
|
+
# Try username/password authentication
|
|
147
|
+
if self.username and password:
|
|
148
|
+
self._gis = GIS(
|
|
149
|
+
url=self.url,
|
|
150
|
+
username=self.username,
|
|
151
|
+
password=password,
|
|
152
|
+
)
|
|
153
|
+
return self._gis
|
|
154
|
+
|
|
155
|
+
# Try environment variables (from .env or shell)
|
|
156
|
+
# Check both PORTAL_USER/PORTAL_PASSWORD and ARCGIS_USERNAME/ARCGIS_PASSWORD
|
|
157
|
+
env_username = os.environ.get("PORTAL_USER") or os.environ.get("ARCGIS_USERNAME")
|
|
158
|
+
env_password = os.environ.get("PORTAL_PASSWORD") or os.environ.get("ARCGIS_PASSWORD")
|
|
159
|
+
if env_username and env_password:
|
|
160
|
+
self._gis = GIS(
|
|
161
|
+
url=self.url,
|
|
162
|
+
username=env_username,
|
|
163
|
+
password=env_password,
|
|
164
|
+
)
|
|
165
|
+
self.username = env_username
|
|
166
|
+
return self._gis
|
|
167
|
+
|
|
168
|
+
# Try Pro authentication or anonymous
|
|
169
|
+
self._gis = GIS(url=self.url)
|
|
170
|
+
if self._gis.users.me:
|
|
171
|
+
self.username = self._gis.users.me.username
|
|
172
|
+
return self._gis
|
|
173
|
+
|
|
174
|
+
except Exception as connection_error:
|
|
175
|
+
msg = f"Failed to connect to Portal at {self.url}: {connection_error}"
|
|
176
|
+
raise RuntimeError(msg) from connection_error
|
|
177
|
+
|
|
178
|
+
def disconnect(
|
|
179
|
+
self,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Disconnect from Portal."""
|
|
182
|
+
self._gis = None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---- Connection Functions -----------------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def get_connection(
|
|
189
|
+
url: str = "https://www.arcgis.com",
|
|
190
|
+
username: str | None = None,
|
|
191
|
+
password: str | None = None,
|
|
192
|
+
) -> PortalConnection:
|
|
193
|
+
"""Create and authenticate a Portal connection.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
url: Portal URL. Defaults to ArcGIS Online.
|
|
197
|
+
username: Portal username (optional).
|
|
198
|
+
password: Portal password (optional).
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Authenticated PortalConnection.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
RuntimeError: If connection fails.
|
|
205
|
+
"""
|
|
206
|
+
connection = PortalConnection(url=url, username=username)
|
|
207
|
+
connection.connect(password=password)
|
|
208
|
+
return connection
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def get_agol_connection(
|
|
212
|
+
username: str | None = None,
|
|
213
|
+
password: str | None = None,
|
|
214
|
+
) -> PortalConnection:
|
|
215
|
+
"""Create connection to ArcGIS Online.
|
|
216
|
+
|
|
217
|
+
Convenience function for AGOL connections.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
username: AGOL username (optional).
|
|
221
|
+
password: AGOL password (optional).
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
Authenticated PortalConnection to AGOL.
|
|
225
|
+
"""
|
|
226
|
+
return get_connection(
|
|
227
|
+
url="https://www.arcgis.com",
|
|
228
|
+
username=username,
|
|
229
|
+
password=password,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|