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/remote.py ADDED
@@ -0,0 +1,728 @@
1
+ """Remote operations module for GitMap.
2
+
3
+ Handles push and pull operations between local repository and
4
+ ArcGIS Portal/AGOL remotes.
5
+
6
+ Execution Context:
7
+ Library module - imported by CLI push/pull commands
8
+
9
+ Dependencies:
10
+ - arcgis: Portal interaction
11
+ - gitmap_core.connection: Portal authentication
12
+ - gitmap_core.models: Data models
13
+
14
+ Metadata:
15
+ Version: 0.1.0
16
+ Author: GitMap Team
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from typing import TYPE_CHECKING
22
+ from typing import Any
23
+
24
+ from gitmap_core.communication import notify_item_group_users
25
+ from gitmap_core.compat import create_folder as compat_create_folder
26
+ from gitmap_core.compat import get_user_folders
27
+ from gitmap_core.connection import PortalConnection
28
+ from gitmap_core.models import Remote
29
+
30
+ if TYPE_CHECKING:
31
+ from arcgis.gis import GIS
32
+ from arcgis.gis import Item
33
+
34
+ from gitmap_core.repository import Repository
35
+
36
+
37
+ # ---- Constants ----------------------------------------------------------------------------------------------
38
+
39
+
40
+ GITMAP_META_TITLE = ".gitmap_meta"
41
+ GITMAP_FOLDER_SUFFIX = "_GitMap"
42
+
43
+
44
+ # ---- Remote Operations Class --------------------------------------------------------------------------------
45
+
46
+
47
+ class RemoteOperations:
48
+ """Handles remote repository operations.
49
+
50
+ Provides methods for pushing branches to Portal and pulling
51
+ updates from Portal.
52
+
53
+ Attributes:
54
+ repo: Local Repository instance.
55
+ connection: Portal connection.
56
+ config: Repository configuration.
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ repo: Repository,
62
+ connection: PortalConnection,
63
+ ) -> None:
64
+ """Initialize remote operations.
65
+
66
+ Args:
67
+ repo: Local repository.
68
+ connection: Authenticated Portal connection.
69
+ """
70
+ self.repo = repo
71
+ self.connection = connection
72
+ self.config = repo.get_config()
73
+
74
+ @property
75
+ def gis(
76
+ self,
77
+ ) -> GIS:
78
+ """Get GIS connection."""
79
+ return self.connection.gis
80
+
81
+ @property
82
+ def remote(
83
+ self,
84
+ ) -> Remote | None:
85
+ """Get configured remote."""
86
+ return self.config.remote
87
+
88
+ # ---- Folder Management ----------------------------------------------------------------------------------
89
+
90
+ def get_or_create_folder(
91
+ self,
92
+ ) -> str:
93
+ """Get or create the GitMap folder in Portal.
94
+
95
+ Returns:
96
+ Folder ID.
97
+
98
+ Raises:
99
+ RuntimeError: If folder creation fails.
100
+ """
101
+ folder_name = self.config.project_name
102
+
103
+ # Check if folder_id is already stored in config (from previous push)
104
+ if self.remote and self.remote.folder_id:
105
+ return self.remote.folder_id
106
+
107
+ try:
108
+ # Check existing folders first using compat layer
109
+ folders = get_user_folders(self.gis)
110
+
111
+ # Search for existing folder (case-insensitive)
112
+ for folder in folders:
113
+ folder_title = folder.get("title") or ""
114
+ if folder_title.lower() == folder_name.lower():
115
+ folder_id = folder.get("id")
116
+ if folder_id:
117
+ return folder_id
118
+
119
+ # Try to get folder by searching user's content
120
+ # Sometimes folders aren't in user.folders but exist
121
+ user = self.gis.users.me
122
+ try:
123
+ user_content = user.items()
124
+ for item in user_content:
125
+ # Check if item is in a folder with matching name
126
+ item_folder = getattr(item, "ownerFolder", None)
127
+ if item_folder:
128
+ # Get folder info
129
+ folder_info = self.gis.content.get_folder(item_folder, user.username)
130
+ if folder_info:
131
+ folder_title = getattr(folder_info, "title", None) or (folder_info.get("title") if isinstance(folder_info, dict) else None)
132
+ if folder_title == folder_name:
133
+ folder_id = getattr(folder_info, "id", None) or (folder_info.get("id") if isinstance(folder_info, dict) else None)
134
+ if folder_id:
135
+ return folder_id
136
+ except Exception:
137
+ # If folder search fails, continue to creation
138
+ pass
139
+
140
+ # Create new folder using compat layer (handles API differences)
141
+ try:
142
+ result = compat_create_folder(self.gis, folder_name)
143
+ if result:
144
+ folder_id = result.get("id", "")
145
+ if folder_id:
146
+ return folder_id
147
+ # If result is falsy or has no id, raise to trigger fallback search
148
+ msg = f"Folder creation returned no ID for '{folder_name}'"
149
+ raise RuntimeError(msg)
150
+ except Exception as create_error:
151
+ # If creation fails because folder exists, search one more time
152
+ error_msg = str(create_error).lower()
153
+ if "not available" in error_msg or "already exists" in error_msg or "unable to create" in error_msg:
154
+ # Folder exists but we didn't find it - search all folders again
155
+ folders = get_user_folders(self.gis)
156
+ for folder in folders:
157
+ folder_title = folder.get("title") or ""
158
+ if folder_title.lower() == folder_name.lower():
159
+ folder_id = folder.get("id")
160
+ if folder_id:
161
+ return folder_id
162
+
163
+ # Try searching through user's items to find the folder
164
+ try:
165
+ user_items = user.items()
166
+ seen_folders: set[str] = set()
167
+ for item in user_items:
168
+ item_folder = getattr(item, "ownerFolder", None)
169
+ if item_folder and item_folder not in seen_folders:
170
+ seen_folders.add(item_folder)
171
+ try:
172
+ folder_obj = self.gis.content.get_folder(item_folder, user.username)
173
+ if folder_obj:
174
+ folder_title = getattr(folder_obj, "title", None)
175
+ if folder_title == folder_name:
176
+ folder_id = getattr(folder_obj, "id", None)
177
+ if folder_id:
178
+ return folder_id
179
+ except Exception:
180
+ continue
181
+ except Exception:
182
+ pass
183
+
184
+ # If still not found, the folder exists but we can't locate it
185
+ msg = f"Folder '{folder_name}' exists in Portal but could not be located automatically. Please check Portal and update config manually if needed."
186
+ raise RuntimeError(msg) from create_error
187
+ else:
188
+ # Different error - re-raise
189
+ msg = f"Failed to create folder '{folder_name}': {create_error}"
190
+ raise RuntimeError(msg) from create_error
191
+
192
+ except Exception as folder_error:
193
+ msg = f"Folder operation failed: {folder_error}"
194
+ raise RuntimeError(msg) from folder_error
195
+
196
+ # ---- Push Operations ------------------------------------------------------------------------------------
197
+
198
+ def push(
199
+ self,
200
+ branch: str | None = None,
201
+ skip_notifications: bool = False,
202
+ ) -> tuple[Item, dict[str, Any]]:
203
+ """Push branch to Portal.
204
+
205
+ Creates or updates a web map item in the GitMap folder
206
+ representing the specified branch.
207
+
208
+ Args:
209
+ branch: Branch name (defaults to current branch).
210
+ skip_notifications: If True, skip sending notifications even for production branch.
211
+
212
+ Returns:
213
+ Tuple of (created/updated Portal Item, notification status dict).
214
+
215
+ Raises:
216
+ RuntimeError: If push fails.
217
+ """
218
+ try:
219
+ branch = branch or self.repo.get_current_branch()
220
+ if not branch:
221
+ msg = "No branch to push (detached HEAD)"
222
+ raise RuntimeError(msg)
223
+
224
+ commit_id = self.repo.get_branch_commit(branch)
225
+ if not commit_id:
226
+ msg = f"Branch '{branch}' has no commits"
227
+ raise RuntimeError(msg)
228
+
229
+ commit = self.repo.get_commit(commit_id)
230
+ if not commit:
231
+ msg = f"Commit '{commit_id}' not found"
232
+ raise RuntimeError(msg)
233
+
234
+ # For main branch, if we have the original item_id, update it directly
235
+ if branch == "main" and self.remote and self.remote.item_id:
236
+ try:
237
+ original_item = self.gis.content.get(self.remote.item_id)
238
+ if original_item and original_item.type == "Web Map":
239
+ updated_item = self._update_webmap_item(original_item, commit.map_data, commit)
240
+
241
+ # Check if this is the production branch and send notifications
242
+ notification_status = {
243
+ "attempted": False,
244
+ "sent": False,
245
+ "reason": "",
246
+ "users_notified": [],
247
+ }
248
+
249
+ if not skip_notifications and self.remote.production_branch and branch == self.remote.production_branch:
250
+ notification_status["attempted"] = True
251
+ try:
252
+ # Check if item is shared with groups
253
+ # item.sharing is a SharingManager object, not a dict
254
+ if updated_item.access == "private":
255
+ notification_status["reason"] = "Item is private (not shared)"
256
+ else:
257
+ # Try to get groups from item properties
258
+ groups = []
259
+ try:
260
+ if hasattr(updated_item, "properties") and updated_item.properties:
261
+ sharing_data = updated_item.properties.get("sharing", {})
262
+ if isinstance(sharing_data, dict):
263
+ groups = sharing_data.get("groups", [])
264
+
265
+ # If not found, query user's groups
266
+ if not groups:
267
+ user = self.gis.users.me
268
+ if user:
269
+ user_groups = user.groups
270
+ for group in user_groups:
271
+ try:
272
+ group_items = group.content()
273
+ if any(g_item.id == updated_item.id for g_item in group_items):
274
+ groups.append(group.id)
275
+ except Exception:
276
+ continue
277
+ except Exception:
278
+ groups = []
279
+
280
+ if not groups:
281
+ notification_status["reason"] = "Item is not shared with any groups"
282
+ else:
283
+ # Attempt to send notifications
284
+ notified_users = notify_item_group_users(
285
+ gis=self.gis,
286
+ item=updated_item,
287
+ subject=f"Production Map Updated: {updated_item.title}",
288
+ body=f"The production map '{updated_item.title}' has been updated.\n\n"
289
+ f"Branch: {branch}\n"
290
+ f"Commit: {commit.id[:8]}\n"
291
+ f"Message: {commit.message}\n\n"
292
+ f"View the map: {updated_item.homepage}",
293
+ )
294
+ if notified_users:
295
+ notification_status["sent"] = True
296
+ notification_status["users_notified"] = notified_users
297
+ else:
298
+ notification_status["reason"] = "No users found in groups that have access to the map"
299
+ except Exception as notify_error:
300
+ # Don't fail the push if notifications fail
301
+ notification_status["reason"] = f"Notification error: {notify_error}"
302
+
303
+ return updated_item, notification_status
304
+ except Exception:
305
+ # Original item not found - fall through to folder-based logic
306
+ pass
307
+
308
+ # Check for existing branch item in user's root content (no folder)
309
+ existing_item = self._find_branch_item_in_root(branch)
310
+
311
+ if existing_item:
312
+ # Update existing item
313
+ updated_item = self._update_webmap_item(existing_item, commit.map_data, commit)
314
+ else:
315
+ # Create new item in root content (no folder)
316
+ updated_item = self._create_webmap_item_in_root(branch, commit.map_data, commit)
317
+
318
+ # Check if this is the production branch and send notifications
319
+ notification_status = {
320
+ "attempted": False,
321
+ "sent": False,
322
+ "reason": "",
323
+ "users_notified": [],
324
+ }
325
+
326
+ if not skip_notifications and self.remote and self.remote.production_branch and branch == self.remote.production_branch:
327
+ notification_status["attempted"] = True
328
+ try:
329
+ # Check if item is shared with groups
330
+ # item.sharing is a SharingManager object, not a dict
331
+ if updated_item.access == "private":
332
+ notification_status["reason"] = "Item is private (not shared)"
333
+ else:
334
+ # Try to get groups from item properties
335
+ groups = []
336
+ try:
337
+ if hasattr(updated_item, "properties") and updated_item.properties:
338
+ sharing_data = updated_item.properties.get("sharing", {})
339
+ if isinstance(sharing_data, dict):
340
+ groups = sharing_data.get("groups", [])
341
+
342
+ # If not found, query user's groups
343
+ if not groups:
344
+ user = self.gis.users.me
345
+ if user:
346
+ user_groups = user.groups
347
+ for group in user_groups:
348
+ try:
349
+ group_items = group.content()
350
+ if any(g_item.id == updated_item.id for g_item in group_items):
351
+ groups.append(group.id)
352
+ except Exception:
353
+ continue
354
+ except Exception:
355
+ groups = []
356
+
357
+ if not groups:
358
+ notification_status["reason"] = "Item is not shared with any groups"
359
+ else:
360
+ # Attempt to send notifications
361
+ notified_users = notify_item_group_users(
362
+ gis=self.gis,
363
+ item=updated_item,
364
+ subject=f"Production Map Updated: {updated_item.title}",
365
+ body=f"The production map '{updated_item.title}' has been updated.\n\n"
366
+ f"Branch: {branch}\n"
367
+ f"Commit: {commit.id[:8]}\n"
368
+ f"Message: {commit.message}\n\n"
369
+ f"View the map: {updated_item.homepage}",
370
+ )
371
+ if notified_users:
372
+ notification_status["sent"] = True
373
+ notification_status["users_notified"] = notified_users
374
+ else:
375
+ notification_status["reason"] = "No users found in groups that have access to the map"
376
+ except Exception as notify_error:
377
+ # Don't fail the push if notifications fail
378
+ notification_status["reason"] = f"Notification error: {notify_error}"
379
+
380
+ return updated_item, notification_status
381
+
382
+ except Exception as push_error:
383
+ msg = f"Push failed: {push_error}"
384
+ raise RuntimeError(msg) from push_error
385
+
386
+ def _find_branch_item(
387
+ self,
388
+ branch: str,
389
+ folder_id: str,
390
+ ) -> Item | None:
391
+ """Find existing web map item for branch.
392
+
393
+ Args:
394
+ branch: Branch name.
395
+ folder_id: Portal folder ID.
396
+
397
+ Returns:
398
+ Item if found, None otherwise.
399
+ """
400
+ try:
401
+ user = self.gis.users.me
402
+ items = user.items(folder=folder_id)
403
+
404
+ item_title = self._branch_to_item_title(branch)
405
+ for item in items:
406
+ if item.title == item_title and item.type == "Web Map":
407
+ return item
408
+
409
+ return None
410
+
411
+ except Exception:
412
+ return None
413
+
414
+ def _branch_to_item_title(
415
+ self,
416
+ branch: str,
417
+ ) -> str:
418
+ """Convert branch name to Portal item title.
419
+
420
+ Args:
421
+ branch: Branch name.
422
+
423
+ Returns:
424
+ Sanitized item title.
425
+ """
426
+ # Replace slashes with underscores for Portal compatibility
427
+ return branch.replace("/", "_")
428
+
429
+ def _find_branch_item_in_root(
430
+ self,
431
+ branch: str,
432
+ ) -> Item | None:
433
+ """Find existing web map item for branch in user's root content.
434
+
435
+ Args:
436
+ branch: Branch name.
437
+
438
+ Returns:
439
+ Item if found, None otherwise.
440
+ """
441
+ try:
442
+ user = self.gis.users.me
443
+ # Get items from root (no folder)
444
+ items = user.items()
445
+
446
+ item_title = self._branch_to_item_title(branch)
447
+ # Also check with project name prefix for uniqueness
448
+ prefixed_title = f"{self.config.project_name}_{item_title}"
449
+
450
+ for item in items:
451
+ if item.type == "Web Map":
452
+ if item.title == prefixed_title or item.title == item_title:
453
+ # Verify it's a GitMap item by checking tags
454
+ if "GitMap" in (item.tags or []):
455
+ return item
456
+
457
+ return None
458
+
459
+ except Exception:
460
+ return None
461
+
462
+ def _create_webmap_item_in_root(
463
+ self,
464
+ branch: str,
465
+ map_data: dict[str, Any],
466
+ commit: Any,
467
+ ) -> Item:
468
+ """Create new web map item in user's root content (no folder).
469
+
470
+ Args:
471
+ branch: Branch name.
472
+ map_data: Web map JSON.
473
+ commit: Commit object.
474
+
475
+ Returns:
476
+ Created Item.
477
+ """
478
+ item_title = self._branch_to_item_title(branch)
479
+ # Prefix with project name to avoid collisions
480
+ prefixed_title = f"{self.config.project_name}_{item_title}"
481
+
482
+ item_properties = {
483
+ "title": prefixed_title,
484
+ "type": "Web Map",
485
+ "tags": ["GitMap", f"project:{self.config.project_name}", f"branch:{branch}", f"commit:{commit.id[:8]}"],
486
+ "description": f"GitMap project: {self.config.project_name}\nBranch: {branch}\nCommit: {commit.id}\n{commit.message}",
487
+ }
488
+
489
+ # Create in root content (no folder parameter)
490
+ item = self.gis.content.add(
491
+ item_properties=item_properties,
492
+ data=json.dumps(map_data),
493
+ )
494
+
495
+ return item
496
+
497
+ def _create_webmap_item(
498
+ self,
499
+ branch: str,
500
+ map_data: dict[str, Any],
501
+ commit: Any,
502
+ folder_id: str,
503
+ ) -> Item:
504
+ """Create new web map item in Portal folder.
505
+
506
+ Args:
507
+ branch: Branch name.
508
+ map_data: Web map JSON.
509
+ commit: Commit object.
510
+ folder_id: Target folder ID.
511
+
512
+ Returns:
513
+ Created Item.
514
+ """
515
+ item_title = self._branch_to_item_title(branch)
516
+
517
+ item_properties = {
518
+ "title": item_title,
519
+ "type": "Web Map",
520
+ "tags": ["GitMap", f"branch:{branch}", f"commit:{commit.id[:8]}"],
521
+ "description": f"GitMap branch: {branch}\nCommit: {commit.id}\n{commit.message}",
522
+ }
523
+
524
+ # Try new folder-based API first (2.3.0+), fall back to legacy
525
+ try:
526
+ folder = self.gis.content.folders.get(folder_id)
527
+ item = folder.add(
528
+ item_properties=item_properties,
529
+ text=json.dumps(map_data),
530
+ )
531
+ except (AttributeError, TypeError):
532
+ # Fallback for older API versions
533
+ item = self.gis.content.add(
534
+ item_properties=item_properties,
535
+ data=json.dumps(map_data),
536
+ folder=folder_id,
537
+ )
538
+
539
+ return item
540
+
541
+ def _update_webmap_item(
542
+ self,
543
+ item: Item,
544
+ map_data: dict[str, Any],
545
+ commit: Any,
546
+ ) -> Item:
547
+ """Update existing web map item.
548
+
549
+ Args:
550
+ item: Existing Portal item.
551
+ map_data: New web map JSON.
552
+ commit: Commit object.
553
+
554
+ Returns:
555
+ Updated Item.
556
+ """
557
+ # Update item properties
558
+ item.update(
559
+ item_properties={
560
+ "tags": item.tags + [f"commit:{commit.id[:8]}"],
561
+ "description": f"GitMap commit: {commit.id}\n{commit.message}",
562
+ },
563
+ data=json.dumps(map_data),
564
+ )
565
+
566
+ return item
567
+
568
+ # ---- Pull Operations ------------------------------------------------------------------------------------
569
+
570
+ def pull(
571
+ self,
572
+ branch: str | None = None,
573
+ ) -> dict[str, Any]:
574
+ """Pull latest from Portal.
575
+
576
+ Fetches the web map JSON from Portal for the specified branch
577
+ and updates the local staging area.
578
+
579
+ Args:
580
+ branch: Branch name (defaults to current branch).
581
+
582
+ Returns:
583
+ Pulled map data.
584
+
585
+ Raises:
586
+ RuntimeError: If pull fails.
587
+ """
588
+ try:
589
+ branch = branch or self.repo.get_current_branch()
590
+ if not branch:
591
+ msg = "No branch to pull (detached HEAD)"
592
+ raise RuntimeError(msg)
593
+
594
+ if not self.remote:
595
+ msg = "No remote configured"
596
+ raise RuntimeError(msg)
597
+
598
+ # For main branch, if we have the original item_id, pull from it directly
599
+ if branch == "main" and self.remote.item_id:
600
+ try:
601
+ original_item = self.gis.content.get(self.remote.item_id)
602
+ if original_item and original_item.type == "Web Map":
603
+ map_data = original_item.get_data()
604
+ if not map_data:
605
+ msg = "Failed to get data from remote item"
606
+ raise RuntimeError(msg)
607
+
608
+ # Update local index
609
+ self.repo.update_index(map_data)
610
+
611
+ # Update remote tracking ref
612
+ self._update_remote_ref(branch, self.repo.get_head_commit() or "")
613
+
614
+ return map_data
615
+ except Exception:
616
+ # Original item not found - fall through to folder-based logic
617
+ pass
618
+
619
+ folder_id = self.remote.folder_id
620
+ if not folder_id:
621
+ msg = "Remote folder not configured"
622
+ raise RuntimeError(msg)
623
+
624
+ # Find branch item
625
+ item = self._find_branch_item(branch, folder_id)
626
+ if not item:
627
+ msg = f"Branch '{branch}' not found in remote"
628
+ raise RuntimeError(msg)
629
+
630
+ # Get map data
631
+ map_data = item.get_data()
632
+ if not map_data:
633
+ msg = f"Failed to get data from remote item"
634
+ raise RuntimeError(msg)
635
+
636
+ # Update local index
637
+ self.repo.update_index(map_data)
638
+
639
+ # Update remote tracking ref
640
+ self._update_remote_ref(branch, self.repo.get_head_commit() or "")
641
+
642
+ return map_data
643
+
644
+ except Exception as pull_error:
645
+ msg = f"Pull failed: {pull_error}"
646
+ raise RuntimeError(msg) from pull_error
647
+
648
+ def _update_remote_ref(
649
+ self,
650
+ branch: str,
651
+ commit_id: str,
652
+ ) -> None:
653
+ """Update remote tracking reference.
654
+
655
+ Args:
656
+ branch: Branch name.
657
+ commit_id: Commit ID.
658
+ """
659
+ remote_ref_dir = self.repo.remotes_dir / "origin"
660
+ remote_ref_dir.mkdir(parents=True, exist_ok=True)
661
+
662
+ ref_path = remote_ref_dir / branch.replace("/", "_")
663
+ ref_path.write_text(commit_id)
664
+
665
+ # ---- Metadata Operations --------------------------------------------------------------------------------
666
+
667
+ def push_metadata(
668
+ self,
669
+ ) -> Item:
670
+ """Push repository metadata to Portal.
671
+
672
+ Creates/updates the .gitmap_meta item containing branch
673
+ and commit information.
674
+
675
+ Returns:
676
+ Metadata Item.
677
+ """
678
+ folder_id = self.get_or_create_folder()
679
+
680
+ metadata = {
681
+ "version": "1.0",
682
+ "project_name": self.config.project_name,
683
+ "branches": {},
684
+ }
685
+
686
+ # Collect branch info
687
+ for branch in self.repo.list_branches():
688
+ commit_id = self.repo.get_branch_commit(branch)
689
+ metadata["branches"][branch] = {
690
+ "commit_id": commit_id,
691
+ }
692
+
693
+ # Find or create metadata item
694
+ existing = self._find_metadata_item(folder_id)
695
+
696
+ if existing:
697
+ existing.update(data=json.dumps(metadata))
698
+ return existing
699
+ else:
700
+ item_properties = {
701
+ "title": GITMAP_META_TITLE,
702
+ "type": "Code Attachment",
703
+ "tags": ["GitMap", "metadata"],
704
+ }
705
+ return self.gis.content.add(
706
+ item_properties=item_properties,
707
+ data=json.dumps(metadata),
708
+ folder=folder_id,
709
+ )
710
+
711
+ def _find_metadata_item(
712
+ self,
713
+ folder_id: str,
714
+ ) -> Item | None:
715
+ """Find metadata item in folder."""
716
+ try:
717
+ user = self.gis.users.me
718
+ items = user.items(folder=folder_id)
719
+
720
+ for item in items:
721
+ if item.title == GITMAP_META_TITLE:
722
+ return item
723
+
724
+ return None
725
+ except Exception:
726
+ return None
727
+
728
+