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.
- pyegp_parser/__init__.py +356 -0
- pyegp_parser/archive.py +114 -0
- pyegp_parser/bulk.py +113 -0
- pyegp_parser/classifier.py +60 -0
- pyegp_parser/cli.py +195 -0
- pyegp_parser/dag.py +100 -0
- pyegp_parser/mcp_entry.py +31 -0
- pyegp_parser/mcp_server.py +457 -0
- pyegp_parser/models/__init__.py +105 -0
- pyegp_parser/models/base.py +38 -0
- pyegp_parser/models/bulk.py +69 -0
- pyegp_parser/models/data.py +59 -0
- pyegp_parser/models/elements.py +48 -0
- pyegp_parser/models/external_file.py +35 -0
- pyegp_parser/models/external_objects.py +32 -0
- pyegp_parser/models/log_code.py +43 -0
- pyegp_parser/models/process_flow.py +44 -0
- pyegp_parser/models/project.py +244 -0
- pyegp_parser/models/query.py +160 -0
- pyegp_parser/models/shortcut.py +48 -0
- pyegp_parser/models/tasks.py +89 -0
- pyegp_parser/models/visual_layout.py +111 -0
- pyegp_parser/parsers/__init__.py +15 -0
- pyegp_parser/parsers/data_parser.py +369 -0
- pyegp_parser/parsers/dna_parser.py +182 -0
- pyegp_parser/parsers/element_parser.py +198 -0
- pyegp_parser/parsers/external_objects_parser.py +119 -0
- pyegp_parser/parsers/layout_parser.py +208 -0
- pyegp_parser/parsers/log_code_parser.py +347 -0
- pyegp_parser/parsers/ods_parser.py +206 -0
- pyegp_parser/parsers/pfd_parser.py +152 -0
- pyegp_parser/parsers/project_parser.py +273 -0
- pyegp_parser/parsers/query_parser.py +510 -0
- pyegp_parser/parsers/shortcut_parser.py +83 -0
- pyegp_parser/parsers/task_parser.py +460 -0
- pyegp_parser/pretty_printer.py +385 -0
- pyegp_parser/py.typed +0 -0
- pyegp_parser/redaction.py +137 -0
- pyegp_parser/schema_generator.py +355 -0
- pyegp_parser/serializer.py +342 -0
- pyegp_parser/validator.py +56 -0
- pyegp_parser-0.1.0.dist-info/METADATA +254 -0
- pyegp_parser-0.1.0.dist-info/RECORD +47 -0
- pyegp_parser-0.1.0.dist-info/WHEEL +5 -0
- pyegp_parser-0.1.0.dist-info/entry_points.txt +3 -0
- pyegp_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyegp_parser-0.1.0.dist-info/top_level.txt +1 -0
pyegp_parser/__init__.py
ADDED
|
@@ -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)
|
pyegp_parser/archive.py
ADDED
|
@@ -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
|