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/diff.py ADDED
@@ -0,0 +1,283 @@
1
+ """JSON diffing and comparison module.
2
+
3
+ Provides utilities for comparing web map JSON structures,
4
+ detecting changes at the layer and property level.
5
+
6
+ Execution Context:
7
+ Library module - imported by CLI diff and merge commands
8
+
9
+ Dependencies:
10
+ - deepdiff: Deep dictionary comparison
11
+
12
+ Metadata:
13
+ Version: 0.1.0
14
+ Author: GitMap Team
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass
19
+ from dataclasses import field
20
+ from typing import Any
21
+
22
+ from deepdiff import DeepDiff
23
+
24
+
25
+ # ---- Data Classes -------------------------------------------------------------------------------------------
26
+
27
+
28
+ @dataclass
29
+ class LayerChange:
30
+ """Represents a change to a single layer.
31
+
32
+ Attributes:
33
+ layer_id: ID of the changed layer.
34
+ layer_title: Title of the layer.
35
+ change_type: Type of change (added, removed, modified).
36
+ details: Detailed change information.
37
+ """
38
+
39
+ layer_id: str
40
+ layer_title: str
41
+ change_type: str # 'added', 'removed', 'modified'
42
+ details: dict[str, Any] = field(default_factory=dict)
43
+
44
+
45
+ @dataclass
46
+ class MapDiff:
47
+ """Represents differences between two map states.
48
+
49
+ Attributes:
50
+ layer_changes: List of layer-level changes.
51
+ table_changes: List of table-level changes.
52
+ property_changes: Map-level property changes.
53
+ has_changes: Whether any changes were detected.
54
+ """
55
+
56
+ layer_changes: list[LayerChange] = field(default_factory=list)
57
+ table_changes: list[LayerChange] = field(default_factory=list)
58
+ property_changes: dict[str, Any] = field(default_factory=dict)
59
+
60
+ @property
61
+ def has_changes(
62
+ self,
63
+ ) -> bool:
64
+ """Check if any changes exist."""
65
+ return bool(self.layer_changes or self.table_changes or self.property_changes)
66
+
67
+ @property
68
+ def added_layers(
69
+ self,
70
+ ) -> list[LayerChange]:
71
+ """Get added layers."""
72
+ return [c for c in self.layer_changes if c.change_type == "added"]
73
+
74
+ @property
75
+ def removed_layers(
76
+ self,
77
+ ) -> list[LayerChange]:
78
+ """Get removed layers."""
79
+ return [c for c in self.layer_changes if c.change_type == "removed"]
80
+
81
+ @property
82
+ def modified_layers(
83
+ self,
84
+ ) -> list[LayerChange]:
85
+ """Get modified layers."""
86
+ return [c for c in self.layer_changes if c.change_type == "modified"]
87
+
88
+ @property
89
+ def added_tables(
90
+ self,
91
+ ) -> list[LayerChange]:
92
+ """Get added tables."""
93
+ return [c for c in self.table_changes if c.change_type == "added"]
94
+
95
+ @property
96
+ def removed_tables(
97
+ self,
98
+ ) -> list[LayerChange]:
99
+ """Get removed tables."""
100
+ return [c for c in self.table_changes if c.change_type == "removed"]
101
+
102
+ @property
103
+ def modified_tables(
104
+ self,
105
+ ) -> list[LayerChange]:
106
+ """Get modified tables."""
107
+ return [c for c in self.table_changes if c.change_type == "modified"]
108
+
109
+
110
+ # ---- Diff Functions -----------------------------------------------------------------------------------------
111
+
112
+
113
+ def diff_maps(
114
+ map1: dict[str, Any],
115
+ map2: dict[str, Any],
116
+ ) -> MapDiff:
117
+ """Compare two web map JSON structures.
118
+
119
+ Args:
120
+ map1: First map (typically current/index state).
121
+ map2: Second map (typically committed state).
122
+
123
+ Returns:
124
+ MapDiff object describing all differences.
125
+ """
126
+ result = MapDiff()
127
+
128
+ # Compare operational layers
129
+ layers1 = map1.get("operationalLayers", [])
130
+ layers2 = map2.get("operationalLayers", [])
131
+
132
+ layer_changes = diff_layers(layers1, layers2)
133
+ result.layer_changes = layer_changes
134
+
135
+ # Compare tables (same structure as layers)
136
+ tables1 = map1.get("tables", [])
137
+ tables2 = map2.get("tables", [])
138
+
139
+ table_changes = diff_layers(tables1, tables2)
140
+ result.table_changes = table_changes
141
+
142
+ # Compare map-level properties (excluding layers and tables)
143
+ map1_props = {k: v for k, v in map1.items() if k not in ("operationalLayers", "tables")}
144
+ map2_props = {k: v for k, v in map2.items() if k not in ("operationalLayers", "tables")}
145
+
146
+ if map1_props != map2_props:
147
+ deep_diff = DeepDiff(map2_props, map1_props, ignore_order=True)
148
+ result.property_changes = deep_diff.to_dict() if deep_diff else {}
149
+
150
+ return result
151
+
152
+
153
+ def diff_layers(
154
+ layers1: list[dict[str, Any]],
155
+ layers2: list[dict[str, Any]],
156
+ ) -> list[LayerChange]:
157
+ """Compare two lists of operational layers.
158
+
159
+ Args:
160
+ layers1: First layer list (current state).
161
+ layers2: Second layer list (previous state).
162
+
163
+ Returns:
164
+ List of LayerChange objects.
165
+ """
166
+ changes = []
167
+
168
+ # Index layers by ID
169
+ index1 = {layer.get("id"): layer for layer in layers1 if layer.get("id")}
170
+ index2 = {layer.get("id"): layer for layer in layers2 if layer.get("id")}
171
+
172
+ # Find added layers (in layers1 but not layers2)
173
+ for layer_id, layer in index1.items():
174
+ if layer_id not in index2:
175
+ changes.append(LayerChange(
176
+ layer_id=str(layer_id),
177
+ layer_title=layer.get("title", "Untitled"),
178
+ change_type="added",
179
+ ))
180
+
181
+ # Find removed layers (in layers2 but not layers1)
182
+ for layer_id, layer in index2.items():
183
+ if layer_id not in index1:
184
+ changes.append(LayerChange(
185
+ layer_id=str(layer_id),
186
+ layer_title=layer.get("title", "Untitled"),
187
+ change_type="removed",
188
+ ))
189
+
190
+ # Find modified layers (in both but different)
191
+ for layer_id in index1:
192
+ if layer_id in index2:
193
+ layer1 = index1[layer_id]
194
+ layer2 = index2[layer_id]
195
+
196
+ if layer1 != layer2:
197
+ deep_diff = DeepDiff(layer2, layer1, ignore_order=True)
198
+ changes.append(LayerChange(
199
+ layer_id=str(layer_id),
200
+ layer_title=layer1.get("title", "Untitled"),
201
+ change_type="modified",
202
+ details=deep_diff.to_dict() if deep_diff else {},
203
+ ))
204
+
205
+ return changes
206
+
207
+
208
+ def diff_json(
209
+ obj1: Any,
210
+ obj2: Any,
211
+ ignore_order: bool = True,
212
+ ) -> dict[str, Any]:
213
+ """Generic JSON diff using DeepDiff.
214
+
215
+ Args:
216
+ obj1: First object (current state).
217
+ obj2: Second object (previous state).
218
+ ignore_order: Whether to ignore list ordering.
219
+
220
+ Returns:
221
+ Dictionary of differences.
222
+ """
223
+ deep_diff = DeepDiff(obj2, obj1, ignore_order=ignore_order)
224
+ return deep_diff.to_dict() if deep_diff else {}
225
+
226
+
227
+ # ---- Formatting Functions -----------------------------------------------------------------------------------
228
+
229
+
230
+ def format_diff_summary(
231
+ map_diff: MapDiff,
232
+ ) -> str:
233
+ """Format MapDiff as human-readable summary.
234
+
235
+ Args:
236
+ map_diff: MapDiff object.
237
+
238
+ Returns:
239
+ Formatted string summary.
240
+ """
241
+ if not map_diff.has_changes:
242
+ return "No changes detected."
243
+
244
+ lines = []
245
+
246
+ if map_diff.added_layers:
247
+ lines.append(f"Added layers ({len(map_diff.added_layers)}):")
248
+ for change in map_diff.added_layers:
249
+ lines.append(f" + {change.layer_title} ({change.layer_id})")
250
+
251
+ if map_diff.removed_layers:
252
+ lines.append(f"Removed layers ({len(map_diff.removed_layers)}):")
253
+ for change in map_diff.removed_layers:
254
+ lines.append(f" - {change.layer_title} ({change.layer_id})")
255
+
256
+ if map_diff.modified_layers:
257
+ lines.append(f"Modified layers ({len(map_diff.modified_layers)}):")
258
+ for change in map_diff.modified_layers:
259
+ lines.append(f" ~ {change.layer_title} ({change.layer_id})")
260
+
261
+ if map_diff.added_tables:
262
+ lines.append(f"Added tables ({len(map_diff.added_tables)}):")
263
+ for change in map_diff.added_tables:
264
+ lines.append(f" + {change.layer_title} ({change.layer_id})")
265
+
266
+ if map_diff.removed_tables:
267
+ lines.append(f"Removed tables ({len(map_diff.removed_tables)}):")
268
+ for change in map_diff.removed_tables:
269
+ lines.append(f" - {change.layer_title} ({change.layer_id})")
270
+
271
+ if map_diff.modified_tables:
272
+ lines.append(f"Modified tables ({len(map_diff.modified_tables)}):")
273
+ for change in map_diff.modified_tables:
274
+ lines.append(f" ~ {change.layer_title} ({change.layer_id})")
275
+
276
+ if map_diff.property_changes:
277
+ lines.append("Map properties changed:")
278
+ for key in map_diff.property_changes:
279
+ lines.append(f" * {key}")
280
+
281
+ return "\n".join(lines)
282
+
283
+
gitmap_core/maps.py ADDED
@@ -0,0 +1,385 @@
1
+ """Web map JSON operations module.
2
+
3
+ Handles extraction, serialization, and staging of ArcGIS web map JSON
4
+ data for version control operations.
5
+
6
+ Execution Context:
7
+ Library module - imported by CLI commands and remote operations
8
+
9
+ Dependencies:
10
+ - arcgis: Web map item access
11
+ - gitmap_core.models: Data models
12
+
13
+ Metadata:
14
+ Version: 0.1.0
15
+ Author: GitMap Team
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING
22
+ from typing import Any
23
+
24
+ if TYPE_CHECKING:
25
+ from arcgis.gis import GIS
26
+ from arcgis.gis import Item
27
+
28
+
29
+ # ---- Map Extraction Functions -------------------------------------------------------------------------------
30
+
31
+
32
+ def get_webmap_json(
33
+ item: Item,
34
+ ) -> dict[str, Any]:
35
+ """Extract web map JSON from Portal item.
36
+
37
+ Args:
38
+ item: ArcGIS web map item.
39
+
40
+ Returns:
41
+ Web map JSON as dictionary.
42
+
43
+ Raises:
44
+ RuntimeError: If item is not a web map or extraction fails.
45
+ """
46
+ try:
47
+ if item.type != "Web Map":
48
+ msg = f"Item '{item.title}' is not a Web Map (type: {item.type})"
49
+ raise RuntimeError(msg)
50
+
51
+ # Get the web map data
52
+ webmap_data = item.get_data()
53
+
54
+ if not webmap_data:
55
+ msg = f"Failed to get data from web map '{item.title}'"
56
+ raise RuntimeError(msg)
57
+
58
+ return webmap_data
59
+
60
+ except Exception as extraction_error:
61
+ if isinstance(extraction_error, RuntimeError):
62
+ raise
63
+ msg = f"Failed to extract web map JSON: {extraction_error}"
64
+ raise RuntimeError(msg) from extraction_error
65
+
66
+
67
+ def get_webmap_by_id(
68
+ gis: GIS,
69
+ item_id: str,
70
+ ) -> tuple[Item, dict[str, Any]]:
71
+ """Fetch web map item and its JSON by item ID.
72
+
73
+ Args:
74
+ gis: Authenticated GIS connection.
75
+ item_id: Portal item ID.
76
+
77
+ Returns:
78
+ Tuple of (Item, web map JSON dict).
79
+
80
+ Raises:
81
+ RuntimeError: If item not found or is not a web map.
82
+ """
83
+ try:
84
+ item = gis.content.get(item_id)
85
+
86
+ if not item:
87
+ msg = f"Item with ID '{item_id}' not found"
88
+ raise RuntimeError(msg)
89
+
90
+ webmap_json = get_webmap_json(item)
91
+ return item, webmap_json
92
+
93
+ except Exception as fetch_error:
94
+ if isinstance(fetch_error, RuntimeError):
95
+ raise
96
+ msg = f"Failed to fetch web map {item_id}: {fetch_error}"
97
+ raise RuntimeError(msg) from fetch_error
98
+
99
+
100
+ def list_webmaps(
101
+ gis: GIS,
102
+ query: str = "",
103
+ owner: str = "",
104
+ tag: str = "",
105
+ max_results: int = 100,
106
+ ) -> list[dict[str, str]]:
107
+ """List available web maps from the Portal.
108
+
109
+ Args:
110
+ gis: Authenticated GIS connection.
111
+ query: Optional search query string to filter web maps (e.g., "title:MyMap").
112
+ Defaults to empty string to list all web maps.
113
+ owner: Optional owner username to filter web maps.
114
+ tag: Optional tag to filter web maps.
115
+ max_results: Maximum number of web maps to return (default: 100).
116
+
117
+ Returns:
118
+ List of dictionaries containing web map information (id, title, owner, type).
119
+
120
+ Raises:
121
+ RuntimeError: If web map search fails.
122
+ """
123
+ try:
124
+ # Build search query components
125
+ query_parts = ['type:"Web Map"']
126
+
127
+ if owner:
128
+ query_parts.append(f'owner:{owner}')
129
+
130
+ if tag:
131
+ query_parts.append(f'tags:{tag}')
132
+
133
+ if query:
134
+ query_parts.append(query)
135
+
136
+ # Combine all query parts with AND
137
+ search_query = ' AND '.join(query_parts)
138
+ items = gis.content.search(query=search_query, max_items=max_results)
139
+
140
+ result = []
141
+ for item in items:
142
+ result.append({
143
+ "id": getattr(item, "id", ""),
144
+ "title": getattr(item, "title", ""),
145
+ "owner": getattr(item, "owner", ""),
146
+ "type": getattr(item, "type", ""),
147
+ })
148
+ return result
149
+ except Exception as search_error:
150
+ msg = f"Failed to search web maps: {search_error}"
151
+ raise RuntimeError(msg) from search_error
152
+
153
+
154
+ def list_services(
155
+ gis: GIS,
156
+ query: str = "",
157
+ owner: str = "",
158
+ service_type: str = "",
159
+ max_results: int = 100,
160
+ ) -> list[dict[str, str]]:
161
+ """List available services (Feature Services, Map Services, etc.) from the Portal.
162
+
163
+ Args:
164
+ gis: Authenticated GIS connection.
165
+ query: Optional search query string to filter services.
166
+ owner: Optional owner username to filter services.
167
+ service_type: Optional service type filter (e.g., "Feature Service", "Map Service").
168
+ Defaults to empty string to list Feature Services.
169
+ max_results: Maximum number of services to return (default: 100).
170
+
171
+ Returns:
172
+ List of dictionaries containing service information (id, title, owner, type, url).
173
+
174
+ Raises:
175
+ RuntimeError: If service search fails.
176
+ """
177
+ try:
178
+ # Build search query components
179
+ # Default to Feature Service if no type specified
180
+ if service_type:
181
+ query_parts = [f'type:"{service_type}"']
182
+ else:
183
+ query_parts = ['type:"Feature Service"']
184
+
185
+ if owner:
186
+ query_parts.append(f'owner:{owner}')
187
+
188
+ if query:
189
+ query_parts.append(query)
190
+
191
+ # Combine all query parts with AND
192
+ search_query = ' AND '.join(query_parts)
193
+ items = gis.content.search(query=search_query, max_items=max_results)
194
+
195
+ result = []
196
+ for item in items:
197
+ result.append({
198
+ "id": getattr(item, "id", ""),
199
+ "title": getattr(item, "title", ""),
200
+ "owner": getattr(item, "owner", ""),
201
+ "type": getattr(item, "type", ""),
202
+ "url": getattr(item, "url", ""),
203
+ })
204
+ return result
205
+ except Exception as search_error:
206
+ msg = f"Failed to search services: {search_error}"
207
+ raise RuntimeError(msg) from search_error
208
+
209
+
210
+ # ---- Layer Operations ---------------------------------------------------------------------------------------
211
+
212
+
213
+ def get_operational_layers(
214
+ map_data: dict[str, Any],
215
+ ) -> list[dict[str, Any]]:
216
+ """Extract operational layers from web map JSON.
217
+
218
+ Args:
219
+ map_data: Web map JSON dictionary.
220
+
221
+ Returns:
222
+ List of operational layer dictionaries.
223
+ """
224
+ return map_data.get("operationalLayers", [])
225
+
226
+
227
+ def get_basemap_layers(
228
+ map_data: dict[str, Any],
229
+ ) -> list[dict[str, Any]]:
230
+ """Extract basemap layers from web map JSON.
231
+
232
+ Args:
233
+ map_data: Web map JSON dictionary.
234
+
235
+ Returns:
236
+ List of basemap layer dictionaries.
237
+ """
238
+ basemap = map_data.get("baseMap", {})
239
+ return basemap.get("baseMapLayers", [])
240
+
241
+
242
+ def get_layer_by_id(
243
+ map_data: dict[str, Any],
244
+ layer_id: str,
245
+ ) -> dict[str, Any] | None:
246
+ """Find a layer by its ID.
247
+
248
+ Args:
249
+ map_data: Web map JSON dictionary.
250
+ layer_id: Layer ID to find.
251
+
252
+ Returns:
253
+ Layer dictionary or None if not found.
254
+ """
255
+ for layer in get_operational_layers(map_data):
256
+ if layer.get("id") == layer_id:
257
+ return layer
258
+ return None
259
+
260
+
261
+ def get_layer_ids(
262
+ map_data: dict[str, Any],
263
+ ) -> list[str]:
264
+ """Get all operational layer IDs.
265
+
266
+ Args:
267
+ map_data: Web map JSON dictionary.
268
+
269
+ Returns:
270
+ List of layer IDs.
271
+ """
272
+ return [
273
+ layer.get("id", "")
274
+ for layer in get_operational_layers(map_data)
275
+ if layer.get("id")
276
+ ]
277
+
278
+
279
+ # ---- Map Comparison Functions -------------------------------------------------------------------------------
280
+
281
+
282
+ def compare_layers(
283
+ layers1: list[dict[str, Any]],
284
+ layers2: list[dict[str, Any]],
285
+ ) -> dict[str, Any]:
286
+ """Compare two lists of layers.
287
+
288
+ Args:
289
+ layers1: First layer list (e.g., from index).
290
+ layers2: Second layer list (e.g., from commit).
291
+
292
+ Returns:
293
+ Dictionary with added, removed, and modified layer info.
294
+ """
295
+ ids1 = {layer.get("id"): layer for layer in layers1 if layer.get("id")}
296
+ ids2 = {layer.get("id"): layer for layer in layers2 if layer.get("id")}
297
+
298
+ added = [ids1[id] for id in ids1 if id not in ids2]
299
+ removed = [ids2[id] for id in ids2 if id not in ids1]
300
+ modified = []
301
+
302
+ for layer_id in ids1:
303
+ if layer_id in ids2:
304
+ if ids1[layer_id] != ids2[layer_id]:
305
+ modified.append({
306
+ "id": layer_id,
307
+ "old": ids2[layer_id],
308
+ "new": ids1[layer_id],
309
+ })
310
+
311
+ return {
312
+ "added": added,
313
+ "removed": removed,
314
+ "modified": modified,
315
+ }
316
+
317
+
318
+ # ---- Serialization Functions --------------------------------------------------------------------------------
319
+
320
+
321
+ def save_map_json(
322
+ map_data: dict[str, Any],
323
+ filepath: Path,
324
+ ) -> None:
325
+ """Save web map JSON to file.
326
+
327
+ Args:
328
+ map_data: Web map JSON dictionary.
329
+ filepath: Path to save file.
330
+ """
331
+ filepath.write_text(json.dumps(map_data, indent=2))
332
+
333
+
334
+ def load_map_json(
335
+ filepath: Path,
336
+ ) -> dict[str, Any]:
337
+ """Load web map JSON from file.
338
+
339
+ Args:
340
+ filepath: Path to JSON file.
341
+
342
+ Returns:
343
+ Web map JSON dictionary.
344
+
345
+ Raises:
346
+ RuntimeError: If file cannot be loaded.
347
+ """
348
+ try:
349
+ return json.loads(filepath.read_text())
350
+ except Exception as load_error:
351
+ msg = f"Failed to load map JSON from {filepath}: {load_error}"
352
+ raise RuntimeError(msg) from load_error
353
+
354
+
355
+ # ---- Map Creation Functions ---------------------------------------------------------------------------------
356
+
357
+
358
+ def create_empty_webmap(
359
+ title: str = "New Map",
360
+ spatial_reference: int = 102100,
361
+ ) -> dict[str, Any]:
362
+ """Create an empty web map JSON structure.
363
+
364
+ Args:
365
+ title: Map title.
366
+ spatial_reference: Spatial reference WKID.
367
+
368
+ Returns:
369
+ Empty web map JSON dictionary.
370
+ """
371
+ return {
372
+ "operationalLayers": [],
373
+ "baseMap": {
374
+ "baseMapLayers": [],
375
+ "title": "Basemap",
376
+ },
377
+ "spatialReference": {
378
+ "wkid": spatial_reference,
379
+ },
380
+ "version": "2.28",
381
+ "authoringApp": "GitMap",
382
+ "authoringAppVersion": "0.1.0",
383
+ }
384
+
385
+