pydtsx-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 (36) hide show
  1. pydtsx_parser/__init__.py +3 -0
  2. pydtsx_parser/__main__.py +8 -0
  3. pydtsx_parser/cli.py +120 -0
  4. pydtsx_parser/constants.py +41 -0
  5. pydtsx_parser/dispatcher.py +373 -0
  6. pydtsx_parser/envelope.py +164 -0
  7. pydtsx_parser/errors.py +37 -0
  8. pydtsx_parser/extractors/__init__.py +1 -0
  9. pydtsx_parser/extractors/columns.py +157 -0
  10. pydtsx_parser/extractors/components.py +284 -0
  11. pydtsx_parser/extractors/connections.py +349 -0
  12. pydtsx_parser/extractors/executables.py +118 -0
  13. pydtsx_parser/extractors/paths.py +133 -0
  14. pydtsx_parser/extractors/pipeline.py +115 -0
  15. pydtsx_parser/extractors/precedence.py +134 -0
  16. pydtsx_parser/extractors/sort.py +156 -0
  17. pydtsx_parser/extractors/sources.py +130 -0
  18. pydtsx_parser/extractors/sql_tasks.py +195 -0
  19. pydtsx_parser/extractors/transformations.py +371 -0
  20. pydtsx_parser/extractors/variables.py +76 -0
  21. pydtsx_parser/mcp_entry.py +31 -0
  22. pydtsx_parser/mcp_server.py +499 -0
  23. pydtsx_parser/parsers/__init__.py +1 -0
  24. pydtsx_parser/parsers/conmgr.py +67 -0
  25. pydtsx_parser/parsers/dtproj.py +470 -0
  26. pydtsx_parser/parsers/dtsx.py +156 -0
  27. pydtsx_parser/parsers/params.py +132 -0
  28. pydtsx_parser/py.typed +0 -0
  29. pydtsx_parser/redaction.py +282 -0
  30. pydtsx_parser/xml_utils.py +185 -0
  31. pydtsx_parser-0.1.0.dist-info/METADATA +280 -0
  32. pydtsx_parser-0.1.0.dist-info/RECORD +36 -0
  33. pydtsx_parser-0.1.0.dist-info/WHEEL +5 -0
  34. pydtsx_parser-0.1.0.dist-info/entry_points.txt +3 -0
  35. pydtsx_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
  36. pydtsx_parser-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """SSIS Parser - A Python parser for SQL Server Integration Services files."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """Entry point for running pydtsx_parser as a module: python -m pydtsx_parser."""
2
+
3
+ import sys
4
+
5
+ from pydtsx_parser.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
pydtsx_parser/cli.py ADDED
@@ -0,0 +1,120 @@
1
+ """CLI module for the SSIS Parser.
2
+
3
+ Provides argument parsing, output routing (stdout vs file), stderr
4
+ warning/error logging, and exit code handling.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import logging
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from pydtsx_parser.dispatcher import SUPPORTED_EXTENSIONS, dispatch
14
+ from pydtsx_parser.errors import SSISParseError
15
+
16
+ logger = logging.getLogger("pydtsx_parser")
17
+
18
+
19
+ def parse_args(args: list[str]) -> argparse.Namespace:
20
+ """Parse CLI arguments.
21
+
22
+ Args:
23
+ args: List of command-line argument strings.
24
+
25
+ Returns:
26
+ Parsed argparse.Namespace with path, output, and pretty attributes.
27
+ """
28
+ parser = argparse.ArgumentParser(
29
+ prog="pydtsx-parser",
30
+ description="Parse SSIS files (.dtsx, .dtproj, .conmgr, .params) into JSON.",
31
+ )
32
+ parser.add_argument(
33
+ "path",
34
+ help="File or directory path to parse",
35
+ )
36
+ parser.add_argument(
37
+ "--output",
38
+ "-o",
39
+ default=None,
40
+ help="Output file path (default: stdout)",
41
+ )
42
+ parser.add_argument(
43
+ "--pretty",
44
+ "-p",
45
+ action="store_true",
46
+ default=False,
47
+ help="Pretty-print JSON with 2-space indent",
48
+ )
49
+ return parser.parse_args(args)
50
+
51
+
52
+ def main(args: list[str] | None = None) -> int:
53
+ """Entry point for the SSIS Parser CLI.
54
+
55
+ Parses arguments, dispatches to the appropriate parser, and routes
56
+ output to stdout or a file. Logs warnings/errors to stderr.
57
+
58
+ Args:
59
+ args: Optional list of CLI arguments. If None, uses sys.argv[1:].
60
+
61
+ Returns:
62
+ Exit code: 0 for success, non-zero for errors.
63
+ """
64
+ # Configure stderr logging for warnings and errors
65
+ logging.basicConfig(
66
+ stream=sys.stderr,
67
+ format="%(levelname)s: %(message)s",
68
+ level=logging.WARNING,
69
+ )
70
+
71
+ if args is None:
72
+ args = sys.argv[1:]
73
+
74
+ parsed = parse_args(args)
75
+ path = parsed.path
76
+ output_path = parsed.output
77
+ pretty = parsed.pretty
78
+
79
+ # Resolve the path
80
+ resolved = Path(path).resolve()
81
+
82
+ # Check if path exists
83
+ if not resolved.exists():
84
+ print(f"Error: Path does not exist: {resolved}", file=sys.stderr)
85
+ return 1
86
+
87
+ # For single files, check if the extension is supported
88
+ if resolved.is_file():
89
+ ext = resolved.suffix.lower()
90
+ if ext not in SUPPORTED_EXTENSIONS:
91
+ logger.warning("Unsupported file type '%s': %s (skipping)", ext, resolved)
92
+ return 0
93
+
94
+ # Dispatch parsing
95
+ try:
96
+ result = dispatch(str(resolved), pretty=pretty)
97
+ except SSISParseError as e:
98
+ print(f"Error: {e}", file=sys.stderr)
99
+ return 1
100
+ except Exception as e:
101
+ print(f"Error: Unexpected error: {e}", file=sys.stderr)
102
+ return 1
103
+
104
+ # Serialize to JSON
105
+ indent = 2 if pretty else None
106
+ json_output = json.dumps(result, indent=indent, ensure_ascii=False)
107
+
108
+ # Route output
109
+ if output_path:
110
+ try:
111
+ output_file = Path(output_path)
112
+ output_file.parent.mkdir(parents=True, exist_ok=True)
113
+ output_file.write_text(json_output, encoding="utf-8")
114
+ except OSError as e:
115
+ print(f"Error: Cannot write to output file: {e}", file=sys.stderr)
116
+ return 1
117
+ else:
118
+ print(json_output)
119
+
120
+ return 0
@@ -0,0 +1,41 @@
1
+ """Constants used throughout the SSIS Parser."""
2
+
3
+ # Component classification map: componentClassID -> category
4
+ # Used to classify data flow components as source, destination, or transformation.
5
+ # Components not in this map are classified as "unknown".
6
+ COMPONENT_CLASSIFICATION = {
7
+ "Microsoft.OLEDBSource": "source",
8
+ "Microsoft.FlatFileSource": "source",
9
+ "Microsoft.SSISOracleSrc": "source",
10
+ "Microsoft.OLEDBDestination": "destination",
11
+ "Microsoft.FlatFileDestination": "destination",
12
+ "Microsoft.DerivedColumn": "transformation",
13
+ "Microsoft.Sort": "transformation",
14
+ "Microsoft.MergeJoin": "transformation",
15
+ "Microsoft.ConditionalSplit": "transformation",
16
+ }
17
+
18
+ # SSIS numeric data type codes to human-readable type names
19
+ DATA_TYPE_MAP = {
20
+ "2": "i2",
21
+ "3": "i4",
22
+ "4": "r4",
23
+ "5": "r8",
24
+ "6": "cy",
25
+ "7": "dbDate",
26
+ "11": "bool",
27
+ "20": "i8",
28
+ "72": "guid",
29
+ "129": "str",
30
+ "130": "wstr",
31
+ "131": "numeric",
32
+ "135": "dbTimeStamp",
33
+ "139": "numericWithPrecision",
34
+ }
35
+
36
+ # XML namespace URIs used in SSIS files
37
+ NAMESPACES = {
38
+ "DTS": "www.microsoft.com/SqlServer/Dts",
39
+ "SSIS": "www.microsoft.com/SqlServer/SSIS",
40
+ "SQLTask": "www.microsoft.com/sqlserver/dts/tasks/sqltask",
41
+ }
@@ -0,0 +1,373 @@
1
+ """File dispatcher for the SSIS Parser.
2
+
3
+ Routes files to the correct parser by extension, handles directory scanning
4
+ for recursive discovery of supported SSIS files, and produces combined
5
+ project-level output when given a directory.
6
+
7
+ Supported file types:
8
+ - .dtsx → DTSX package parser
9
+ - .dtproj → DTPROJ project parser
10
+ - .conmgr → Connection manager parser
11
+ - .params → Parameters parser
12
+ """
13
+
14
+ import os
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from pydtsx_parser.envelope import build_envelope
19
+ from pydtsx_parser.errors import SSISParseError
20
+ from pydtsx_parser.parsers.conmgr import parse_conmgr
21
+ from pydtsx_parser.parsers.dtproj import parse_dtproj
22
+ from pydtsx_parser.parsers.dtsx import parse_dtsx
23
+ from pydtsx_parser.parsers.params import parse_params
24
+ from pydtsx_parser.redaction import redact
25
+
26
+ # Supported file extensions for SSIS parsing
27
+ SUPPORTED_EXTENSIONS = {".dtsx", ".dtproj", ".conmgr", ".params"}
28
+
29
+ # Mapping of file extension to file_type enum value
30
+ _EXTENSION_TO_FILE_TYPE = {
31
+ ".dtsx": "dtsx_package",
32
+ ".dtproj": "dtproj_project",
33
+ ".conmgr": "conmgr_connection",
34
+ ".params": "params_parameters",
35
+ }
36
+
37
+ # Mapping of file extension to parser function
38
+ _EXTENSION_TO_PARSER = {
39
+ ".dtsx": parse_dtsx,
40
+ ".dtproj": parse_dtproj,
41
+ ".conmgr": parse_conmgr,
42
+ ".params": parse_params,
43
+ }
44
+
45
+
46
+ def dispatch(path: str, pretty: bool = False) -> dict:
47
+ """Parse a single file or directory and return the result dict.
48
+
49
+ For a single file:
50
+ - Detects file type from extension
51
+ - Calls the appropriate parser
52
+ - Applies redaction
53
+ - Wraps in output envelope with metadata
54
+
55
+ For a directory:
56
+ - Recursively scans for supported SSIS files
57
+ - Parses each file
58
+ - Produces combined project-level output grouping packages,
59
+ connection managers, and parameters with cross-references
60
+ by ObjectName
61
+
62
+ Args:
63
+ path: File or directory path to parse.
64
+ pretty: Whether to pretty-print JSON (passed through for
65
+ downstream consumers; does not affect dict output).
66
+
67
+ Returns:
68
+ The parsed result dictionary (envelope for single files,
69
+ combined project structure for directories).
70
+
71
+ Raises:
72
+ FileNotFoundError: If the path does not exist.
73
+ ValueError: If a single file has an unsupported extension.
74
+ """
75
+ if not path:
76
+ raise FileNotFoundError(path or "", "Path is empty or None")
77
+
78
+ resolved = Path(path).resolve()
79
+
80
+ if not resolved.exists():
81
+ raise FileNotFoundError(str(resolved), f"Path does not exist: {resolved}")
82
+
83
+ if resolved.is_dir():
84
+ return _dispatch_directory(str(resolved))
85
+ else:
86
+ return _dispatch_file(str(resolved))
87
+
88
+
89
+ def scan_directory(dir_path: str) -> list[str]:
90
+ """Recursively find all supported SSIS files in a directory tree.
91
+
92
+ Walks the directory tree and collects files with extensions matching
93
+ SUPPORTED_EXTENSIONS. Results are sorted for deterministic output.
94
+
95
+ Args:
96
+ dir_path: Path to the directory to scan.
97
+
98
+ Returns:
99
+ Sorted list of absolute file paths for all supported SSIS files.
100
+ """
101
+ found_files = []
102
+ resolved = Path(dir_path).resolve()
103
+
104
+ for root, _dirs, files in os.walk(str(resolved)):
105
+ for filename in files:
106
+ ext = Path(filename).suffix.lower()
107
+ if ext in SUPPORTED_EXTENSIONS:
108
+ found_files.append(str(Path(root) / filename))
109
+
110
+ found_files.sort()
111
+ return found_files
112
+
113
+
114
+ def detect_file_type(file_path: str) -> str:
115
+ """Map file extension to file_type enum value.
116
+
117
+ Args:
118
+ file_path: Path to the file.
119
+
120
+ Returns:
121
+ One of: "dtsx_package", "dtproj_project", "conmgr_connection",
122
+ "params_parameters".
123
+
124
+ Raises:
125
+ ValueError: If the file extension is not supported.
126
+ """
127
+ ext = Path(file_path).suffix.lower()
128
+ if ext not in _EXTENSION_TO_FILE_TYPE:
129
+ raise ValueError(
130
+ f"Unsupported file extension '{ext}' for file: {file_path}. "
131
+ f"Supported extensions: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
132
+ )
133
+ return _EXTENSION_TO_FILE_TYPE[ext]
134
+
135
+
136
+ def _dispatch_file(file_path: str) -> dict:
137
+ """Parse a single file: detect type, parse, redact, wrap in envelope.
138
+
139
+ Args:
140
+ file_path: Absolute path to the file.
141
+
142
+ Returns:
143
+ Output envelope dict with parsed content.
144
+
145
+ Raises:
146
+ ValueError: If file extension is not supported.
147
+ """
148
+ file_type = detect_file_type(file_path)
149
+ ext = Path(file_path).suffix.lower()
150
+ parser = _EXTENSION_TO_PARSER[ext]
151
+
152
+ # Call the appropriate parser
153
+ content = parser(file_path)
154
+
155
+ # Apply redaction
156
+ redacted_content, redaction_count = redact(content)
157
+
158
+ # Wrap in output envelope
159
+ envelope = build_envelope(
160
+ content=redacted_content,
161
+ source_path=file_path,
162
+ file_type=file_type,
163
+ redaction_count=redaction_count,
164
+ )
165
+
166
+ return envelope
167
+
168
+
169
+ def _dispatch_directory(dir_path: str) -> dict:
170
+ """Parse all supported files in a directory and produce combined output.
171
+
172
+ Produces a project-level structure grouping:
173
+ - packages (.dtsx files)
174
+ - connection_managers (.conmgr files)
175
+ - parameters (.params files)
176
+ - projects (.dtproj files)
177
+
178
+ Cross-references are established by ObjectName where available.
179
+
180
+ Args:
181
+ dir_path: Absolute path to the directory.
182
+
183
+ Returns:
184
+ Combined project-level output dict.
185
+ """
186
+ files = scan_directory(dir_path)
187
+
188
+ packages = []
189
+ connection_managers = []
190
+ parameters = []
191
+ projects = []
192
+ errors = []
193
+
194
+ for file_path in files:
195
+ ext = Path(file_path).suffix.lower()
196
+
197
+ if ext not in SUPPORTED_EXTENSIONS:
198
+ # Skip unsupported files with a warning
199
+ print(
200
+ f"Warning: Skipping unsupported file type: {file_path}",
201
+ file=sys.stderr,
202
+ )
203
+ continue
204
+
205
+ try:
206
+ file_type = detect_file_type(file_path)
207
+ parser = _EXTENSION_TO_PARSER[ext]
208
+ content = parser(file_path)
209
+
210
+ # Check if parser returned an error dict
211
+ if isinstance(content, dict) and content.get("error"):
212
+ errors.append(
213
+ {
214
+ "file_path": file_path,
215
+ "file_type": file_type,
216
+ "error": content,
217
+ }
218
+ )
219
+ continue
220
+
221
+ # Apply redaction to content
222
+ redacted_content, redaction_count = redact(content)
223
+
224
+ entry = {
225
+ "file_path": file_path,
226
+ "file_type": file_type,
227
+ "file_name": Path(file_path).name,
228
+ "redaction_count": redaction_count,
229
+ "content": redacted_content,
230
+ }
231
+
232
+ # Add ObjectName cross-reference where available
233
+ object_name = _extract_object_name(redacted_content, ext)
234
+ if object_name:
235
+ entry["object_name"] = object_name
236
+
237
+ # Group by file type
238
+ if ext == ".dtsx":
239
+ packages.append(entry)
240
+ elif ext == ".conmgr":
241
+ connection_managers.append(entry)
242
+ elif ext == ".params":
243
+ parameters.append(entry)
244
+ elif ext == ".dtproj":
245
+ projects.append(entry)
246
+
247
+ except SSISParseError as e:
248
+ errors.append(
249
+ {
250
+ "file_path": file_path,
251
+ "file_type": _EXTENSION_TO_FILE_TYPE.get(ext, "unknown"),
252
+ "error": {
253
+ "error": True,
254
+ "file_path": file_path,
255
+ "error_type": type(e).__name__.lower(),
256
+ "message": str(e),
257
+ },
258
+ }
259
+ )
260
+ except Exception as e:
261
+ errors.append(
262
+ {
263
+ "file_path": file_path,
264
+ "file_type": _EXTENSION_TO_FILE_TYPE.get(ext, "unknown"),
265
+ "error": {
266
+ "error": True,
267
+ "file_path": file_path,
268
+ "error_type": "unexpected_error",
269
+ "message": str(e),
270
+ },
271
+ }
272
+ )
273
+
274
+ # Build cross-reference index by ObjectName
275
+ cross_references = _build_cross_references(
276
+ packages, connection_managers, parameters
277
+ )
278
+
279
+ return {
280
+ "directory_path": dir_path,
281
+ "file_type": "project_directory",
282
+ "summary": {
283
+ "total_files": len(files),
284
+ "packages": len(packages),
285
+ "connection_managers": len(connection_managers),
286
+ "parameters": len(parameters),
287
+ "projects": len(projects),
288
+ "errors": len(errors),
289
+ },
290
+ "packages": packages,
291
+ "connection_managers": connection_managers,
292
+ "parameters": parameters,
293
+ "projects": projects,
294
+ "cross_references": cross_references,
295
+ "errors": errors,
296
+ }
297
+
298
+
299
+ def _extract_object_name(content: dict, ext: str) -> str | None:
300
+ """Extract the ObjectName from parsed content for cross-referencing.
301
+
302
+ Args:
303
+ content: The parsed (and redacted) content dict.
304
+ ext: The file extension.
305
+
306
+ Returns:
307
+ The ObjectName string if found, or None.
308
+ """
309
+ if ext == ".dtsx":
310
+ # DTSX packages have object_name in package_attributes
311
+ pkg_attrs = content.get("package_attributes", {})
312
+ return pkg_attrs.get("object_name")
313
+ elif ext == ".conmgr":
314
+ # Connection managers have object_name in the connection_manager dict
315
+ conn_mgr = content.get("connection_manager", {})
316
+ return conn_mgr.get("object_name")
317
+ elif ext == ".dtproj":
318
+ # Project files: use the project name from manifest
319
+ manifest = content.get("manifest", {})
320
+ project_props = manifest.get("project_properties", {})
321
+ return project_props.get("name")
322
+ elif ext == ".params":
323
+ # Params files don't typically have an object name
324
+ return None
325
+ return None
326
+
327
+
328
+ def _build_cross_references(
329
+ packages: list[dict],
330
+ connection_managers: list[dict],
331
+ parameters: list[dict],
332
+ ) -> dict:
333
+ """Build cross-reference index mapping ObjectNames across file types.
334
+
335
+ Creates a lookup that shows which connection managers and parameters
336
+ are referenced by which packages.
337
+
338
+ Args:
339
+ packages: List of parsed package entries.
340
+ connection_managers: List of parsed connection manager entries.
341
+ parameters: List of parsed parameter entries.
342
+
343
+ Returns:
344
+ Dict with connection_manager_index and parameter_index.
345
+ """
346
+ # Index connection managers by their ObjectName
347
+ conn_mgr_index = {}
348
+ for cm in connection_managers:
349
+ obj_name = cm.get("object_name")
350
+ if obj_name:
351
+ conn_mgr_index[obj_name] = {
352
+ "file_path": cm.get("file_path"),
353
+ "file_name": cm.get("file_name"),
354
+ }
355
+
356
+ # Index which packages reference which connection managers
357
+ package_connections = {}
358
+ for pkg in packages:
359
+ pkg_name = pkg.get("object_name", pkg.get("file_name", ""))
360
+ content = pkg.get("content", {})
361
+ pkg_conn_mgrs = content.get("connection_managers", [])
362
+ referenced_names = []
363
+ for conn in pkg_conn_mgrs:
364
+ conn_name = conn.get("object_name")
365
+ if conn_name:
366
+ referenced_names.append(conn_name)
367
+ if referenced_names:
368
+ package_connections[pkg_name] = referenced_names
369
+
370
+ return {
371
+ "connection_manager_index": conn_mgr_index,
372
+ "package_connection_references": package_connections,
373
+ }