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/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
+