pyegp-parser 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.
Files changed (47) hide show
  1. pyegp_parser/__init__.py +356 -0
  2. pyegp_parser/archive.py +114 -0
  3. pyegp_parser/bulk.py +113 -0
  4. pyegp_parser/classifier.py +60 -0
  5. pyegp_parser/cli.py +195 -0
  6. pyegp_parser/dag.py +100 -0
  7. pyegp_parser/mcp_entry.py +31 -0
  8. pyegp_parser/mcp_server.py +457 -0
  9. pyegp_parser/models/__init__.py +105 -0
  10. pyegp_parser/models/base.py +38 -0
  11. pyegp_parser/models/bulk.py +69 -0
  12. pyegp_parser/models/data.py +59 -0
  13. pyegp_parser/models/elements.py +48 -0
  14. pyegp_parser/models/external_file.py +35 -0
  15. pyegp_parser/models/external_objects.py +32 -0
  16. pyegp_parser/models/log_code.py +43 -0
  17. pyegp_parser/models/process_flow.py +44 -0
  18. pyegp_parser/models/project.py +244 -0
  19. pyegp_parser/models/query.py +160 -0
  20. pyegp_parser/models/shortcut.py +48 -0
  21. pyegp_parser/models/tasks.py +89 -0
  22. pyegp_parser/models/visual_layout.py +111 -0
  23. pyegp_parser/parsers/__init__.py +15 -0
  24. pyegp_parser/parsers/data_parser.py +369 -0
  25. pyegp_parser/parsers/dna_parser.py +182 -0
  26. pyegp_parser/parsers/element_parser.py +198 -0
  27. pyegp_parser/parsers/external_objects_parser.py +119 -0
  28. pyegp_parser/parsers/layout_parser.py +208 -0
  29. pyegp_parser/parsers/log_code_parser.py +347 -0
  30. pyegp_parser/parsers/ods_parser.py +206 -0
  31. pyegp_parser/parsers/pfd_parser.py +152 -0
  32. pyegp_parser/parsers/project_parser.py +273 -0
  33. pyegp_parser/parsers/query_parser.py +510 -0
  34. pyegp_parser/parsers/shortcut_parser.py +83 -0
  35. pyegp_parser/parsers/task_parser.py +460 -0
  36. pyegp_parser/pretty_printer.py +385 -0
  37. pyegp_parser/py.typed +0 -0
  38. pyegp_parser/redaction.py +137 -0
  39. pyegp_parser/schema_generator.py +355 -0
  40. pyegp_parser/serializer.py +342 -0
  41. pyegp_parser/validator.py +56 -0
  42. pyegp_parser-0.1.0.dist-info/METADATA +254 -0
  43. pyegp_parser-0.1.0.dist-info/RECORD +47 -0
  44. pyegp_parser-0.1.0.dist-info/WHEEL +5 -0
  45. pyegp_parser-0.1.0.dist-info/entry_points.txt +3 -0
  46. pyegp_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
  47. pyegp_parser-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,356 @@
1
+ """EGP Parser — Parse SAS Enterprise Guide .egp project files into structured JSON."""
2
+
3
+ import datetime
4
+ import logging
5
+ import xml.etree.ElementTree as ET
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .models.bulk import BulkResult
10
+ from .models.log_code import LogElement
11
+ from .models.project import ParsedProject
12
+
13
+ __version__ = "0.1.0"
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def parse_file(
19
+ egp_path: str | Path,
20
+ output_dir: str | Path | None = None,
21
+ ) -> ParsedProject:
22
+ """Parse a single .egp file into a structured project object.
23
+
24
+ Opens the archive, parses project.xml, extracts all elements and artifacts,
25
+ validates completeness, and optionally writes JSON output.
26
+
27
+ Args:
28
+ egp_path: Path to the .egp file to parse.
29
+ output_dir: Optional directory for JSON output. If None, no file is written.
30
+
31
+ Returns:
32
+ A ParsedProject dataclass instance containing all extracted data.
33
+
34
+ Raises:
35
+ FileNotFoundError: If egp_path does not exist.
36
+ ValueError: If file is not a valid EGP archive.
37
+ """
38
+ from .archive import open_archive
39
+ from .models.elements import ElementCategory
40
+ from .models.process_flow import ProcessFlowContainer
41
+ from .models.project import ParsedProject, SourceInfo
42
+ from .parsers.data_parser import parse_data_list, parse_external_file_list
43
+ from .parsers.element_parser import parse_elements
44
+ from .parsers.external_objects_parser import parse_external_objects
45
+ from .parsers.layout_parser import parse_open_project_view, parse_visual_layout
46
+ from .parsers.log_code_parser import (
47
+ extract_execution_logs,
48
+ extract_project_log,
49
+ parse_code_element,
50
+ parse_log_element,
51
+ )
52
+ from .parsers.ods_parser import (
53
+ extract_ods_results,
54
+ match_ods_results_to_tasks,
55
+ )
56
+ from .parsers.pfd_parser import parse_process_flow
57
+ from .parsers.project_parser import parse_project_xml
58
+ from .parsers.query_parser import parse_query
59
+ from .parsers.shortcut_parser import parse_shortcut
60
+ from .parsers.task_parser import (
61
+ parse_append_task,
62
+ parse_code_task,
63
+ parse_eg_task,
64
+ parse_export_task,
65
+ parse_import_task,
66
+ )
67
+ from .serializer import serialize_project
68
+ from .validator import validate_completeness
69
+
70
+ path = Path(egp_path).resolve()
71
+
72
+ # Step 1: Open and validate the archive
73
+ inventory = open_archive(path)
74
+ try:
75
+ # Track which paths we process for completeness validation
76
+ processed_paths: set[str] = set()
77
+
78
+ # Step 2: Read project.xml (handling UTF-8, UTF-16, BOM encodings)
79
+ xml_content = _read_project_xml(inventory)
80
+ processed_paths.add("project.xml")
81
+
82
+ # Parse the XML root for sub-parsers that need it
83
+ root = ET.fromstring(xml_content)
84
+
85
+ # Step 3: Parse project metadata via project_parser
86
+ partial_project = parse_project_xml(xml_content)
87
+
88
+ # Step 4: Parse data list
89
+ data_list = parse_data_list(root)
90
+
91
+ # Step 5: Parse external files (catch ValueError for malformed DNA)
92
+ external_files: list = []
93
+ try:
94
+ external_files = parse_external_file_list(root)
95
+ except ValueError as e:
96
+ logger.warning("Malformed external file DNA: %s", e)
97
+
98
+ # Step 6: Parse elements
99
+ parsed_elements = parse_elements(root)
100
+
101
+ # Step 6b: Invoke typed parsers for each element category.
102
+ # ValueError from typed parsers propagates to the caller — no silent
103
+ # error swallowing. This ensures missing required sections are surfaced
104
+ # rather than silently producing incomplete output.
105
+ queries: list = []
106
+ tasks: list = []
107
+ shortcuts: list = []
108
+ log_elements_list: list = []
109
+ code_elements_list: list = []
110
+
111
+ for elem in parsed_elements:
112
+ if elem.category == ElementCategory.QUERY:
113
+ submitable, query_model = parse_query(elem.xml_node)
114
+ queries.append(
115
+ {
116
+ "metadata": elem.metadata,
117
+ "submitable": submitable,
118
+ "query_model": query_model,
119
+ }
120
+ )
121
+ elif elem.category == ElementCategory.IMPORT_TASK:
122
+ # `result` is rebound to several element types across branches.
123
+ result: Any = parse_import_task(
124
+ elem.xml_node, elem.metadata, archive=inventory
125
+ )
126
+ tasks.append(result)
127
+ elif elem.category == ElementCategory.CODE_TASK:
128
+ result = parse_code_task(
129
+ elem.xml_node, elem.metadata, archive=inventory
130
+ )
131
+ tasks.append(result)
132
+ elif elem.category == ElementCategory.EG_TASK:
133
+ result = parse_eg_task(elem.xml_node, elem.metadata, archive=inventory)
134
+ tasks.append(result)
135
+ elif elem.category == ElementCategory.EXPORT_TASK:
136
+ result = parse_export_task(elem.xml_node, elem.metadata)
137
+ tasks.append(result)
138
+ elif elem.category == ElementCategory.APPEND_TASK:
139
+ result = parse_append_task(elem.xml_node, elem.metadata)
140
+ tasks.append(result)
141
+ elif elem.category == ElementCategory.SHORTCUT_TO_DATA:
142
+ result = parse_shortcut(elem.xml_node, elem.metadata, is_data=True)
143
+ shortcuts.append(result)
144
+ elif elem.category == ElementCategory.SHORTCUT_TO_FILE:
145
+ result = parse_shortcut(elem.xml_node, elem.metadata, is_data=False)
146
+ shortcuts.append(result)
147
+ elif elem.category == ElementCategory.LOG:
148
+ try:
149
+ result = parse_log_element(elem.xml_node, elem.metadata)
150
+ except ValueError as e:
151
+ logger.warning(
152
+ "Failed to parse log element '%s': %s", elem.metadata.id, e
153
+ )
154
+ # Still record the element, without its display settings.
155
+ result = LogElement(metadata=elem.metadata)
156
+ log_elements_list.append(result)
157
+ elif elem.category == ElementCategory.CODE:
158
+ result = parse_code_element(elem.xml_node, elem.metadata)
159
+ code_elements_list.append(result)
160
+
161
+ # Step 6c: Parse External_Objects section (ValueError propagates)
162
+ external_objects = parse_external_objects(root)
163
+
164
+ # Step 7: Parse process flow containers and build DAGs
165
+ containers: list[ProcessFlowContainer] = []
166
+ for elem in parsed_elements:
167
+ if elem.category == ElementCategory.PROCESS_FLOW_CONTAINER:
168
+ try:
169
+ dag_model = parse_process_flow(elem.xml_node)
170
+ containers.append(
171
+ ProcessFlowContainer(
172
+ metadata=elem.metadata,
173
+ dag=dag_model,
174
+ )
175
+ )
176
+ except ValueError as e:
177
+ logger.warning(
178
+ "Failed to parse process flow for element '%s': %s",
179
+ elem.metadata.id,
180
+ e,
181
+ )
182
+ # Still record the container without a DAG
183
+ containers.append(
184
+ ProcessFlowContainer(metadata=elem.metadata, dag=None)
185
+ )
186
+
187
+ # Step 8: Extract execution logs. NOTE: extraction is invoked to validate
188
+ # the log entries (and mark them processed below), but the returned logs
189
+ # are not currently attached to the ParsedProject.
190
+ try:
191
+ extract_execution_logs(inventory)
192
+ # Mark log paths as processed
193
+ from .archive import EntryCategory
194
+
195
+ for entry in inventory.entries:
196
+ if entry.category == EntryCategory.EXECUTION_LOG:
197
+ processed_paths.add(entry.path)
198
+ except ValueError as e:
199
+ logger.warning("Failed to extract execution logs: %s", e)
200
+
201
+ # Step 9: Extract project log (catch ValueError for missing ProjectLog)
202
+ project_log = None
203
+ try:
204
+ project_log = extract_project_log(inventory, root)
205
+ # Mark project log paths as processed
206
+ from .archive import EntryCategory
207
+
208
+ for entry in inventory.entries:
209
+ if entry.category == EntryCategory.PROJECT_LOG:
210
+ processed_paths.add(entry.path)
211
+ except ValueError as e:
212
+ logger.warning("Missing or invalid project log: %s", e)
213
+
214
+ # Step 10: Extract ODS results
215
+ ods_entries = extract_ods_results(inventory)
216
+ ods_entries = match_ods_results_to_tasks(ods_entries, parsed_elements)
217
+ # Mark ODS paths as processed
218
+ from .archive import EntryCategory
219
+
220
+ for entry in inventory.entries:
221
+ if entry.category == EntryCategory.ODS_RESULT:
222
+ processed_paths.add(entry.path)
223
+
224
+ # Also mark task configs, code files as processed
225
+ for entry in inventory.entries:
226
+ if entry.category in (
227
+ EntryCategory.TASK_CONFIG,
228
+ EntryCategory.CODE_FILE,
229
+ ):
230
+ processed_paths.add(entry.path)
231
+
232
+ # Step 11: Parse visual layout
233
+ element_ids = {
234
+ elem.metadata.id for elem in parsed_elements if elem.metadata.id is not None
235
+ }
236
+ visual_layout = parse_visual_layout(root, element_ids=element_ids)
237
+ open_project_view = parse_open_project_view(root)
238
+
239
+ # Step 12: Validate completeness
240
+ completeness_summary, unprocessed_entries, completeness_warning = (
241
+ validate_completeness(inventory, processed_paths)
242
+ )
243
+
244
+ # Step 13: Build source info (Req 15.16, 15.17)
245
+ source = SourceInfo(
246
+ file_path=str(path),
247
+ file_name=path.name,
248
+ file_size_bytes=path.stat().st_size,
249
+ parsed_at=datetime.datetime.now().isoformat(),
250
+ total_zip_entries=len(inventory.entries),
251
+ )
252
+
253
+ # Assemble the full ParsedProject with all sections populated
254
+ # Convert ParsedElement objects to their metadata for serialization
255
+ # (ParsedElement contains xml_node which is not JSON-serializable)
256
+ serializable_elements = [elem.metadata for elem in parsed_elements]
257
+
258
+ project = ParsedProject(
259
+ source=source,
260
+ metadata=partial_project.metadata,
261
+ settings=partial_project.settings,
262
+ data_list=data_list,
263
+ external_files=external_files,
264
+ elements=serializable_elements,
265
+ containers=containers,
266
+ parameters=partial_project.parameters,
267
+ project_log=project_log,
268
+ visual_layout=visual_layout,
269
+ completeness_summary=completeness_summary,
270
+ unprocessed_entries=unprocessed_entries,
271
+ completeness_warning=completeness_warning,
272
+ binary_entries=ods_entries,
273
+ application_overrides=partial_project.application_overrides,
274
+ metadata_info=partial_project.metadata_info,
275
+ open_project_view=open_project_view,
276
+ queries=queries,
277
+ tasks=tasks,
278
+ shortcuts=shortcuts,
279
+ log_elements=log_elements_list,
280
+ code_elements=code_elements_list,
281
+ external_objects=external_objects,
282
+ )
283
+
284
+ # Step 14: Write output if output_dir is specified (Req 15.6)
285
+ if output_dir is not None:
286
+ out_path = Path(output_dir)
287
+ serialize_project(project, out_path)
288
+
289
+ return project
290
+ finally:
291
+ inventory.close()
292
+
293
+
294
+ def _read_project_xml(inventory) -> str:
295
+ """Read project.xml from the archive, handling UTF-8, UTF-16, and BOM encodings.
296
+
297
+ Tries UTF-8 first, then falls back to UTF-16 and UTF-8-sig (BOM) if needed.
298
+
299
+ Args:
300
+ inventory: The open ArchiveInventory.
301
+
302
+ Returns:
303
+ The decoded XML content string.
304
+
305
+ Raises:
306
+ ValueError: If project.xml cannot be decoded with any supported encoding.
307
+ """
308
+ raw_bytes = inventory.get_bytes("project.xml")
309
+
310
+ # Try UTF-8 BOM first (utf-8-sig handles BOM transparently)
311
+ if raw_bytes.startswith(b"\xef\xbb\xbf"):
312
+ return raw_bytes.decode("utf-8-sig")
313
+
314
+ # Try UTF-16 BOM (both LE and BE)
315
+ if raw_bytes.startswith(b"\xff\xfe") or raw_bytes.startswith(b"\xfe\xff"):
316
+ return raw_bytes.decode("utf-16")
317
+
318
+ # Default: try UTF-8
319
+ try:
320
+ return raw_bytes.decode("utf-8")
321
+ except UnicodeDecodeError:
322
+ pass
323
+
324
+ # Fallback: try UTF-16 without BOM
325
+ try:
326
+ return raw_bytes.decode("utf-16")
327
+ except UnicodeDecodeError:
328
+ pass
329
+
330
+ raise ValueError(
331
+ "project.xml cannot be decoded: tried UTF-8, UTF-8-sig, and UTF-16"
332
+ )
333
+
334
+
335
+ def parse_directory(
336
+ directory: str | Path,
337
+ output_dir: str | Path | None = None,
338
+ ) -> BulkResult:
339
+ """Parse all .egp files in a directory recursively.
340
+
341
+ Discovers .egp files, parses each independently, and returns a structured
342
+ result with successes, failures, and summary counts.
343
+
344
+ Args:
345
+ directory: Root directory to search for .egp files.
346
+ output_dir: Optional directory for JSON output. If None, no file is written.
347
+
348
+ Returns:
349
+ A BulkResult dataclass instance with successes, failures, and summary.
350
+
351
+ Raises:
352
+ ValueError: If directory does not exist or is not a directory.
353
+ """
354
+ from .bulk import process_directory
355
+
356
+ return process_directory(directory, output_dir=output_dir)
@@ -0,0 +1,114 @@
1
+ """ZIP extraction and entry classification for EGP archives."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from zipfile import BadZipFile, ZipFile
7
+
8
+
9
+ class EntryCategory(Enum):
10
+ """Classification categories for ZIP archive entries."""
11
+
12
+ PROJECT_XML = "project_xml"
13
+ TASK_CONFIG = "task_config"
14
+ CODE_FILE = "code_file"
15
+ EXECUTION_LOG = "execution_log"
16
+ PROJECT_LOG = "project_log"
17
+ ODS_RESULT = "ods_result"
18
+ EMPTY_DIRECTORY = "empty_directory"
19
+ UNKNOWN = "unknown"
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ArchiveEntry:
24
+ """A single entry in the EGP ZIP archive with its classification."""
25
+
26
+ path: str
27
+ category: EntryCategory
28
+ compressed_size: int
29
+ uncompressed_size: int
30
+
31
+
32
+ @dataclass
33
+ class ArchiveInventory:
34
+ """Collection of classified archive entries with access to the open ZIP file."""
35
+
36
+ entries: list[ArchiveEntry]
37
+ zip_file: ZipFile # kept open for content reads
38
+
39
+ def get_content(self, path: str, encoding: str = "utf-8") -> str:
40
+ """Read a text entry from the archive.
41
+
42
+ Args:
43
+ path: The archive entry path to read.
44
+ encoding: Text encoding to use (default: utf-8).
45
+
46
+ Returns:
47
+ Decoded text content of the entry.
48
+ """
49
+ return self.zip_file.read(path).decode(encoding)
50
+
51
+ def get_bytes(self, path: str) -> bytes:
52
+ """Read raw bytes from the archive.
53
+
54
+ Args:
55
+ path: The archive entry path to read.
56
+
57
+ Returns:
58
+ Raw bytes of the entry.
59
+ """
60
+ return self.zip_file.read(path)
61
+
62
+ def close(self) -> None:
63
+ """Close the underlying ZIP file."""
64
+ self.zip_file.close()
65
+
66
+
67
+ def open_archive(egp_path: Path) -> ArchiveInventory:
68
+ """Open an EGP file and classify all entries.
69
+
70
+ Args:
71
+ egp_path: Path to the .egp file.
72
+
73
+ Returns:
74
+ ArchiveInventory with classified entries and an open ZipFile handle.
75
+
76
+ Raises:
77
+ FileNotFoundError: If path does not exist.
78
+ ValueError: If not a valid ZIP or missing project.xml.
79
+ """
80
+ # Import here to avoid circular dependency (classifier imports EntryCategory from this module)
81
+ from .classifier import classify_entry
82
+
83
+ path = Path(egp_path)
84
+
85
+ if not path.exists():
86
+ raise FileNotFoundError(f"EGP file not found: {path}")
87
+
88
+ try:
89
+ zf = ZipFile(path, "r")
90
+ except BadZipFile as e:
91
+ raise ValueError(f"Not a valid EGP archive (invalid ZIP): {path}") from e
92
+
93
+ # Enumerate and classify all entries
94
+ entries: list[ArchiveEntry] = []
95
+ has_project_xml = False
96
+
97
+ for info in zf.infolist():
98
+ category = classify_entry(info.filename)
99
+ if category == EntryCategory.PROJECT_XML:
100
+ has_project_xml = True
101
+ entries.append(
102
+ ArchiveEntry(
103
+ path=info.filename,
104
+ category=category,
105
+ compressed_size=info.compress_size,
106
+ uncompressed_size=info.file_size,
107
+ )
108
+ )
109
+
110
+ if not has_project_xml:
111
+ zf.close()
112
+ raise ValueError(f"Not a valid EGP archive (missing project.xml): {path}")
113
+
114
+ return ArchiveInventory(entries=entries, zip_file=zf)
pyegp_parser/bulk.py ADDED
@@ -0,0 +1,113 @@
1
+ """Bulk directory processing for EGP files.
2
+
3
+ Discovers all .egp files recursively within a directory, parses each
4
+ independently, records successes and failures, and optionally writes
5
+ per-file JSON outputs preserving subdirectory structure.
6
+
7
+ Requirements: 19.1, 19.2, 19.3, 19.4, 19.5, 19.6, 19.7
8
+ """
9
+
10
+ from pathlib import Path
11
+
12
+ from .models.bulk import BulkFileFailure, BulkFileSuccess, BulkResult, BulkSummary
13
+
14
+
15
+ def discover_egp_files(directory: Path) -> list[Path]:
16
+ """Discover all .egp files recursively, sorted lexicographically by full path.
17
+
18
+ Args:
19
+ directory: Root directory to search.
20
+
21
+ Returns:
22
+ Sorted list of Path objects pointing to .egp files.
23
+
24
+ Raises:
25
+ ValueError: If directory does not exist or is not a directory.
26
+ """
27
+ if not directory.exists():
28
+ raise ValueError(f"Invalid directory path: '{directory}' does not exist")
29
+ if not directory.is_dir():
30
+ raise ValueError(f"Invalid directory path: '{directory}' is not a directory")
31
+ egp_files = list(directory.rglob("*.egp"))
32
+ # Sort lexicographically by full path string for consistent ordering
33
+ egp_files.sort(key=lambda p: str(p))
34
+ return egp_files
35
+
36
+
37
+ def _get_parse_file():
38
+ """Lazy import of parse_file to avoid circular dependencies."""
39
+ import pyegp_parser
40
+
41
+ return pyegp_parser.parse_file
42
+
43
+
44
+ def process_directory(
45
+ directory: str | Path,
46
+ output_dir: str | Path | None = None,
47
+ ) -> BulkResult:
48
+ """Parse all .egp files in a directory recursively.
49
+
50
+ Discovers .egp files, parses each independently, and collects results.
51
+ If output_dir is provided, writes per-file JSON outputs preserving
52
+ subdirectory structure relative to the input directory.
53
+
54
+ Args:
55
+ directory: Root directory to scan for .egp files.
56
+ output_dir: Optional directory for per-file JSON output.
57
+ Output paths preserve subdirectory structure:
58
+ {output_dir}/{relative_path_stem}.json
59
+
60
+ Returns:
61
+ BulkResult with successes, failures, and summary counts.
62
+
63
+ Raises:
64
+ ValueError: If directory does not exist or is not a directory.
65
+ """
66
+ parse_file = _get_parse_file()
67
+
68
+ directory = Path(directory).resolve()
69
+
70
+ # Validate directory and discover files (raises ValueError if invalid)
71
+ egp_files = discover_egp_files(directory)
72
+
73
+ output_path = Path(output_dir).resolve() if output_dir else None
74
+
75
+ successes: list[BulkFileSuccess] = []
76
+ failures: list[BulkFileFailure] = []
77
+
78
+ for egp_file in egp_files:
79
+ try:
80
+ # Determine per-file output directory if output_dir is specified
81
+ file_output_dir = None
82
+ if output_path is not None:
83
+ # Preserve subdirectory structure relative to input directory
84
+ relative = egp_file.relative_to(directory)
85
+ # Use stem (filename without extension) as the output subdirectory
86
+ file_output_dir = output_path / relative.parent / relative.stem
87
+
88
+ project = parse_file(egp_file, output_dir=file_output_dir)
89
+ successes.append(
90
+ BulkFileSuccess(
91
+ file_path=str(egp_file),
92
+ project=project,
93
+ )
94
+ )
95
+ except Exception as e:
96
+ failures.append(
97
+ BulkFileFailure(
98
+ file_path=str(egp_file),
99
+ error_message=str(e),
100
+ )
101
+ )
102
+
103
+ summary = BulkSummary(
104
+ total_files=len(egp_files),
105
+ success_count=len(successes),
106
+ failure_count=len(failures),
107
+ )
108
+
109
+ return BulkResult(
110
+ successes=successes,
111
+ failures=failures,
112
+ summary=summary,
113
+ )
@@ -0,0 +1,60 @@
1
+ """Path pattern matching for ZIP entry classification."""
2
+
3
+ import re
4
+
5
+ from .archive import EntryCategory
6
+
7
+ # Compiled regex patterns for categories that don't need backreference validation
8
+ _SIMPLE_PATTERNS: dict[EntryCategory, re.Pattern] = {
9
+ EntryCategory.PROJECT_XML: re.compile(r"^project\.xml$"),
10
+ EntryCategory.CODE_FILE: re.compile(r"^CodeTask-(?P<id>[A-Za-z0-9]+)/code\.sas$"),
11
+ EntryCategory.EXECUTION_LOG: re.compile(
12
+ r"^(?P<task_type>\w+)-(?P<task_id>[A-Za-z0-9]+)/Log-(?P<log_id>[A-Za-z0-9]+)/result\.log$"
13
+ ),
14
+ EntryCategory.ODS_RESULT: re.compile(
15
+ r"^ODSResults/ODSResult-(?P<id>[A-Za-z0-9]+)/.*$"
16
+ ),
17
+ }
18
+
19
+ # Patterns that require backreference-like validation (checked via function)
20
+ _TASK_CONFIG_PATTERN = re.compile(
21
+ r"^(?P<task_type>\w+)-(?P<id>[A-Za-z0-9]+)/(?P<repeat_type>\w+)-(?P<repeat_id>[A-Za-z0-9]+)\.xml$"
22
+ )
23
+
24
+ _PROJECT_LOG_PATTERN = re.compile(
25
+ r"^ProjectLog-(?P<id>[A-Za-z0-9]+)/ProjectLog-(?P<repeat_id>[A-Za-z0-9]+)/result\.log$"
26
+ )
27
+
28
+
29
+ def classify_entry(path: str) -> EntryCategory:
30
+ """Classify a ZIP entry path into its category.
31
+
32
+ Empty directory entries (ending with '/') are classified as EMPTY_DIRECTORY.
33
+ Paths matching known patterns get their respective category.
34
+ All other paths are classified as UNKNOWN.
35
+ """
36
+ if path.endswith("/"):
37
+ return EntryCategory.EMPTY_DIRECTORY
38
+
39
+ # Check simple patterns first
40
+ for category, pattern in _SIMPLE_PATTERNS.items():
41
+ if pattern.match(path):
42
+ return category
43
+
44
+ # Check TASK_CONFIG with backreference validation:
45
+ # Pattern: {TaskType}-{ID}/{TaskType}-{ID}.xml (type and ID must repeat)
46
+ m = _TASK_CONFIG_PATTERN.match(path)
47
+ if (
48
+ m
49
+ and m.group("task_type") == m.group("repeat_type")
50
+ and m.group("id") == m.group("repeat_id")
51
+ ):
52
+ return EntryCategory.TASK_CONFIG
53
+
54
+ # Check PROJECT_LOG with backreference validation:
55
+ # Pattern: ProjectLog-{ID}/ProjectLog-{ID}/result.log (ID must repeat)
56
+ m = _PROJECT_LOG_PATTERN.match(path)
57
+ if m and m.group("id") == m.group("repeat_id"):
58
+ return EntryCategory.PROJECT_LOG
59
+
60
+ return EntryCategory.UNKNOWN