mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
Add XML file ingestion support (#560)
* Add XML file ingestion support * fix(xml-ingestor): add ingest_string test and document ingest() return keys - Add test_xml_ingestor_ingests_string to cover the public ingest_string() method which had no test coverage - Document all source_type return keys in the ingest() docstring so callers know to use result["xml"] rather than result["data"] for XML sources * docs(changelog): add unreleased entry for XML ingestion support (#560) --------- Co-authored-by: KaifAhmad1 <kaifahmad087@gmail.com>
This commit is contained in:
@@ -9,6 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **XML File Ingestion Support** (#560) by @Luffy2208
|
||||
- Added `XMLIngestor` class with `lxml` backend for parsing local XML files
|
||||
- Nested element hierarchy and flat element list extraction
|
||||
- Namespace and prefix extraction with collision handling
|
||||
- Attribute and element metadata extraction
|
||||
- Optional XSD schema validation with detailed error reporting
|
||||
- Optional DTD validation (internal and external)
|
||||
- Secure-by-default parser (`resolve_entities=False`, `no_network=True`) blocking XXE attacks
|
||||
- `ingest_xml()` convenience function and `ingest_file(..., method="xml")` support
|
||||
- Unified `.xml` auto-detection via `ingest("file.xml")`
|
||||
- Directory ingestion with recursive scanning and `fail_fast` support
|
||||
- `ingest_string()` for in-memory XML bytes/str ingestion
|
||||
- Comprehensive test coverage (8/8 tests passing)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **NERExtractor LLM method returning pattern-based output on custom gateways** (#554, PR #556) by @KaifAhmad1
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Ingest
|
||||
|
||||
> **Universal data ingestion from files, web, feeds, streams, repos, emails, and databases.**
|
||||
> **Universal data ingestion from files, XML, web, feeds, streams, repos, emails, and databases.**
|
||||
|
||||
---
|
||||
|
||||
@@ -13,6 +13,7 @@ The **Ingest Module** is the entry point for loading data into Semantica. It pro
|
||||
**Data ingestion** is the process of loading data from various sources into Semantica for processing. The ingest module handles:
|
||||
- **File Systems**: Local files, cloud storage (S3, GCS, Azure)
|
||||
- **Analytics Files**: Apache Parquet files and partitioned datasets
|
||||
- **Structured Files**: XML files with namespaces, attributes, XSD, and DTD validation
|
||||
- **Web Content**: Websites, RSS feeds, APIs
|
||||
- **Streams**: Real-time data from Kafka, RabbitMQ, etc.
|
||||
- **Databases**: SQL, NoSQL, and cloud data warehouses including Snowflake
|
||||
@@ -81,6 +82,12 @@ The **Ingest Module** is the entry point for loading data into Semantica. It pro
|
||||
|
||||
Read Parquet files, schemas, metadata, and Hive-style partitioned directories
|
||||
|
||||
- :material-code-tags:{ .lg .middle } **XML Files**
|
||||
|
||||
---
|
||||
|
||||
Parse XML into structured trees with namespaces, attributes, metadata, XSD, and DTD validation
|
||||
|
||||
</div>
|
||||
|
||||
!!! tip "When to Use"
|
||||
@@ -138,6 +145,19 @@ Handles Apache Parquet files and partitioned datasets.
|
||||
| `extract_schema(path)` | Extract column names, types, nullability, and schema metadata |
|
||||
| `extract_metadata(path)` | Extract row counts, row groups, compression, and partition info |
|
||||
|
||||
### XMLIngestor
|
||||
|
||||
Handles XML files and directories.
|
||||
|
||||
**Methods:**
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `ingest_file(path)` | Parse a single XML file into nested and flat structures |
|
||||
| `ingest_directory(path)` | Parse XML files from a folder |
|
||||
| `validate_file(path, schema_path=None, validate_dtd=False)` | Return XSD/DTD validation results |
|
||||
| `extract_metadata(path)` | Extract root, namespace, element, attribute, and DTD metadata |
|
||||
|
||||
### WebIngestor
|
||||
|
||||
Handles web content.
|
||||
@@ -234,6 +254,7 @@ from semantica.ingest import ingest
|
||||
# Auto-detect source type
|
||||
ingest("doc.pdf", source_type="file")
|
||||
ingest("events.parquet") # Auto-detects Parquet
|
||||
ingest("catalog.xml") # Auto-detects XML
|
||||
ingest("https://google.com", source_type="web")
|
||||
ingest("kafka://topic", source_type="stream")
|
||||
```
|
||||
@@ -260,6 +281,31 @@ metadata = ingestor.extract_metadata("events.parquet")
|
||||
partitioned = ingest_parquet("./warehouse/events", method="directory")
|
||||
```
|
||||
|
||||
### XML File Ingestion
|
||||
|
||||
```python
|
||||
from semantica.ingest import XMLIngestor, ingest_xml
|
||||
|
||||
ingestor = XMLIngestor()
|
||||
|
||||
# Parse a local XML file into a nested tree and flat element list
|
||||
xml_data = ingestor.ingest_file("catalog.xml")
|
||||
|
||||
print(xml_data.root_tag)
|
||||
print(xml_data.namespaces)
|
||||
print(xml_data.elements[0]["attributes"])
|
||||
|
||||
# Validate with an XSD schema
|
||||
validated = ingest_xml("catalog.xml", schema_path="catalog.xsd")
|
||||
print(validated.validation["schema"]["valid"])
|
||||
|
||||
# Validate an internal or local DTD declaration
|
||||
dtd_report = ingestor.validate_file("catalog.xml", validate_dtd=True)
|
||||
|
||||
# Extract metadata without returning the full tree
|
||||
metadata = ingest_xml("catalog.xml", method="metadata")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -94,6 +94,7 @@ Main Classes:
|
||||
- DBIngestor: Database export handling
|
||||
- OntologyIngestor: Ontology file processing
|
||||
- ParquetIngestor: Apache Parquet file and partitioned dataset processing
|
||||
- XMLIngestor: XML file parsing, validation, and metadata extraction
|
||||
- MethodRegistry: Registry for custom ingestion methods
|
||||
- IngestConfig: Configuration manager for ingest module
|
||||
|
||||
@@ -108,6 +109,7 @@ Convenience Functions:
|
||||
- ingest_database: Database ingestion wrapper
|
||||
- ingest_ontology: Ontology ingestion wrapper
|
||||
- ingest_parquet: Parquet ingestion wrapper
|
||||
- ingest_xml: XML ingestion wrapper
|
||||
|
||||
|
||||
Example Usage:
|
||||
@@ -145,6 +147,7 @@ from .methods import (
|
||||
ingest_repository,
|
||||
ingest_stream,
|
||||
ingest_web,
|
||||
ingest_xml,
|
||||
list_available_methods,
|
||||
)
|
||||
from .registry import MethodRegistry, method_registry
|
||||
@@ -204,6 +207,9 @@ _LAZY_EXPORTS: Dict[str, Tuple[str, str]] = {
|
||||
# Parquet ingestion
|
||||
"ParquetIngestor": (".parquet_ingestor", "ParquetIngestor"),
|
||||
"ParquetData": (".parquet_ingestor", "ParquetData"),
|
||||
# XML ingestion
|
||||
"XMLIngestor": (".xml_ingestor", "XMLIngestor"),
|
||||
"XMLIngestionData": (".xml_ingestor", "XMLIngestionData"),
|
||||
}
|
||||
|
||||
_OPTIONAL_DEPENDENCY_MESSAGES = {
|
||||
@@ -310,6 +316,9 @@ __all__ = [
|
||||
# Parquet ingestion
|
||||
"ParquetIngestor",
|
||||
"ParquetData",
|
||||
# XML ingestion
|
||||
"XMLIngestor",
|
||||
"XMLIngestionData",
|
||||
# Registry and Methods
|
||||
"MethodRegistry",
|
||||
"method_registry",
|
||||
@@ -323,6 +332,7 @@ __all__ = [
|
||||
"ingest_database",
|
||||
"ingest_ontology",
|
||||
"ingest_parquet",
|
||||
"ingest_xml",
|
||||
"ingest_mcp",
|
||||
"get_ingest_method",
|
||||
"list_available_methods",
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
# Ingest Module Usage Guide
|
||||
|
||||
This guide demonstrates how to use the ingest module for ingesting data from various sources including files, web content, feeds, streams, repositories, emails, and databases.
|
||||
This guide demonstrates how to use the ingest module for ingesting data from various sources including files, XML, web content, feeds, streams, repositories, emails, and databases.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Basic Usage](#basic-usage)
|
||||
2. [File Ingestion](#file-ingestion)
|
||||
3. [Parquet Ingestion](#parquet-ingestion)
|
||||
4. [Web Ingestion](#web-ingestion)
|
||||
5. [Feed Ingestion](#feed-ingestion)
|
||||
6. [Stream Ingestion](#stream-ingestion)
|
||||
7. [Repository Ingestion](#repository-ingestion)
|
||||
8. [Email Ingestion](#email-ingestion)
|
||||
9. [Database Ingestion](#database-ingestion)
|
||||
10. [MCP Server Ingestion](#mcp-server-ingestion)
|
||||
11. [Unified Ingestion](#unified-ingestion)
|
||||
12. [Using Methods](#using-methods)
|
||||
13. [Using Registry](#using-registry)
|
||||
14. [Configuration](#configuration)
|
||||
15. [Advanced Examples](#advanced-examples)
|
||||
4. [XML Ingestion](#xml-ingestion)
|
||||
5. [Web Ingestion](#web-ingestion)
|
||||
6. [Feed Ingestion](#feed-ingestion)
|
||||
7. [Stream Ingestion](#stream-ingestion)
|
||||
8. [Repository Ingestion](#repository-ingestion)
|
||||
9. [Email Ingestion](#email-ingestion)
|
||||
10. [Database Ingestion](#database-ingestion)
|
||||
11. [MCP Server Ingestion](#mcp-server-ingestion)
|
||||
12. [Unified Ingestion](#unified-ingestion)
|
||||
13. [Using Methods](#using-methods)
|
||||
14. [Using Registry](#using-registry)
|
||||
15. [Configuration](#configuration)
|
||||
16. [Advanced Examples](#advanced-examples)
|
||||
|
||||
## Basic Usage
|
||||
|
||||
@@ -33,6 +34,9 @@ result = ingest("document.pdf", source_type="file")
|
||||
# Ingest a Parquet file
|
||||
result = ingest("events.parquet")
|
||||
|
||||
# Ingest an XML file
|
||||
result = ingest("catalog.xml")
|
||||
|
||||
# Ingest from web URL
|
||||
result = ingest("https://example.com", source_type="web")
|
||||
|
||||
@@ -43,17 +47,21 @@ result = ingest("https://example.com/feed.xml", source_type="feed")
|
||||
### Using Main Classes
|
||||
|
||||
```python
|
||||
from semantica.ingest import FileIngestor, WebIngestor
|
||||
from semantica.ingest import FileIngestor, WebIngestor, XMLIngestor
|
||||
|
||||
# Create ingestor
|
||||
file_ingestor = FileIngestor()
|
||||
web_ingestor = WebIngestor(delay=1.0, respect_robots=True)
|
||||
xml_ingestor = XMLIngestor()
|
||||
|
||||
# Ingest files
|
||||
files = file_ingestor.ingest_directory("./documents", recursive=True)
|
||||
|
||||
# Ingest web content
|
||||
content = web_ingestor.ingest_url("https://example.com")
|
||||
|
||||
# Ingest XML content
|
||||
xml_data = xml_ingestor.ingest_file("catalog.xml")
|
||||
```
|
||||
|
||||
## File Ingestion
|
||||
@@ -206,6 +214,66 @@ print(data.metadata["partition_columns"])
|
||||
print(data.metadata["partition_values"])
|
||||
```
|
||||
|
||||
## XML Ingestion
|
||||
|
||||
### Single XML File
|
||||
|
||||
```python
|
||||
from semantica.ingest import XMLIngestor, ingest_xml
|
||||
|
||||
# Using convenience function
|
||||
xml_data = ingest_xml("catalog.xml")
|
||||
|
||||
# Using class directly
|
||||
ingestor = XMLIngestor()
|
||||
xml_data = ingestor.ingest_file("catalog.xml")
|
||||
|
||||
print(f"Root: {xml_data.root_tag}")
|
||||
print(f"Namespaces: {xml_data.namespaces}")
|
||||
print(f"Elements: {xml_data.metadata['element_count']}")
|
||||
```
|
||||
|
||||
### XML Namespaces and Attributes
|
||||
|
||||
```python
|
||||
from semantica.ingest import XMLIngestor
|
||||
|
||||
data = XMLIngestor().ingest_file("catalog.xml")
|
||||
|
||||
for element in data.elements:
|
||||
print(element["path"], element["tag"], element["attributes"])
|
||||
```
|
||||
|
||||
### XSD and DTD Validation
|
||||
|
||||
```python
|
||||
from semantica.ingest import XMLIngestor, ingest_xml
|
||||
|
||||
# XSD validation runs automatically when schema_path is provided
|
||||
validated = ingest_xml("catalog.xml", schema_path="catalog.xsd")
|
||||
print(validated.validation["schema"]["valid"])
|
||||
|
||||
# Return a report without raising on validation errors
|
||||
report = XMLIngestor().validate_file(
|
||||
"catalog.xml",
|
||||
schema_path="catalog.xsd",
|
||||
validate_dtd=True,
|
||||
)
|
||||
print(report["is_valid"])
|
||||
```
|
||||
|
||||
### XML Metadata
|
||||
|
||||
```python
|
||||
from semantica.ingest import ingest_xml
|
||||
|
||||
metadata = ingest_xml("catalog.xml", method="metadata")
|
||||
|
||||
print(metadata["root_tag"])
|
||||
print(metadata["namespace_count"])
|
||||
print(metadata["tag_counts"])
|
||||
```
|
||||
|
||||
## Web Ingestion
|
||||
|
||||
### Single URL Ingestion
|
||||
@@ -1001,6 +1069,7 @@ from semantica.ingest import ingest
|
||||
# Auto-detect source type from source
|
||||
result = ingest("document.pdf") # Auto-detects file
|
||||
result = ingest("events.parquet") # Auto-detects Parquet
|
||||
result = ingest("catalog.xml") # Auto-detects XML
|
||||
result = ingest("https://example.com") # Auto-detects web
|
||||
result = ingest("https://example.com/feed.xml") # Auto-detects feed
|
||||
result = ingest("postgresql://user:pass@localhost/db") # Auto-detects database
|
||||
@@ -1014,6 +1083,7 @@ from semantica.ingest import ingest
|
||||
|
||||
# Explicit source type
|
||||
result = ingest("document.pdf", source_type="file")
|
||||
result = ingest("catalog.xml", source_type="xml")
|
||||
result = ingest("https://example.com", source_type="web")
|
||||
result = ingest("https://example.com/feed.xml", source_type="feed")
|
||||
```
|
||||
@@ -1048,7 +1118,8 @@ from semantica.ingest.methods import (
|
||||
ingest_email,
|
||||
ingest_database,
|
||||
ingest_mcp,
|
||||
ingest_parquet
|
||||
ingest_parquet,
|
||||
ingest_xml,
|
||||
)
|
||||
|
||||
# File ingestion
|
||||
@@ -1075,6 +1146,9 @@ data = ingest_database("postgresql://user:pass@localhost/db", table="users")
|
||||
# Parquet ingestion
|
||||
events = ingest_parquet("events.parquet", columns=["event_id"], limit=1000)
|
||||
|
||||
# XML ingestion
|
||||
xml_data = ingest_xml("catalog.xml", schema_path="catalog.xsd")
|
||||
|
||||
# MCP server ingestion via URL
|
||||
data = ingest_mcp("http://localhost:8000/mcp", method="resources")
|
||||
```
|
||||
|
||||
+109
-2
@@ -19,6 +19,12 @@ Parquet Ingestion:
|
||||
- "schema": Parquet schema extraction
|
||||
- "metadata": Parquet file or directory metadata extraction
|
||||
|
||||
XML Ingestion:
|
||||
- "file": Single XML file ingestion with structured parsing
|
||||
- "directory": Directory ingestion for XML files
|
||||
- "metadata": XML structure and namespace metadata extraction
|
||||
- "validate": XSD and DTD validation report
|
||||
|
||||
Web Ingestion:
|
||||
- "url": Single URL ingestion
|
||||
- "sitemap": Sitemap-based crawling
|
||||
@@ -138,6 +144,7 @@ Main Functions:
|
||||
- ingest_email: Email ingestion wrapper
|
||||
- ingest_database: Database ingestion wrapper
|
||||
- ingest_parquet: Parquet ingestion wrapper
|
||||
- ingest_xml: XML ingestion wrapper
|
||||
- ingest: Unified ingestion function with source type dispatch
|
||||
- get_ingest_method: Get ingestion method by name
|
||||
- list_available_methods: List registered methods
|
||||
@@ -172,6 +179,7 @@ if TYPE_CHECKING:
|
||||
from .parquet_ingestor import ParquetData
|
||||
from .stream_ingestor import StreamProcessor
|
||||
from .web_ingestor import WebContent
|
||||
from .xml_ingestor import XMLIngestionData
|
||||
|
||||
logger = get_logger("ingest_methods")
|
||||
|
||||
@@ -333,6 +341,77 @@ def ingest_parquet(
|
||||
raise
|
||||
|
||||
|
||||
def ingest_xml(
|
||||
source: Union[str, Path, List[Union[str, Path]]],
|
||||
method: str = "file",
|
||||
**kwargs,
|
||||
) -> Union[
|
||||
XMLIngestionData,
|
||||
List[XMLIngestionData],
|
||||
Dict[str, Any],
|
||||
List[Dict[str, Any]],
|
||||
]:
|
||||
"""
|
||||
Ingest XML files from source (convenience function).
|
||||
|
||||
Args:
|
||||
source: XML file path, directory path, or list of XML file paths
|
||||
method: Ingestion method:
|
||||
- "file": Single XML file ingestion
|
||||
- "directory": Directory ingestion with recursive scanning
|
||||
- "metadata": Extract XML metadata without returning the full tree
|
||||
- "validate": Return XSD/DTD validation report
|
||||
**kwargs: Additional options passed to XMLIngestor
|
||||
|
||||
Returns:
|
||||
XMLIngestionData, list of XMLIngestionData, metadata dict, or validation dict
|
||||
|
||||
Examples:
|
||||
>>> from semantica.ingest.methods import ingest_xml
|
||||
>>> data = ingest_xml("catalog.xml")
|
||||
>>> report = ingest_xml(
|
||||
... "catalog.xml", method="validate", schema_path="catalog.xsd"
|
||||
... )
|
||||
"""
|
||||
custom_method = method_registry.get("xml", method)
|
||||
if custom_method and custom_method != ingest_xml:
|
||||
try:
|
||||
return custom_method(source, **kwargs)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Custom method {method} failed: {e}, falling back to default"
|
||||
)
|
||||
|
||||
try:
|
||||
from .xml_ingestor import XMLIngestor
|
||||
|
||||
config = ingest_config.get_method_config("xml")
|
||||
config.update(kwargs)
|
||||
ingestor = XMLIngestor(**config)
|
||||
|
||||
def _run_single(
|
||||
path: Union[str, Path],
|
||||
) -> Union[XMLIngestionData, Dict[str, Any]]:
|
||||
if method == "metadata":
|
||||
return ingestor.extract_metadata(path, **kwargs)
|
||||
if method in {"validate", "validation"}:
|
||||
return ingestor.validate_file(path, **kwargs)
|
||||
return ingestor.ingest_file(path, **kwargs)
|
||||
|
||||
if isinstance(source, list):
|
||||
return [_run_single(path) for path in source]
|
||||
|
||||
source_path = Path(source)
|
||||
if method == "directory" or source_path.is_dir():
|
||||
return ingestor.ingest_directory(source_path, **kwargs)
|
||||
|
||||
return _run_single(source_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ingest XML: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def ingest_web(
|
||||
source: Union[str, List[str]], method: str = "url", **kwargs
|
||||
) -> Union[WebContent, List[WebContent], Dict[str, Any]]:
|
||||
@@ -1013,11 +1092,19 @@ def ingest(
|
||||
- "db": Database ingestion
|
||||
- "ontology": Ontology ingestion
|
||||
- "parquet": Apache Parquet file or directory ingestion
|
||||
- "xml": XML file or directory ingestion
|
||||
method: Optional specific ingestion method
|
||||
**kwargs: Additional options passed to ingestor
|
||||
|
||||
Returns:
|
||||
Dict with ingestion results
|
||||
Dict with ingestion results. The top-level key depends on source_type:
|
||||
- "files": file ingestion
|
||||
- "content": web ingestion
|
||||
- "feeds": feed ingestion
|
||||
- "emails": email ingestion
|
||||
- "data": database, parquet, or MCP ingestion
|
||||
- "ontology": ontology ingestion
|
||||
- "xml": XML file or directory ingestion (use ``result["xml"]``)
|
||||
|
||||
Examples:
|
||||
>>> from semantica.ingest.methods import ingest
|
||||
@@ -1027,6 +1114,9 @@ def ingest(
|
||||
>>> result = ingest("https://example.com", source_type="web")
|
||||
>>> # Auto-detect from source
|
||||
>>> result = ingest("https://example.com/feed.xml") # Auto-detects feed
|
||||
>>> # XML ingestion — access via result["xml"]
|
||||
>>> result = ingest("catalog.xml")
|
||||
>>> xml_data = result["xml"]
|
||||
"""
|
||||
# Auto-detect source type if not specified
|
||||
if not source_type:
|
||||
@@ -1056,6 +1146,8 @@ def ingest(
|
||||
source_type = "ontology"
|
||||
elif source_str_lower.endswith((".parquet", ".pq")):
|
||||
source_type = "parquet"
|
||||
elif source_str_lower.endswith(".xml"):
|
||||
source_type = "xml"
|
||||
else:
|
||||
source_type = "file"
|
||||
elif (
|
||||
@@ -1066,6 +1158,12 @@ def ingest(
|
||||
)
|
||||
):
|
||||
source_type = "parquet"
|
||||
elif (
|
||||
isinstance(sources, list)
|
||||
and sources
|
||||
and all(str(source).lower().endswith(".xml") for source in sources)
|
||||
):
|
||||
source_type = "xml"
|
||||
else:
|
||||
source_type = "file"
|
||||
|
||||
@@ -1094,6 +1192,8 @@ def ingest(
|
||||
return {"data": ingest_database(sources, method=method, **kwargs)}
|
||||
elif source_type == "parquet":
|
||||
return {"data": ingest_parquet(sources, method=method or "file", **kwargs)}
|
||||
elif source_type == "xml":
|
||||
return {"xml": ingest_xml(sources, method=method or "file", **kwargs)}
|
||||
elif source_type == "ontology":
|
||||
return {"ontology": ingest_ontology(sources, method=method or "file", **kwargs)}
|
||||
elif source_type == "mcp":
|
||||
@@ -1108,7 +1208,7 @@ def get_ingest_method(task: str, name: str) -> Optional[Callable]:
|
||||
|
||||
Args:
|
||||
task: Task type ("file", "web", "feed", "stream", "repo", "email",
|
||||
"db", "mcp", "ingest")
|
||||
"db", "mcp", "parquet", "xml", "ingest")
|
||||
name: Method name
|
||||
|
||||
Returns:
|
||||
@@ -1178,6 +1278,13 @@ method_registry.register("parquet", "directory", ingest_parquet)
|
||||
method_registry.register("parquet", "schema", ingest_parquet)
|
||||
method_registry.register("parquet", "metadata", ingest_parquet)
|
||||
method_registry.register("file", "parquet", ingest_parquet)
|
||||
method_registry.register("xml", "default", ingest_xml)
|
||||
method_registry.register("xml", "file", ingest_xml)
|
||||
method_registry.register("xml", "directory", ingest_xml)
|
||||
method_registry.register("xml", "metadata", ingest_xml)
|
||||
method_registry.register("xml", "validate", ingest_xml)
|
||||
method_registry.register("xml", "validation", ingest_xml)
|
||||
method_registry.register("file", "xml", ingest_xml)
|
||||
method_registry.register("mcp", "default", ingest_mcp)
|
||||
method_registry.register("mcp", "resources", ingest_mcp)
|
||||
method_registry.register("mcp", "tools", ingest_mcp)
|
||||
|
||||
@@ -60,6 +60,7 @@ class MethodRegistry:
|
||||
"db": {},
|
||||
"mcp": {},
|
||||
"parquet": {},
|
||||
"xml": {},
|
||||
"ingest": {},
|
||||
}
|
||||
|
||||
@@ -70,7 +71,7 @@ class MethodRegistry:
|
||||
|
||||
Args:
|
||||
task: Task type such as "file", "web", "feed", "stream",
|
||||
"repo", "email", "db", "mcp", "parquet", or "ingest"
|
||||
"repo", "email", "db", "mcp", "parquet", "xml", or "ingest"
|
||||
name: Method name
|
||||
method_func: Method function
|
||||
"""
|
||||
@@ -85,7 +86,7 @@ class MethodRegistry:
|
||||
|
||||
Args:
|
||||
task: Task type such as "file", "web", "feed", "stream",
|
||||
"repo", "email", "db", "mcp", "parquet", or "ingest"
|
||||
"repo", "email", "db", "mcp", "parquet", "xml", or "ingest"
|
||||
name: Method name
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -0,0 +1,814 @@
|
||||
"""
|
||||
XML Ingestion Module
|
||||
|
||||
This module provides dedicated XML ingestion for local XML files and directories.
|
||||
It parses XML into structured dictionaries, extracts namespaces, element hierarchy,
|
||||
attributes, and document metadata, and can optionally validate documents with XSD
|
||||
schemas or DTD declarations.
|
||||
|
||||
Example Usage:
|
||||
>>> from semantica.ingest import XMLIngestor
|
||||
>>> ingestor = XMLIngestor()
|
||||
>>> data = ingestor.ingest_file("catalog.xml")
|
||||
>>> data.root_tag
|
||||
'catalog'
|
||||
>>> data.metadata["element_count"]
|
||||
12
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from lxml import etree
|
||||
|
||||
from ..utils.constants import FILE_SIZE_LIMITS
|
||||
from ..utils.exceptions import ProcessingError, ValidationError
|
||||
from ..utils.logging import get_logger
|
||||
from ..utils.progress_tracker import get_progress_tracker
|
||||
|
||||
XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace"
|
||||
|
||||
|
||||
@dataclass
|
||||
class XMLIngestionData:
|
||||
"""Structured XML ingestion result."""
|
||||
|
||||
root: Dict[str, Any]
|
||||
elements: List[Dict[str, Any]]
|
||||
namespaces: Dict[str, str]
|
||||
source_path: str
|
||||
root_tag: str
|
||||
validation: Dict[str, Any] = field(default_factory=dict)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
ingested_at: datetime = field(default_factory=datetime.now)
|
||||
|
||||
|
||||
class XMLIngestor:
|
||||
"""
|
||||
Dedicated XML file ingestion handler.
|
||||
|
||||
Features:
|
||||
- XML parsing into nested and flat structures
|
||||
- Namespace and prefix extraction
|
||||
- Element and attribute metadata extraction
|
||||
- Optional XSD schema validation
|
||||
- Optional DTD validation
|
||||
- Directory ingestion for local XML files
|
||||
"""
|
||||
|
||||
SUPPORTED_EXTENSIONS = {".xml"}
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None, **kwargs):
|
||||
"""
|
||||
Initialize XML ingestor.
|
||||
|
||||
Args:
|
||||
config: Optional configuration dictionary
|
||||
**kwargs: Additional configuration values
|
||||
"""
|
||||
self.logger = get_logger("xml_ingestor")
|
||||
self.config = config or {}
|
||||
self.config.update(kwargs)
|
||||
self.progress_tracker = get_progress_tracker()
|
||||
if not self.progress_tracker.enabled:
|
||||
self.progress_tracker.enabled = True
|
||||
|
||||
self.logger.debug("XML ingestor initialized")
|
||||
|
||||
def ingest(
|
||||
self, source: Union[str, Path], **options
|
||||
) -> Union[XMLIngestionData, List[XMLIngestionData]]:
|
||||
"""
|
||||
Ingest an XML file or directory.
|
||||
|
||||
Args:
|
||||
source: XML file or directory path
|
||||
**options: Additional ingestion options
|
||||
|
||||
Returns:
|
||||
XMLIngestionData for a file, or a list for a directory
|
||||
"""
|
||||
source_path = Path(source)
|
||||
if source_path.is_dir():
|
||||
return self.ingest_directory(source_path, **options)
|
||||
return self.ingest_file(source_path, **options)
|
||||
|
||||
def ingest_file(self, file_path: Union[str, Path], **options) -> XMLIngestionData:
|
||||
"""
|
||||
Ingest and parse a single XML file.
|
||||
|
||||
Args:
|
||||
file_path: Path to an XML file
|
||||
**options: Ingestion options:
|
||||
- schema_path/xsd_path/schema: Optional XSD schema file or XML string
|
||||
- validate_schema: Validate against XSD when schema is provided
|
||||
- validate_dtd: Validate against document DTD
|
||||
- fail_on_validation_error: Raise on validation failure (default: True)
|
||||
- include_tree: Include nested tree in result root (default: True)
|
||||
- include_elements: Include flat element list (default: True)
|
||||
- recover: Let lxml recover malformed XML when possible (default: False)
|
||||
- include_comments: Include XML comments in the tree (default: False)
|
||||
|
||||
Returns:
|
||||
XMLIngestionData: Parsed XML data and metadata
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
self._validate_file(file_path)
|
||||
|
||||
tracking_id = self.progress_tracker.start_tracking(
|
||||
file=str(file_path),
|
||||
module="ingest",
|
||||
submodule="XMLIngestor",
|
||||
message=f"Ingesting XML: {file_path.name}",
|
||||
)
|
||||
|
||||
try:
|
||||
xml_bytes = file_path.read_bytes()
|
||||
data = self._ingest_bytes(
|
||||
xml_bytes,
|
||||
source=str(file_path),
|
||||
source_type="file",
|
||||
file_path=file_path,
|
||||
**options,
|
||||
)
|
||||
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
status="completed",
|
||||
message=f"Ingested XML: {file_path.name}",
|
||||
)
|
||||
return data
|
||||
|
||||
except (ValidationError, ProcessingError):
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id, status="failed", message="XML ingestion failed"
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id, status="failed", message=str(exc)
|
||||
)
|
||||
self.logger.error(f"Failed to ingest XML {file_path}: {exc}")
|
||||
raise ProcessingError(f"Failed to ingest XML: {exc}") from exc
|
||||
|
||||
def ingest_string(
|
||||
self,
|
||||
xml_content: Union[str, bytes],
|
||||
source: str = "string",
|
||||
**options,
|
||||
) -> XMLIngestionData:
|
||||
"""
|
||||
Ingest XML content from a string or bytes object.
|
||||
|
||||
Args:
|
||||
xml_content: XML content
|
||||
source: Source label to include in metadata
|
||||
**options: Additional ingestion options
|
||||
|
||||
Returns:
|
||||
XMLIngestionData: Parsed XML data and metadata
|
||||
"""
|
||||
if isinstance(xml_content, str):
|
||||
xml_bytes = xml_content.encode(options.get("encoding", "utf-8"))
|
||||
else:
|
||||
xml_bytes = xml_content
|
||||
|
||||
return self._ingest_bytes(
|
||||
xml_bytes,
|
||||
source=source,
|
||||
source_type="string",
|
||||
file_path=None,
|
||||
**options,
|
||||
)
|
||||
|
||||
def ingest_directory(
|
||||
self,
|
||||
directory_path: Union[str, Path],
|
||||
recursive: bool = True,
|
||||
**options,
|
||||
) -> List[XMLIngestionData]:
|
||||
"""
|
||||
Ingest XML files from a directory.
|
||||
|
||||
Args:
|
||||
directory_path: Directory path
|
||||
recursive: Whether to search subdirectories
|
||||
**options: Additional ingestion options
|
||||
|
||||
Returns:
|
||||
List[XMLIngestionData]: Parsed XML files
|
||||
"""
|
||||
directory_path = Path(directory_path)
|
||||
if not directory_path.exists():
|
||||
raise ValidationError(f"XML directory not found: {directory_path}")
|
||||
if not directory_path.is_dir():
|
||||
raise ValidationError(f"Path is not a directory: {directory_path}")
|
||||
|
||||
tracking_id = self.progress_tracker.start_tracking(
|
||||
file=str(directory_path),
|
||||
module="ingest",
|
||||
submodule="XMLIngestor",
|
||||
message=f"Ingesting XML directory: {directory_path.name}",
|
||||
)
|
||||
|
||||
try:
|
||||
xml_files = self._xml_files(directory_path, recursive=recursive)
|
||||
results = []
|
||||
|
||||
self.progress_tracker.update_tracking(
|
||||
tracking_id, message=f"Processing {len(xml_files)} XML files"
|
||||
)
|
||||
|
||||
for index, xml_file in enumerate(xml_files, 1):
|
||||
try:
|
||||
results.append(self.ingest_file(xml_file, **options))
|
||||
self.progress_tracker.update_progress(
|
||||
tracking_id,
|
||||
processed=index,
|
||||
total=len(xml_files),
|
||||
message=f"Processing {index}/{len(xml_files)}: {xml_file.name}",
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.error(f"Failed to ingest XML file {xml_file}: {exc}")
|
||||
if self.config.get("fail_fast", False) or options.get(
|
||||
"fail_fast", False
|
||||
):
|
||||
raise ProcessingError(
|
||||
f"Failed to ingest XML file: {exc}"
|
||||
) from exc
|
||||
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id,
|
||||
status="completed",
|
||||
message=f"Ingested {len(results)} XML files",
|
||||
)
|
||||
return results
|
||||
|
||||
except Exception as exc:
|
||||
self.progress_tracker.stop_tracking(
|
||||
tracking_id, status="failed", message=str(exc)
|
||||
)
|
||||
raise
|
||||
|
||||
def validate_file(
|
||||
self,
|
||||
file_path: Union[str, Path],
|
||||
schema_path: Optional[Union[str, Path]] = None,
|
||||
validate_dtd: bool = False,
|
||||
**options,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate an XML file and return a validation report.
|
||||
|
||||
Args:
|
||||
file_path: XML file path
|
||||
schema_path: Optional XSD schema path
|
||||
validate_dtd: Whether to validate document DTD
|
||||
**options: Additional parser options
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: Validation report
|
||||
"""
|
||||
if schema_path is not None:
|
||||
options["schema_path"] = schema_path
|
||||
|
||||
schema_source = self._schema_source(options)
|
||||
options.setdefault("validate_schema", schema_source is not None)
|
||||
options["validate_dtd"] = validate_dtd
|
||||
options["fail_on_validation_error"] = False
|
||||
options.setdefault("include_tree", False)
|
||||
options.setdefault("include_elements", False)
|
||||
|
||||
return self.ingest_file(file_path, **options).validation
|
||||
|
||||
def extract_metadata(
|
||||
self, file_path: Union[str, Path], **options
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract XML document metadata without requiring callers to inspect the tree.
|
||||
|
||||
Args:
|
||||
file_path: XML file path
|
||||
**options: Additional ingestion options
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: XML metadata
|
||||
"""
|
||||
options.setdefault("include_tree", False)
|
||||
options.setdefault("include_elements", False)
|
||||
return self.ingest_file(file_path, **options).metadata
|
||||
|
||||
def _ingest_bytes(
|
||||
self,
|
||||
xml_bytes: bytes,
|
||||
source: str,
|
||||
source_type: str,
|
||||
file_path: Optional[Path],
|
||||
**options,
|
||||
) -> XMLIngestionData:
|
||||
tree, parser_errors = self._parse_tree(xml_bytes, source, options)
|
||||
validation = self._validate_tree(tree, source, options)
|
||||
|
||||
root = tree.getroot()
|
||||
namespaces = self._collect_namespaces(root)
|
||||
prefix_by_namespace = self._prefix_by_namespace(namespaces)
|
||||
root_data, elements, structure_metadata = self._build_structures(
|
||||
root,
|
||||
namespaces=namespaces,
|
||||
prefix_by_namespace=prefix_by_namespace,
|
||||
options=options,
|
||||
)
|
||||
|
||||
metadata = self._document_metadata(
|
||||
tree=tree,
|
||||
source=source,
|
||||
source_type=source_type,
|
||||
file_path=file_path,
|
||||
namespaces=namespaces,
|
||||
structure_metadata=structure_metadata,
|
||||
validation=validation,
|
||||
parser_errors=parser_errors,
|
||||
)
|
||||
|
||||
return XMLIngestionData(
|
||||
root=root_data,
|
||||
elements=elements,
|
||||
namespaces=namespaces,
|
||||
source_path=source,
|
||||
root_tag=root_data["tag"],
|
||||
validation=validation,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
def _parse_tree(
|
||||
self, xml_bytes: bytes, source: str, options: Dict[str, Any]
|
||||
) -> Tuple[Any, List[str]]:
|
||||
validate_dtd = bool(
|
||||
options.get("validate_dtd", self.config.get("validate_dtd", False))
|
||||
)
|
||||
parser = etree.XMLParser(
|
||||
remove_blank_text=bool(
|
||||
options.get(
|
||||
"remove_blank_text", self.config.get("remove_blank_text", True)
|
||||
)
|
||||
),
|
||||
remove_comments=not bool(options.get("include_comments", False)),
|
||||
resolve_entities=False,
|
||||
no_network=not bool(options.get("allow_network", False)),
|
||||
load_dtd=bool(options.get("load_dtd", validate_dtd)),
|
||||
dtd_validation=False,
|
||||
recover=bool(options.get("recover", self.config.get("recover", False))),
|
||||
huge_tree=bool(
|
||||
options.get("huge_tree", self.config.get("huge_tree", False))
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
tree = etree.parse(io.BytesIO(xml_bytes), parser)
|
||||
except etree.XMLSyntaxError as exc:
|
||||
message = self._format_xml_error(exc)
|
||||
raise ProcessingError(f"Malformed XML in {source}: {message}") from exc
|
||||
|
||||
parser_errors = [str(error) for error in parser.error_log]
|
||||
return tree, parser_errors
|
||||
|
||||
def _validate_tree(
|
||||
self, tree: Any, source: str, options: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
schema_source = self._schema_source(options)
|
||||
validate_schema_option = options.get("validate_schema")
|
||||
validate_schema = (
|
||||
schema_source is not None
|
||||
if validate_schema_option is None
|
||||
else bool(validate_schema_option)
|
||||
)
|
||||
validate_dtd = bool(
|
||||
options.get("validate_dtd", self.config.get("validate_dtd", False))
|
||||
)
|
||||
fail_on_error = bool(options.get("fail_on_validation_error", True))
|
||||
|
||||
validation = {
|
||||
"is_valid": True,
|
||||
"schema": {
|
||||
"validated": False,
|
||||
"valid": None,
|
||||
"source": self._schema_label(schema_source),
|
||||
"errors": [],
|
||||
},
|
||||
"dtd": {
|
||||
"validated": False,
|
||||
"valid": None,
|
||||
"name": None,
|
||||
"system_url": None,
|
||||
"public_id": None,
|
||||
"errors": [],
|
||||
},
|
||||
}
|
||||
|
||||
if validate_schema:
|
||||
if schema_source is None:
|
||||
raise ValidationError(
|
||||
"XML schema validation requested but no XSD schema was provided",
|
||||
validation_context={"source": source},
|
||||
)
|
||||
|
||||
schema = self._load_schema(schema_source)
|
||||
schema_valid = schema.validate(tree)
|
||||
schema_errors = [str(error) for error in schema.error_log]
|
||||
validation["schema"].update(
|
||||
{
|
||||
"validated": True,
|
||||
"valid": schema_valid,
|
||||
"errors": schema_errors,
|
||||
}
|
||||
)
|
||||
validation["is_valid"] = validation["is_valid"] and schema_valid
|
||||
|
||||
if not schema_valid and fail_on_error:
|
||||
raise ValidationError(
|
||||
self._validation_message(
|
||||
"XML schema validation failed", source, schema_errors
|
||||
),
|
||||
validation_context={"source": source},
|
||||
errors=schema_errors,
|
||||
)
|
||||
|
||||
if validate_dtd:
|
||||
dtd = tree.docinfo.internalDTD or tree.docinfo.externalDTD
|
||||
if dtd is None:
|
||||
dtd_valid = False
|
||||
dtd_errors = ["No DTD declaration found in XML document."]
|
||||
else:
|
||||
dtd_valid = dtd.validate(tree)
|
||||
dtd_errors = [str(error) for error in dtd.error_log]
|
||||
validation["dtd"].update(
|
||||
{
|
||||
"name": getattr(dtd, "name", None),
|
||||
"system_url": getattr(dtd, "system_url", None),
|
||||
"public_id": getattr(dtd, "public_id", None),
|
||||
}
|
||||
)
|
||||
|
||||
validation["dtd"].update(
|
||||
{"validated": True, "valid": dtd_valid, "errors": dtd_errors}
|
||||
)
|
||||
validation["is_valid"] = validation["is_valid"] and dtd_valid
|
||||
|
||||
if not dtd_valid and fail_on_error:
|
||||
raise ValidationError(
|
||||
self._validation_message(
|
||||
"XML DTD validation failed", source, dtd_errors
|
||||
),
|
||||
validation_context={"source": source},
|
||||
errors=dtd_errors,
|
||||
)
|
||||
|
||||
return validation
|
||||
|
||||
def _build_structures(
|
||||
self,
|
||||
root: Any,
|
||||
namespaces: Dict[str, str],
|
||||
prefix_by_namespace: Dict[str, str],
|
||||
options: Dict[str, Any],
|
||||
) -> Tuple[Dict[str, Any], List[Dict[str, Any]], Dict[str, Any]]:
|
||||
elements: List[Dict[str, Any]] = []
|
||||
stats = {
|
||||
"element_count": 0,
|
||||
"attribute_count": 0,
|
||||
"max_depth": 0,
|
||||
"tag_counts": {},
|
||||
"element_limit_reached": False,
|
||||
}
|
||||
|
||||
root_data = self._element_to_dict(
|
||||
root,
|
||||
parent_path="",
|
||||
depth=0,
|
||||
namespaces=namespaces,
|
||||
prefix_by_namespace=prefix_by_namespace,
|
||||
elements=elements,
|
||||
stats=stats,
|
||||
options=options,
|
||||
)
|
||||
|
||||
unique_tags = sorted(stats["tag_counts"].keys())
|
||||
metadata = {
|
||||
"element_count": stats["element_count"],
|
||||
"attribute_count": stats["attribute_count"],
|
||||
"max_depth": stats["max_depth"],
|
||||
"unique_tags": unique_tags,
|
||||
"tag_counts": stats["tag_counts"],
|
||||
"flat_element_count": len(elements),
|
||||
"element_limit_reached": stats["element_limit_reached"],
|
||||
}
|
||||
|
||||
return root_data, elements, metadata
|
||||
|
||||
def _element_to_dict(
|
||||
self,
|
||||
element: Any,
|
||||
parent_path: str,
|
||||
depth: int,
|
||||
namespaces: Dict[str, str],
|
||||
prefix_by_namespace: Dict[str, str],
|
||||
elements: List[Dict[str, Any]],
|
||||
stats: Dict[str, Any],
|
||||
options: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
tag_info = self._name_info(element.tag, prefix_by_namespace)
|
||||
path = (
|
||||
f"{parent_path}/{tag_info['tag']}" if parent_path else f"/{tag_info['tag']}"
|
||||
)
|
||||
attributes, attribute_details = self._attributes_to_dict(
|
||||
element.attrib,
|
||||
prefix_by_namespace,
|
||||
)
|
||||
text = element.text or ""
|
||||
if options.get("strip_text", True):
|
||||
text = text.strip()
|
||||
|
||||
child_elements = [child for child in element if isinstance(child.tag, str)]
|
||||
|
||||
element_data = {
|
||||
"tag": tag_info["tag"],
|
||||
"local_name": tag_info["local_name"],
|
||||
"namespace": tag_info["namespace"],
|
||||
"prefix": tag_info["prefix"],
|
||||
"text": text,
|
||||
"attributes": attributes,
|
||||
"attribute_details": attribute_details,
|
||||
"path": path,
|
||||
"depth": depth,
|
||||
"child_count": len(child_elements),
|
||||
}
|
||||
|
||||
stats["element_count"] += 1
|
||||
stats["attribute_count"] += len(attributes)
|
||||
stats["max_depth"] = max(stats["max_depth"], depth)
|
||||
stats["tag_counts"][tag_info["tag"]] = (
|
||||
stats["tag_counts"].get(tag_info["tag"], 0) + 1
|
||||
)
|
||||
|
||||
if options.get("include_elements", True):
|
||||
max_elements = options.get("max_elements")
|
||||
if max_elements is None or len(elements) < max_elements:
|
||||
flat_element = dict(element_data)
|
||||
flat_element.pop("children", None)
|
||||
elements.append(flat_element)
|
||||
else:
|
||||
stats["element_limit_reached"] = True
|
||||
|
||||
children = [
|
||||
self._element_to_dict(
|
||||
child,
|
||||
parent_path=path,
|
||||
depth=depth + 1,
|
||||
namespaces=namespaces,
|
||||
prefix_by_namespace=prefix_by_namespace,
|
||||
elements=elements,
|
||||
stats=stats,
|
||||
options=options,
|
||||
)
|
||||
for child in child_elements
|
||||
]
|
||||
|
||||
if options.get("include_tree", True):
|
||||
element_data["children"] = children
|
||||
|
||||
return element_data
|
||||
|
||||
def _attributes_to_dict(
|
||||
self, attributes: Dict[str, str], prefix_by_namespace: Dict[str, str]
|
||||
) -> Tuple[Dict[str, str], Dict[str, Dict[str, Optional[str]]]]:
|
||||
values = {}
|
||||
details = {}
|
||||
|
||||
for raw_name, value in attributes.items():
|
||||
name_info = self._name_info(raw_name, prefix_by_namespace)
|
||||
display_name = name_info["tag"]
|
||||
values[display_name] = value
|
||||
details[display_name] = {
|
||||
"value": value,
|
||||
"local_name": name_info["local_name"],
|
||||
"namespace": name_info["namespace"],
|
||||
"prefix": name_info["prefix"],
|
||||
}
|
||||
|
||||
return values, details
|
||||
|
||||
def _name_info(
|
||||
self, raw_name: Any, prefix_by_namespace: Dict[str, str]
|
||||
) -> Dict[str, Optional[str]]:
|
||||
if not isinstance(raw_name, str):
|
||||
return {
|
||||
"tag": str(raw_name),
|
||||
"local_name": str(raw_name),
|
||||
"namespace": None,
|
||||
"prefix": None,
|
||||
}
|
||||
|
||||
if raw_name.startswith("{"):
|
||||
qname = etree.QName(raw_name)
|
||||
namespace = qname.namespace
|
||||
local_name = qname.localname
|
||||
else:
|
||||
namespace = None
|
||||
local_name = raw_name
|
||||
|
||||
prefix = prefix_by_namespace.get(namespace) if namespace else None
|
||||
if namespace == XML_NAMESPACE:
|
||||
prefix = "xml"
|
||||
|
||||
if prefix and prefix != "default":
|
||||
tag = f"{prefix}:{local_name}"
|
||||
else:
|
||||
tag = local_name
|
||||
|
||||
return {
|
||||
"tag": tag,
|
||||
"local_name": local_name,
|
||||
"namespace": namespace,
|
||||
"prefix": prefix,
|
||||
}
|
||||
|
||||
def _collect_namespaces(self, root: Any) -> Dict[str, str]:
|
||||
namespaces: Dict[str, str] = {}
|
||||
|
||||
for element in root.iter():
|
||||
if not isinstance(element.tag, str):
|
||||
continue
|
||||
|
||||
for prefix, uri in (element.nsmap or {}).items():
|
||||
if not uri:
|
||||
continue
|
||||
key = prefix or "default"
|
||||
self._add_namespace(namespaces, key, uri)
|
||||
|
||||
for raw_name in element.attrib:
|
||||
if isinstance(raw_name, str) and raw_name.startswith(
|
||||
f"{{{XML_NAMESPACE}}}"
|
||||
):
|
||||
self._add_namespace(namespaces, "xml", XML_NAMESPACE)
|
||||
|
||||
return namespaces
|
||||
|
||||
def _add_namespace(self, namespaces: Dict[str, str], prefix: str, uri: str) -> None:
|
||||
if prefix not in namespaces:
|
||||
namespaces[prefix] = uri
|
||||
return
|
||||
|
||||
if namespaces[prefix] == uri:
|
||||
return
|
||||
|
||||
index = 2
|
||||
while f"{prefix}_{index}" in namespaces:
|
||||
index += 1
|
||||
namespaces[f"{prefix}_{index}"] = uri
|
||||
|
||||
def _prefix_by_namespace(self, namespaces: Dict[str, str]) -> Dict[str, str]:
|
||||
prefix_by_namespace: Dict[str, str] = {}
|
||||
for prefix, uri in namespaces.items():
|
||||
prefix_by_namespace.setdefault(uri, prefix)
|
||||
return prefix_by_namespace
|
||||
|
||||
def _document_metadata(
|
||||
self,
|
||||
tree: Any,
|
||||
source: str,
|
||||
source_type: str,
|
||||
file_path: Optional[Path],
|
||||
namespaces: Dict[str, str],
|
||||
structure_metadata: Dict[str, Any],
|
||||
validation: Dict[str, Any],
|
||||
parser_errors: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
root = tree.getroot()
|
||||
docinfo = tree.docinfo
|
||||
has_dtd = bool(docinfo.internalDTD or docinfo.externalDTD)
|
||||
prefix_by_namespace = self._prefix_by_namespace(namespaces)
|
||||
root_info = self._name_info(root.tag, prefix_by_namespace)
|
||||
|
||||
metadata = {
|
||||
"format": "xml",
|
||||
"source": source,
|
||||
"source_type": source_type,
|
||||
"root_tag": root_info["tag"],
|
||||
"root_element": root_info["local_name"],
|
||||
"root_namespace": root_info["namespace"],
|
||||
"root_prefix": root_info["prefix"],
|
||||
"namespace_count": len(namespaces),
|
||||
"namespaces": namespaces,
|
||||
"has_dtd": has_dtd,
|
||||
"dtd_name": docinfo.root_name,
|
||||
"dtd_system_url": docinfo.system_url,
|
||||
"dtd_public_id": docinfo.public_id,
|
||||
"xml_version": docinfo.xml_version,
|
||||
"encoding": docinfo.encoding,
|
||||
"parser_errors": parser_errors,
|
||||
"schema_validated": validation["schema"]["validated"],
|
||||
"dtd_validated": validation["dtd"]["validated"],
|
||||
"validation_passed": validation["is_valid"],
|
||||
**structure_metadata,
|
||||
}
|
||||
|
||||
if file_path is not None:
|
||||
metadata.update(
|
||||
{
|
||||
"file": str(file_path),
|
||||
"file_name": file_path.name,
|
||||
"file_size": file_path.stat().st_size,
|
||||
"extension": file_path.suffix,
|
||||
}
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
def _schema_source(self, options: Dict[str, Any]) -> Any:
|
||||
return (
|
||||
options.get("schema_path")
|
||||
or options.get("xsd_path")
|
||||
or options.get("schema")
|
||||
or self.config.get("schema_path")
|
||||
or self.config.get("xsd_path")
|
||||
or self.config.get("schema")
|
||||
)
|
||||
|
||||
def _schema_label(self, schema_source: Any) -> Optional[str]:
|
||||
if schema_source is None:
|
||||
return None
|
||||
if isinstance(schema_source, (str, Path)):
|
||||
text = str(schema_source)
|
||||
if text.lstrip().startswith("<"):
|
||||
return "inline"
|
||||
return text
|
||||
return type(schema_source).__name__
|
||||
|
||||
def _load_schema(self, schema_source: Any) -> Any:
|
||||
if isinstance(schema_source, etree.XMLSchema):
|
||||
return schema_source
|
||||
|
||||
parser = etree.XMLParser(resolve_entities=False, no_network=True)
|
||||
|
||||
if isinstance(schema_source, Path):
|
||||
if not schema_source.exists():
|
||||
raise ValidationError(f"XSD schema file not found: {schema_source}")
|
||||
schema_doc = etree.parse(str(schema_source), parser)
|
||||
return etree.XMLSchema(schema_doc)
|
||||
|
||||
if isinstance(schema_source, bytes):
|
||||
schema_doc = etree.fromstring(schema_source, parser)
|
||||
return etree.XMLSchema(schema_doc)
|
||||
|
||||
if isinstance(schema_source, str):
|
||||
if schema_source.lstrip().startswith("<"):
|
||||
schema_doc = etree.fromstring(schema_source.encode("utf-8"), parser)
|
||||
return etree.XMLSchema(schema_doc)
|
||||
|
||||
schema_path = Path(schema_source)
|
||||
if not schema_path.exists():
|
||||
raise ValidationError(f"XSD schema file not found: {schema_source}")
|
||||
schema_doc = etree.parse(str(schema_path), parser)
|
||||
return etree.XMLSchema(schema_doc)
|
||||
|
||||
raise ValidationError(
|
||||
f"Unsupported XSD schema source: {type(schema_source).__name__}"
|
||||
)
|
||||
|
||||
def _validation_message(self, prefix: str, source: str, errors: List[str]) -> str:
|
||||
first_error = errors[0] if errors else "No detailed validation error available."
|
||||
return f"{prefix} for {source}: {first_error}"
|
||||
|
||||
def _format_xml_error(self, exc: etree.XMLSyntaxError) -> str:
|
||||
if exc.error_log:
|
||||
return str(exc.error_log.last_error)
|
||||
return str(exc)
|
||||
|
||||
def _validate_file(self, file_path: Path) -> None:
|
||||
if not file_path.exists():
|
||||
raise ValidationError(f"XML file not found: {file_path}")
|
||||
if not file_path.is_file():
|
||||
raise ValidationError(f"Path is not a file: {file_path}")
|
||||
if file_path.suffix.lower() not in self.SUPPORTED_EXTENSIONS:
|
||||
raise ValidationError(f"File is not an XML file: {file_path}")
|
||||
|
||||
max_size = FILE_SIZE_LIMITS.get("MAX_DOCUMENT_SIZE", 104857600)
|
||||
file_size = file_path.stat().st_size
|
||||
if file_size > max_size:
|
||||
raise ValidationError(
|
||||
f"File size {file_size:,} bytes exceeds maximum {max_size:,} bytes "
|
||||
f"({file_path.name})"
|
||||
)
|
||||
|
||||
def _xml_files(self, directory_path: Path, recursive: bool) -> List[Path]:
|
||||
iterator = directory_path.rglob("*") if recursive else directory_path.iterdir()
|
||||
return sorted(
|
||||
path
|
||||
for path in iterator
|
||||
if path.is_file() and path.suffix.lower() in self.SUPPORTED_EXTENSIONS
|
||||
)
|
||||
@@ -0,0 +1,199 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from semantica.ingest import (
|
||||
XMLIngestionData,
|
||||
XMLIngestor,
|
||||
ingest,
|
||||
ingest_file,
|
||||
ingest_xml,
|
||||
)
|
||||
from semantica.ingest.xml_ingestor import XML_NAMESPACE
|
||||
from semantica.utils.exceptions import ProcessingError, ValidationError
|
||||
|
||||
|
||||
def test_xml_ingestor_extracts_structure_namespaces_and_attributes(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
xml_file = tmp_path / "catalog.xml"
|
||||
xml_file.write_text(
|
||||
"""<?xml version="1.0"?>
|
||||
<catalog xmlns="https://example.com/catalog" xmlns:bk="https://example.com/book">
|
||||
<bk:book id="b1" xml:lang="en">
|
||||
<title>Semantica</title>
|
||||
<bk:author role="lead">Ada</bk:author>
|
||||
</bk:book>
|
||||
</catalog>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
data = XMLIngestor().ingest_file(xml_file)
|
||||
|
||||
assert isinstance(data, XMLIngestionData)
|
||||
assert data.root_tag == "catalog"
|
||||
assert data.namespaces["default"] == "https://example.com/catalog"
|
||||
assert data.namespaces["bk"] == "https://example.com/book"
|
||||
assert data.namespaces["xml"] == XML_NAMESPACE
|
||||
assert data.root["children"][0]["tag"] == "bk:book"
|
||||
assert data.root["children"][0]["attributes"]["id"] == "b1"
|
||||
assert data.root["children"][0]["attribute_details"]["xml:lang"]["value"] == "en"
|
||||
assert (
|
||||
data.root["children"][0]["attribute_details"]["xml:lang"]["namespace"]
|
||||
== XML_NAMESPACE
|
||||
)
|
||||
assert data.elements[0]["tag"] == "catalog"
|
||||
assert data.elements[1]["tag"] == "bk:book"
|
||||
assert data.metadata["element_count"] == 4
|
||||
assert data.metadata["attribute_count"] == 3
|
||||
assert data.metadata["root_namespace"] == "https://example.com/catalog"
|
||||
assert data.metadata["tag_counts"]["bk:author"] == 1
|
||||
|
||||
|
||||
def test_xml_ingestor_validates_xsd_schema(tmp_path: Path) -> None:
|
||||
xml_file = tmp_path / "catalog.xml"
|
||||
xml_file.write_text(
|
||||
"""<catalog>
|
||||
<book id="b1">
|
||||
<title>Semantica</title>
|
||||
</book>
|
||||
</catalog>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
xsd_file = tmp_path / "catalog.xsd"
|
||||
xsd_file.write_text(
|
||||
"""<?xml version="1.0"?>
|
||||
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="catalog">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="book" maxOccurs="unbounded">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="title" type="xs:string"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="id" type="xs:string" use="required"/>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
data = XMLIngestor().ingest_file(xml_file, schema_path=xsd_file)
|
||||
|
||||
assert data.validation["is_valid"] is True
|
||||
assert data.validation["schema"]["validated"] is True
|
||||
assert data.validation["schema"]["valid"] is True
|
||||
|
||||
|
||||
def test_xml_ingestor_reports_schema_validation_errors(tmp_path: Path) -> None:
|
||||
xml_file = tmp_path / "catalog.xml"
|
||||
xml_file.write_text("<catalog><book id='b1'/></catalog>", encoding="utf-8")
|
||||
xsd_file = tmp_path / "catalog.xsd"
|
||||
xsd_file.write_text(
|
||||
"""<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
|
||||
<xs:element name="catalog">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="book">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:element name="title" type="xs:string"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="id" type="xs:string" use="required"/>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:schema>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = XMLIngestor().validate_file(xml_file, schema_path=xsd_file)
|
||||
|
||||
assert report["is_valid"] is False
|
||||
assert report["schema"]["validated"] is True
|
||||
assert report["schema"]["valid"] is False
|
||||
assert report["schema"]["errors"]
|
||||
|
||||
with pytest.raises(ValidationError, match="XML schema validation failed"):
|
||||
XMLIngestor().ingest_file(xml_file, schema_path=xsd_file)
|
||||
|
||||
|
||||
def test_xml_ingestor_validates_internal_dtd(tmp_path: Path) -> None:
|
||||
xml_file = tmp_path / "note.xml"
|
||||
xml_file.write_text(
|
||||
"""<!DOCTYPE note [
|
||||
<!ELEMENT note (to,from)>
|
||||
<!ELEMENT to (#PCDATA)>
|
||||
<!ELEMENT from (#PCDATA)>
|
||||
]>
|
||||
<note><to>Ada</to><from>Grace</from></note>
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
report = XMLIngestor().validate_file(xml_file, validate_dtd=True)
|
||||
|
||||
assert report["is_valid"] is True
|
||||
assert report["dtd"]["validated"] is True
|
||||
assert report["dtd"]["valid"] is True
|
||||
assert report["dtd"]["name"] == "note"
|
||||
|
||||
|
||||
def test_xml_ingestor_rejects_malformed_xml(tmp_path: Path) -> None:
|
||||
xml_file = tmp_path / "broken.xml"
|
||||
xml_file.write_text("<root><item></root>", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ProcessingError, match="Malformed XML"):
|
||||
XMLIngestor().ingest_file(xml_file)
|
||||
|
||||
|
||||
def test_xml_ingest_methods_and_unified_dispatch(tmp_path: Path) -> None:
|
||||
xml_file = tmp_path / "catalog.xml"
|
||||
xml_file.write_text(
|
||||
"<catalog><book id='b1'>Semantica</book></catalog>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
direct = ingest_xml(xml_file)
|
||||
metadata = ingest_xml(xml_file, method="metadata")
|
||||
via_file_method = ingest_file(xml_file, method="xml")
|
||||
unified = ingest(xml_file)
|
||||
|
||||
assert isinstance(direct, XMLIngestionData)
|
||||
assert metadata["root_tag"] == "catalog"
|
||||
assert isinstance(via_file_method, XMLIngestionData)
|
||||
assert isinstance(unified["xml"], XMLIngestionData)
|
||||
|
||||
|
||||
def test_xml_ingestor_ingests_string() -> None:
|
||||
data = XMLIngestor().ingest_string("<root><item id='1'>hello</item></root>")
|
||||
|
||||
assert isinstance(data, XMLIngestionData)
|
||||
assert data.root_tag == "root"
|
||||
assert data.metadata["source_type"] == "string"
|
||||
assert data.elements[1]["attributes"]["id"] == "1"
|
||||
assert data.elements[1]["text"] == "hello"
|
||||
|
||||
|
||||
def test_xml_ingestor_ingests_directory(tmp_path: Path) -> None:
|
||||
(tmp_path / "one.xml").write_text("<root><one /></root>", encoding="utf-8")
|
||||
(tmp_path / "two.xml").write_text("<root><two /></root>", encoding="utf-8")
|
||||
(tmp_path / "skip.txt").write_text("<root />", encoding="utf-8")
|
||||
|
||||
results = ingest_xml(tmp_path, method="directory", recursive=False)
|
||||
|
||||
assert len(results) == 2
|
||||
assert {result.metadata["file_name"] for result in results} == {
|
||||
"one.xml",
|
||||
"two.xml",
|
||||
}
|
||||
Reference in New Issue
Block a user