2024-08-01 15:24:17 -04:00
|
|
|
"""Test suite for `unstructured.partition.email` module."""
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
2023-01-17 16:36:44 -06:00
|
|
|
import datetime
|
2023-01-03 11:41:54 -06:00
|
|
|
import email
|
2022-12-19 13:02:44 -05:00
|
|
|
import os
|
|
|
|
import pathlib
|
2024-07-19 11:18:02 -07:00
|
|
|
import tempfile
|
2024-08-01 15:24:17 -04:00
|
|
|
from email import policy
|
|
|
|
from email.message import EmailMessage
|
|
|
|
from typing import cast
|
2022-12-19 13:02:44 -05:00
|
|
|
|
2023-02-27 17:30:54 +01:00
|
|
|
import pytest
|
2024-08-01 15:24:17 -04:00
|
|
|
from pytest_mock import MockFixture
|
2023-01-17 16:36:44 -06:00
|
|
|
|
Dynamic ElementMetadata implementation (#2043)
### Executive Summary
The structure of element metadata is currently static, meaning only
predefined fields can appear in the metadata. We would like the
flexibility for end-users, at their own discretion, to define and use
additional metadata fields that make sense for their particular
use-case.
### Concepts
A key concept for dynamic metadata is _known field_. A known-field is
one of those explicitly defined on `ElementMetadata`. Each of these has
a type and can be specified when _constructing_ a new `ElementMetadata`
instance. This is in contrast to an _end-user defined_ (or _ad-hoc_)
metadata field, one not known at "compile" time and added at the
discretion of an end-user to suit the purposes of their application.
An ad-hoc field can only be added by _assignment_ on an already
constructed instance.
### End-user ad-hoc metadata field behaviors
An ad-hoc field can be added to an `ElementMetadata` instance by
assignment:
```python
>>> metadata = ElementMetadata()
>>> metadata.coefficient = 0.536
```
A field added in this way can be accessed by name:
```python
>>> metadata.coefficient
0.536
```
and that field will appear in the JSON/dict for that instance:
```python
>>> metadata = ElementMetadata()
>>> metadata.coefficient = 0.536
>>> metadata.to_dict()
{"coefficient": 0.536}
```
However, accessing a "user-defined" value that has _not_ been assigned
on that instance raises `AttributeError`:
```python
>>> metadata.coeffcient # -- misspelled "coefficient" --
AttributeError: 'ElementMetadata' object has no attribute 'coeffcient'
```
This makes "tagging" a metadata item with a value very convenient, but
entails the proviso that if an end-user wants to add a metadata field to
_some_ elements and not others (sparse population), AND they want to
access that field by name on ANY element and receive `None` where it has
not been assigned, they will need to use an expression like this:
```python
coefficient = metadata.coefficient if hasattr(metadata, "coefficient") else None
```
### Implementation Notes
- **ad-hoc metadata fields** are discarded during consolidation (for
chunking) because we don't have a consolidation strategy defined for
those. We could consider using a default consolidation strategy like
`FIRST` or possibly allow a user to register a strategy (although that
gets hairy in non-private and multiple-memory-space situations.)
- ad-hoc metadata fields **cannot start with an underscore**.
- We have no way to distinguish an ad-hoc field from any "noise" fields
that might appear in a JSON/dict loaded using `.from_dict()`, so unlike
the original (which only loaded known-fields), we'll rehydrate anything
that we find there.
- No real type-safety is possible on ad-hoc fields but the type-checker
does not complain because the type of all ad-hoc fields is `Any` (which
is the best available behavior in my view).
- We may want to consider whether end-users should be able to add ad-hoc
fields to "sub" metadata objects too, like `DataSourceMetadata` and
conceivably `CoordinatesMetadata` (although I'm not immediately seeing a
use-case for the second one).
2023-11-15 13:22:15 -08:00
|
|
|
from test_unstructured.unit_utils import (
|
2024-08-01 15:24:17 -04:00
|
|
|
LogCaptureFixture,
|
Dynamic ElementMetadata implementation (#2043)
### Executive Summary
The structure of element metadata is currently static, meaning only
predefined fields can appear in the metadata. We would like the
flexibility for end-users, at their own discretion, to define and use
additional metadata fields that make sense for their particular
use-case.
### Concepts
A key concept for dynamic metadata is _known field_. A known-field is
one of those explicitly defined on `ElementMetadata`. Each of these has
a type and can be specified when _constructing_ a new `ElementMetadata`
instance. This is in contrast to an _end-user defined_ (or _ad-hoc_)
metadata field, one not known at "compile" time and added at the
discretion of an end-user to suit the purposes of their application.
An ad-hoc field can only be added by _assignment_ on an already
constructed instance.
### End-user ad-hoc metadata field behaviors
An ad-hoc field can be added to an `ElementMetadata` instance by
assignment:
```python
>>> metadata = ElementMetadata()
>>> metadata.coefficient = 0.536
```
A field added in this way can be accessed by name:
```python
>>> metadata.coefficient
0.536
```
and that field will appear in the JSON/dict for that instance:
```python
>>> metadata = ElementMetadata()
>>> metadata.coefficient = 0.536
>>> metadata.to_dict()
{"coefficient": 0.536}
```
However, accessing a "user-defined" value that has _not_ been assigned
on that instance raises `AttributeError`:
```python
>>> metadata.coeffcient # -- misspelled "coefficient" --
AttributeError: 'ElementMetadata' object has no attribute 'coeffcient'
```
This makes "tagging" a metadata item with a value very convenient, but
entails the proviso that if an end-user wants to add a metadata field to
_some_ elements and not others (sparse population), AND they want to
access that field by name on ANY element and receive `None` where it has
not been assigned, they will need to use an expression like this:
```python
coefficient = metadata.coefficient if hasattr(metadata, "coefficient") else None
```
### Implementation Notes
- **ad-hoc metadata fields** are discarded during consolidation (for
chunking) because we don't have a consolidation strategy defined for
those. We could consider using a default consolidation strategy like
`FIRST` or possibly allow a user to register a strategy (although that
gets hairy in non-private and multiple-memory-space situations.)
- ad-hoc metadata fields **cannot start with an underscore**.
- We have no way to distinguish an ad-hoc field from any "noise" fields
that might appear in a JSON/dict loaded using `.from_dict()`, so unlike
the original (which only loaded known-fields), we'll rehydrate anything
that we find there.
- No real type-safety is possible on ad-hoc fields but the type-checker
does not complain because the type of all ad-hoc fields is `Any` (which
is the best available behavior in my view).
- We may want to consider whether end-users should be able to add ad-hoc
fields to "sub" metadata objects too, like `DataSourceMetadata` and
conceivably `CoordinatesMetadata` (although I'm not immediately seeing a
use-case for the second one).
2023-11-15 13:22:15 -08:00
|
|
|
assert_round_trips_through_JSON,
|
|
|
|
example_doc_path,
|
|
|
|
parse_optional_datetime,
|
|
|
|
)
|
2023-09-11 16:00:14 -05:00
|
|
|
from unstructured.chunking.title import chunk_by_title
|
2023-04-04 14:23:41 -04:00
|
|
|
from unstructured.documents.elements import (
|
2024-08-01 15:24:17 -04:00
|
|
|
Element,
|
2023-04-04 14:23:41 -04:00
|
|
|
ElementMetadata,
|
|
|
|
Image,
|
|
|
|
ListItem,
|
|
|
|
NarrativeText,
|
2023-08-14 11:38:53 -07:00
|
|
|
Text,
|
2023-04-04 14:23:41 -04:00
|
|
|
Title,
|
|
|
|
)
|
2023-01-09 11:08:08 -06:00
|
|
|
from unstructured.documents.email_elements import (
|
|
|
|
MetaData,
|
2023-02-27 17:30:54 +01:00
|
|
|
ReceivedInfo,
|
2023-01-09 11:08:08 -06:00
|
|
|
Recipient,
|
|
|
|
Sender,
|
|
|
|
Subject,
|
|
|
|
)
|
|
|
|
from unstructured.partition.email import (
|
2023-05-11 10:36:25 -04:00
|
|
|
convert_to_iso_8601,
|
2023-01-09 11:08:08 -06:00
|
|
|
extract_attachment_info,
|
|
|
|
partition_email,
|
|
|
|
partition_email_header,
|
|
|
|
)
|
2023-06-29 18:01:12 -04:00
|
|
|
from unstructured.partition.text import partition_text
|
2022-12-19 13:02:44 -05:00
|
|
|
|
|
|
|
EXPECTED_OUTPUT = [
|
|
|
|
NarrativeText(text="This is a test email to use for unit tests."),
|
|
|
|
Title(text="Important points:"),
|
|
|
|
ListItem(text="Roses are red"),
|
|
|
|
ListItem(text="Violets are blue"),
|
|
|
|
]
|
|
|
|
|
2023-01-09 19:49:19 -06:00
|
|
|
IMAGE_EXPECTED_OUTPUT = [
|
|
|
|
NarrativeText(text="This is a test email to use for unit tests."),
|
|
|
|
Title(text="Important points:"),
|
|
|
|
NarrativeText(text="hello this is our logo."),
|
|
|
|
Image(text="unstructured_logo.png"),
|
|
|
|
ListItem(text="Roses are red"),
|
|
|
|
ListItem(text="Violets are blue"),
|
|
|
|
]
|
|
|
|
|
2023-01-17 16:36:44 -06:00
|
|
|
RECEIVED_HEADER_OUTPUT = [
|
|
|
|
ReceivedInfo(name="ABCDEFG-000.ABC.guide", text="00.0.0.00"),
|
|
|
|
ReceivedInfo(name="ABCDEFG-000.ABC.guide", text="ba23::58b5:2236:45g2:88h2"),
|
|
|
|
ReceivedInfo(
|
|
|
|
name="received_datetimetz",
|
|
|
|
text="2023-02-20 10:03:18+12:00",
|
|
|
|
datestamp=datetime.datetime(
|
2023-02-27 17:30:54 +01:00
|
|
|
2023,
|
|
|
|
2,
|
|
|
|
20,
|
|
|
|
10,
|
|
|
|
3,
|
|
|
|
18,
|
|
|
|
tzinfo=datetime.timezone(datetime.timedelta(seconds=43200)),
|
2023-01-17 16:36:44 -06:00
|
|
|
),
|
|
|
|
),
|
|
|
|
MetaData(name="MIME-Version", text="1.0"),
|
|
|
|
MetaData(name="Date", text="Fri, 16 Dec 2022 17:04:16 -0500"),
|
2024-08-01 15:24:17 -04:00
|
|
|
Recipient(name="Hello", text="hello@unstructured.io"),
|
2023-01-17 16:36:44 -06:00
|
|
|
MetaData(
|
|
|
|
name="Message-ID",
|
2024-08-01 15:24:17 -04:00
|
|
|
text="CADc-_xaLB2FeVQ7mNsoX+NJb_7hAJhBKa_zet-rtgPGenj0uVw@mail.gmail.com",
|
2023-01-17 16:36:44 -06:00
|
|
|
),
|
|
|
|
Subject(text="Test Email"),
|
|
|
|
Sender(name="Matthew Robinson", text="mrobinson@unstructured.io"),
|
|
|
|
Recipient(name="Matthew Robinson", text="mrobinson@unstructured.io"),
|
2024-08-01 15:24:17 -04:00
|
|
|
Recipient(name="Fake Email", text="fake-email@unstructured.io"),
|
|
|
|
Recipient(name="test", text="test@unstructured.io"),
|
2023-01-17 16:36:44 -06:00
|
|
|
MetaData(
|
2023-02-27 17:30:54 +01:00
|
|
|
name="Content-Type",
|
|
|
|
text='multipart/alternative; boundary="00000000000095c9b205eff92630"',
|
2023-01-17 16:36:44 -06:00
|
|
|
),
|
|
|
|
]
|
|
|
|
|
2023-01-09 11:08:08 -06:00
|
|
|
HEADER_EXPECTED_OUTPUT = [
|
|
|
|
MetaData(name="MIME-Version", text="1.0"),
|
|
|
|
MetaData(name="Date", text="Fri, 16 Dec 2022 17:04:16 -0500"),
|
|
|
|
MetaData(
|
|
|
|
name="Message-ID",
|
2024-08-01 15:24:17 -04:00
|
|
|
text="CADc-_xaLB2FeVQ7mNsoX+NJb_7hAJhBKa_zet-rtgPGenj0uVw@mail.gmail.com",
|
2023-01-09 11:08:08 -06:00
|
|
|
),
|
|
|
|
Subject(text="Test Email"),
|
|
|
|
Sender(name="Matthew Robinson", text="mrobinson@unstructured.io"),
|
|
|
|
Recipient(name="Matthew Robinson", text="mrobinson@unstructured.io"),
|
|
|
|
MetaData(
|
2023-02-27 17:30:54 +01:00
|
|
|
name="Content-Type",
|
|
|
|
text='multipart/alternative; boundary="00000000000095c9b205eff92630"',
|
2023-01-09 11:08:08 -06:00
|
|
|
),
|
|
|
|
]
|
|
|
|
|
|
|
|
ALL_EXPECTED_OUTPUT = HEADER_EXPECTED_OUTPUT + EXPECTED_OUTPUT
|
|
|
|
|
2023-01-03 11:41:54 -06:00
|
|
|
ATTACH_EXPECTED_OUTPUT = [
|
2023-02-27 17:30:54 +01:00
|
|
|
{"filename": "fake-attachment.txt", "payload": b"Hey this is a fake attachment!"},
|
2023-01-03 11:41:54 -06:00
|
|
|
]
|
|
|
|
|
2022-12-19 13:02:44 -05:00
|
|
|
|
|
|
|
def test_partition_email_from_filename():
|
2024-08-01 15:24:17 -04:00
|
|
|
elements = partition_email(filename=example_doc_path("eml/fake-email.eml"))
|
|
|
|
|
2022-12-19 13:02:44 -05:00
|
|
|
assert len(elements) > 0
|
|
|
|
assert elements == EXPECTED_OUTPUT
|
2023-07-05 15:02:22 -05:00
|
|
|
for element in elements:
|
|
|
|
assert element.metadata.filename == "fake-email.eml"
|
|
|
|
|
|
|
|
|
2023-07-06 09:49:27 -04:00
|
|
|
def test_partition_email_from_filename_malformed_encoding():
|
2024-08-01 15:24:17 -04:00
|
|
|
elements = partition_email(filename=example_doc_path("eml/fake-email-malformed-encoding.eml"))
|
|
|
|
|
2023-07-06 09:49:27 -04:00
|
|
|
assert len(elements) > 0
|
|
|
|
assert elements == EXPECTED_OUTPUT
|
|
|
|
|
|
|
|
|
2023-05-30 10:24:02 -07:00
|
|
|
@pytest.mark.parametrize(
|
2023-06-16 17:52:13 -07:00
|
|
|
("filename", "expected_output"),
|
|
|
|
[
|
|
|
|
("fake-email-utf-16.eml", EXPECTED_OUTPUT),
|
|
|
|
("fake-email-utf-16-be.eml", EXPECTED_OUTPUT),
|
|
|
|
("fake-email-utf-16-le.eml", EXPECTED_OUTPUT),
|
2023-12-20 07:37:17 +00:00
|
|
|
("fake-email-b64.eml", EXPECTED_OUTPUT),
|
2023-06-16 17:52:13 -07:00
|
|
|
("email-no-utf8-2008-07-16.062410.eml", None),
|
|
|
|
("email-no-utf8-2014-03-17.111517.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-1.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-2.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-3.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-4.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-5.eml", None),
|
|
|
|
],
|
2023-05-30 10:24:02 -07:00
|
|
|
)
|
2024-08-01 15:24:17 -04:00
|
|
|
def test_partition_email_from_filename_default_encoding(
|
|
|
|
filename: str, expected_output: Element | None
|
|
|
|
):
|
|
|
|
elements = partition_email(example_doc_path("eml/" + filename))
|
|
|
|
|
2023-05-30 10:24:02 -07:00
|
|
|
assert len(elements) > 0
|
2023-06-16 17:52:13 -07:00
|
|
|
if expected_output:
|
|
|
|
assert elements == expected_output
|
2023-07-05 15:02:22 -05:00
|
|
|
for element in elements:
|
|
|
|
assert element.metadata.filename == filename
|
2023-05-30 10:24:02 -07:00
|
|
|
|
|
|
|
|
2022-12-19 13:02:44 -05:00
|
|
|
def test_partition_email_from_file():
|
2024-08-01 15:24:17 -04:00
|
|
|
with open(example_doc_path("eml/fake-email.eml"), "rb") as f:
|
2022-12-19 13:02:44 -05:00
|
|
|
elements = partition_email(file=f)
|
2024-08-01 15:24:17 -04:00
|
|
|
|
2022-12-19 13:02:44 -05:00
|
|
|
assert len(elements) > 0
|
|
|
|
assert elements == EXPECTED_OUTPUT
|
2023-07-05 15:02:22 -05:00
|
|
|
for element in elements:
|
|
|
|
assert element.metadata.filename is None
|
2022-12-19 13:02:44 -05:00
|
|
|
|
|
|
|
|
2023-05-30 10:24:02 -07:00
|
|
|
@pytest.mark.parametrize(
|
2023-06-16 17:52:13 -07:00
|
|
|
("filename", "expected_output"),
|
|
|
|
[
|
|
|
|
("fake-email-utf-16.eml", EXPECTED_OUTPUT),
|
|
|
|
("fake-email-utf-16-be.eml", EXPECTED_OUTPUT),
|
|
|
|
("fake-email-utf-16-le.eml", EXPECTED_OUTPUT),
|
2023-12-20 07:37:17 +00:00
|
|
|
("fake-email-b64.eml", EXPECTED_OUTPUT),
|
2023-06-16 17:52:13 -07:00
|
|
|
("email-no-utf8-2008-07-16.062410.eml", None),
|
|
|
|
("email-no-utf8-2014-03-17.111517.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-1.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-2.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-3.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-4.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-5.eml", None),
|
|
|
|
],
|
2023-05-30 10:24:02 -07:00
|
|
|
)
|
2024-08-01 15:24:17 -04:00
|
|
|
def test_partition_email_from_file_default_encoding(filename: str, expected_output: Element | None):
|
|
|
|
with open(example_doc_path("eml/" + filename), "rb") as f:
|
2023-05-30 10:24:02 -07:00
|
|
|
elements = partition_email(file=f)
|
2024-08-01 15:24:17 -04:00
|
|
|
|
2023-05-30 10:24:02 -07:00
|
|
|
assert len(elements) > 0
|
2023-06-16 17:52:13 -07:00
|
|
|
if expected_output:
|
|
|
|
assert elements == expected_output
|
2023-07-05 15:02:22 -05:00
|
|
|
for element in elements:
|
|
|
|
assert element.metadata.filename is None
|
2023-05-30 10:24:02 -07:00
|
|
|
|
|
|
|
|
2023-01-09 16:15:14 -05:00
|
|
|
def test_partition_email_from_file_rb():
|
2024-08-01 15:24:17 -04:00
|
|
|
with open(example_doc_path("eml/fake-email.eml"), "rb") as f:
|
2023-01-09 16:15:14 -05:00
|
|
|
elements = partition_email(file=f)
|
2024-08-01 15:24:17 -04:00
|
|
|
|
2023-01-09 16:15:14 -05:00
|
|
|
assert len(elements) > 0
|
|
|
|
assert elements == EXPECTED_OUTPUT
|
2023-07-05 15:02:22 -05:00
|
|
|
for element in elements:
|
|
|
|
assert element.metadata.filename is None
|
2023-01-09 16:15:14 -05:00
|
|
|
|
|
|
|
|
2023-05-30 10:24:02 -07:00
|
|
|
@pytest.mark.parametrize(
|
2023-06-16 17:52:13 -07:00
|
|
|
("filename", "expected_output"),
|
|
|
|
[
|
|
|
|
("fake-email-utf-16.eml", EXPECTED_OUTPUT),
|
|
|
|
("fake-email-utf-16-be.eml", EXPECTED_OUTPUT),
|
|
|
|
("fake-email-utf-16-le.eml", EXPECTED_OUTPUT),
|
|
|
|
("email-no-utf8-2008-07-16.062410.eml", None),
|
|
|
|
("email-no-utf8-2014-03-17.111517.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-1.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-2.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-3.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-4.eml", None),
|
|
|
|
("email-replace-mime-encodings-error-5.eml", None),
|
|
|
|
],
|
2023-05-30 10:24:02 -07:00
|
|
|
)
|
2024-08-01 15:24:17 -04:00
|
|
|
def test_partition_email_from_file_rb_default_encoding(
|
|
|
|
filename: str, expected_output: Element | None
|
|
|
|
):
|
|
|
|
with open(example_doc_path("eml/" + filename), "rb") as f:
|
2023-05-30 10:24:02 -07:00
|
|
|
elements = partition_email(file=f)
|
2024-08-01 15:24:17 -04:00
|
|
|
|
2023-05-30 10:24:02 -07:00
|
|
|
assert len(elements) > 0
|
2023-06-16 17:52:13 -07:00
|
|
|
if expected_output:
|
|
|
|
assert elements == expected_output
|
2023-07-05 15:02:22 -05:00
|
|
|
for element in elements:
|
|
|
|
assert element.metadata.filename is None
|
2023-05-30 10:24:02 -07:00
|
|
|
|
|
|
|
|
2024-07-19 11:18:02 -07:00
|
|
|
def test_partition_email_from_spooled_temp_file():
|
|
|
|
filename = example_doc_path("eml/family-day.eml")
|
|
|
|
with open(filename, "rb") as test_file:
|
|
|
|
spooled_temp_file = tempfile.SpooledTemporaryFile()
|
|
|
|
spooled_temp_file.write(test_file.read())
|
|
|
|
spooled_temp_file.seek(0)
|
|
|
|
elements = partition_email(file=spooled_temp_file)
|
|
|
|
assert len(elements) == 9
|
|
|
|
assert elements[3].text == "Make sure to RSVP!"
|
|
|
|
|
|
|
|
|
2023-01-09 11:08:08 -06:00
|
|
|
def test_partition_email_from_text_file():
|
2024-08-01 15:24:17 -04:00
|
|
|
with open(example_doc_path("eml/fake-email.txt"), "rb") as f:
|
2023-01-09 11:08:08 -06:00
|
|
|
elements = partition_email(file=f, content_source="text/plain")
|
2024-08-01 15:24:17 -04:00
|
|
|
|
2023-01-09 11:08:08 -06:00
|
|
|
assert len(elements) > 0
|
|
|
|
assert elements == EXPECTED_OUTPUT
|
2023-07-05 15:02:22 -05:00
|
|
|
for element in elements:
|
|
|
|
assert element.metadata.filename is None
|
2023-01-09 11:08:08 -06:00
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_from_text_file_with_headers():
|
2024-08-01 15:24:17 -04:00
|
|
|
with open(example_doc_path("eml/fake-email.txt"), "rb") as f:
|
|
|
|
elements = partition_email(file=f, content_source="text/plain", include_headers=True)
|
|
|
|
|
2023-01-09 11:08:08 -06:00
|
|
|
assert len(elements) > 0
|
|
|
|
assert elements == ALL_EXPECTED_OUTPUT
|
2023-07-05 15:02:22 -05:00
|
|
|
for element in elements:
|
|
|
|
assert element.metadata.filename is None
|
2023-01-09 11:08:08 -06:00
|
|
|
|
|
|
|
|
2023-07-24 10:57:24 -05:00
|
|
|
def test_partition_email_from_text_file_max():
|
2024-08-01 15:24:17 -04:00
|
|
|
with open(example_doc_path("eml/fake-email.txt"), "rb") as f:
|
|
|
|
elements = partition_email(file=f, content_source="text/plain", max_partition=20)
|
|
|
|
|
2023-07-24 10:57:24 -05:00
|
|
|
assert len(elements) == 6
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_from_text_file_raises_value_error():
|
2024-08-01 15:24:17 -04:00
|
|
|
with pytest.raises(ValueError), open(example_doc_path("eml/fake-email.txt"), "rb") as f:
|
2023-07-24 10:57:24 -05:00
|
|
|
partition_email(file=f, content_source="text/plain", min_partition=1000)
|
|
|
|
|
|
|
|
|
2022-12-19 13:02:44 -05:00
|
|
|
def test_partition_email_from_text():
|
2024-08-01 15:24:17 -04:00
|
|
|
with open(example_doc_path("eml/fake-email.eml")) as f:
|
2022-12-19 13:02:44 -05:00
|
|
|
text = f.read()
|
2024-08-01 15:24:17 -04:00
|
|
|
|
2022-12-19 13:02:44 -05:00
|
|
|
elements = partition_email(text=text)
|
2024-08-01 15:24:17 -04:00
|
|
|
|
2022-12-19 13:02:44 -05:00
|
|
|
assert len(elements) > 0
|
|
|
|
assert elements == EXPECTED_OUTPUT
|
2023-07-05 15:02:22 -05:00
|
|
|
for element in elements:
|
|
|
|
assert element.metadata.filename is None
|
2022-12-19 13:02:44 -05:00
|
|
|
|
|
|
|
|
2023-03-28 17:03:51 -04:00
|
|
|
def test_partition_email_from_text_work_with_empty_string():
|
|
|
|
assert partition_email(text="") == []
|
|
|
|
|
|
|
|
|
2023-01-09 19:49:19 -06:00
|
|
|
def test_partition_email_from_filename_with_embedded_image():
|
2024-08-01 15:24:17 -04:00
|
|
|
elements = partition_email(
|
|
|
|
example_doc_path("eml/fake-email-image-embedded.eml"), content_source="text/plain"
|
|
|
|
)
|
|
|
|
|
2023-01-09 19:49:19 -06:00
|
|
|
assert len(elements) > 0
|
|
|
|
assert elements == IMAGE_EXPECTED_OUTPUT
|
2023-07-05 15:02:22 -05:00
|
|
|
for element in elements:
|
|
|
|
assert element.metadata.filename == "fake-email-image-embedded.eml"
|
2023-01-09 19:49:19 -06:00
|
|
|
|
|
|
|
|
2023-07-05 15:02:22 -05:00
|
|
|
def test_partition_email_from_file_with_header():
|
2024-08-01 15:24:17 -04:00
|
|
|
with open(example_doc_path("eml/fake-email-header.eml")) as f:
|
|
|
|
msg = email.message_from_file(f, policy=policy.default)
|
2023-01-09 11:08:08 -06:00
|
|
|
|
2024-08-01 15:24:17 -04:00
|
|
|
msg = cast(EmailMessage, msg)
|
|
|
|
elements = partition_email_header(msg)
|
Feat: Create a naive hierarchy for elements (#1268)
## **Summary**
By adding hierarchy to unstructured elements, users will have more
information for implementing vector db/LLM chunking strategies. For
example, text elements could be queried by their preceding title
element. The hierarchy is implemented by a parent_id tag in the
element's metadata.
### Features
- Introduces a parent_id to ElementMetadata (The id of the parent
element, not a pointer)
- Creates a rule set for assigning hierarchies. Sensible default is
assigned, with an optional override parameter
- Sets element parent ids if there isn't an existing parent id or
matches the ruleset
### How it works
Hierarchies are assigned via a parent id field in element metadata.
Elements are read sequentially and evaluated against a ruleset. For
example take the following elements:
1. Title, "This is the Title"
2. Text, "this is the text"
And the ruleset: `{"title": ["text"]}`. When evaluated, the parent_id of
2 will be the id of 1. The algorithm for determining this is more
complex and resolves several edge cases, so please read the code for
further details.
### Schema Changes
```
@dataclass
class ElementMetadata:
coordinates: Optional[CoordinatesMetadata] = None
data_source: Optional[DataSourceMetadata] = None
filename: Optional[str] = None
file_directory: Optional[str] = None
last_modified: Optional[str] = None
filetype: Optional[str] = None
attached_to_filename: Optional[str] = None
+ parent_id: Optional[Union[str, uuid.UUID, NoID, UUID]] = None
+ category_depth: Optional[int] = None
...
```
### Testing
```
from unstructured.partition.auto import partition
from typing import List
elements = partition(filename="./unstructured/example-docs/fake-html.html", strategy="auto")
for element in elements:
print(
f"Category: {getattr(element, 'category', '')}\n"\
f"Text: {getattr(element, 'text', '')}\n"
f"ID: {element.id}\n" \
f"Parent ID: {element.metadata.parent_id}\n"\
f"Depth: {element.metadata.category_depth}\n" \
)
```
### Additional Notes
Implementing this feature revealed a possibly undesired side-effect in
how element metadata are processed. In
`unstructured/partition/common.py` the `_add_element_metadata` is
invoked as part of the `add_metadata_with_filetype` decorator for
filetype partitioning. This method is intended to add additional
information to the metadata generated with the element including
filename and filetype, however the existing metadata is merged into a
newly created metadata object rather than the other way around. Because
of the way it's structured, new metadata fields can easily be forgotten
and pose debugging challenges to developers. This likely warrants a new
issue.
I'm guessing that the implementation is done this way to avoid issues
with deserializing elements, but could be wrong.
---------
Co-authored-by: Benjamin Torres <benjats07@users.noreply.github.com>
2023-09-14 11:23:16 -04:00
|
|
|
|
2023-04-04 14:23:41 -04:00
|
|
|
assert len(elements) > 0
|
2024-08-01 15:24:17 -04:00
|
|
|
assert elements == RECEIVED_HEADER_OUTPUT
|
|
|
|
all(element.metadata.filename is None for element in elements)
|
2023-05-12 11:33:01 -04:00
|
|
|
|
2023-04-04 14:23:41 -04:00
|
|
|
|
2023-01-17 11:33:45 -05:00
|
|
|
def test_extract_email_text_matches_html():
|
2024-08-01 15:24:17 -04:00
|
|
|
filename = example_doc_path("eml/fake-email-attachment.eml")
|
|
|
|
elements_from_text = partition_email(filename, content_source="text/plain")
|
|
|
|
elements_from_html = partition_email(filename, content_source="text/html")
|
|
|
|
|
2023-01-17 11:33:45 -05:00
|
|
|
assert len(elements_from_text) == len(elements_from_html)
|
|
|
|
# NOTE(robinson) - checking each individually is necessary because the text/html returns
|
|
|
|
# HTMLTitle, HTMLNarrativeText, etc
|
|
|
|
for i, element in enumerate(elements_from_text):
|
|
|
|
assert element == elements_from_text[i]
|
2023-07-05 15:02:22 -05:00
|
|
|
assert element.metadata.filename == "fake-email-attachment.eml"
|
2023-01-17 11:33:45 -05:00
|
|
|
|
|
|
|
|
2023-12-20 07:37:17 +00:00
|
|
|
def test_extract_base64_email_text_matches_html():
|
2024-08-01 15:24:17 -04:00
|
|
|
filename = example_doc_path("eml/fake-email-b64.eml")
|
|
|
|
elements_from_text = partition_email(filename, content_source="text/plain")
|
|
|
|
elements_from_html = partition_email(filename, content_source="text/html")
|
|
|
|
|
2023-12-20 07:37:17 +00:00
|
|
|
assert len(elements_from_text) == len(elements_from_html)
|
|
|
|
for i, element in enumerate(elements_from_text):
|
|
|
|
assert element == elements_from_text[i]
|
|
|
|
assert element.metadata.filename == "fake-email-b64.eml"
|
|
|
|
|
|
|
|
|
2023-03-10 18:10:39 -05:00
|
|
|
def test_partition_email_processes_fake_email_with_header():
|
2024-08-01 15:24:17 -04:00
|
|
|
elements = partition_email(example_doc_path("eml/fake-email-header.eml"))
|
|
|
|
|
2023-03-10 18:10:39 -05:00
|
|
|
assert len(elements) > 0
|
2024-08-01 15:24:17 -04:00
|
|
|
assert all(element.metadata.filename == "fake-email-header.eml" for element in elements)
|
|
|
|
assert all(
|
|
|
|
element.metadata.bcc_recipient == ["Hello <hello@unstructured.io>"] for element in elements
|
|
|
|
)
|
|
|
|
assert all(
|
|
|
|
element.metadata.cc_recipient
|
|
|
|
== ["Fake Email <fake-email@unstructured.io>", "test@unstructured.io"]
|
|
|
|
for element in elements
|
|
|
|
)
|
|
|
|
assert all(element.metadata.email_message_id is not None for element in elements)
|
2023-05-11 10:36:25 -04:00
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
(("time", "expected")),
|
|
|
|
[
|
|
|
|
("Thu, 4 May 2023 02:32:49 +0000", "2023-05-04T02:32:49+00:00"),
|
|
|
|
("Thu, 4 May 2023 02:32:49 +0000", "2023-05-04T02:32:49+00:00"),
|
|
|
|
("Thu, 4 May 2023 02:32:49 +0000 (UTC)", "2023-05-04T02:32:49+00:00"),
|
|
|
|
("Thursday 5/3/2023 02:32:49", None),
|
|
|
|
],
|
|
|
|
)
|
2024-08-01 15:24:17 -04:00
|
|
|
def test_convert_to_iso_8601(time: str, expected: str | None):
|
2023-05-11 10:36:25 -04:00
|
|
|
iso_time = convert_to_iso_8601(time)
|
2024-08-01 15:24:17 -04:00
|
|
|
|
2023-05-11 10:36:25 -04:00
|
|
|
assert iso_time == expected
|
2023-06-16 17:52:13 -07:00
|
|
|
|
|
|
|
|
2024-08-01 15:24:17 -04:00
|
|
|
def test_partition_email_still_works_with_no_content(caplog: LogCaptureFixture):
|
|
|
|
elements = partition_email(example_doc_path("eml/email-no-html-content-1.eml"))
|
|
|
|
|
2024-02-07 17:31:49 -05:00
|
|
|
assert len(elements) == 1
|
|
|
|
assert elements[0].text.startswith("Hey there")
|
|
|
|
assert "text/html was not found. Falling back to text/plain" in caplog.text
|
2023-06-29 18:01:12 -04:00
|
|
|
|
|
|
|
|
2024-08-01 15:24:17 -04:00
|
|
|
def test_partition_email_with_json():
|
|
|
|
elements = partition_email(example_doc_path("eml/fake-email.eml"))
|
|
|
|
assert_round_trips_through_JSON(elements)
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_with_pgp_encrypted_message(caplog: LogCaptureFixture):
|
|
|
|
elements = partition_email(example_doc_path("eml/fake-encrypted.eml"))
|
|
|
|
|
|
|
|
assert elements == []
|
|
|
|
assert "WARNING" in caplog.text
|
|
|
|
assert "Encrypted email detected" in caplog.text
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_inline_content_disposition():
|
|
|
|
elements = partition_email(
|
|
|
|
example_doc_path("eml/email-inline-content-disposition.eml"),
|
|
|
|
process_attachments=True,
|
|
|
|
attachment_partitioner=partition_text,
|
|
|
|
)
|
|
|
|
|
|
|
|
assert isinstance(elements[0], Text)
|
|
|
|
assert isinstance(elements[1], Text)
|
|
|
|
|
|
|
|
|
|
|
|
def test_add_chunking_strategy_on_partition_email():
|
|
|
|
chunk_elements = partition_email(
|
|
|
|
example_doc_path("eml/fake-email.txt"), chunking_strategy="by_title"
|
|
|
|
)
|
|
|
|
elements = partition_email(example_doc_path("eml/fake-email.txt"))
|
|
|
|
chunks = chunk_by_title(elements)
|
|
|
|
|
|
|
|
assert chunk_elements != elements
|
|
|
|
assert chunk_elements == chunks
|
|
|
|
|
|
|
|
|
|
|
|
# -- raise error behaviors -----------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_msg_raises_with_no_partitioner():
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
partition_email(example_doc_path("eml/fake-email-attachment.eml"), process_attachments=True)
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_raises_with_none_specified():
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
partition_email()
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_raises_with_too_many_specified():
|
|
|
|
with open(example_doc_path("eml/fake-email.eml")) as f:
|
|
|
|
text = f.read()
|
|
|
|
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
partition_email(example_doc_path("eml/fake-email.eml"), text=text)
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_raises_with_invalid_content_type():
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
partition_email(example_doc_path("eml/fake-email.eml"), content_source="application/json")
|
|
|
|
|
|
|
|
|
|
|
|
# -- metadata behaviors --------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_from_filename_with_metadata_filename():
|
|
|
|
elements = partition_email(example_doc_path("eml/fake-email.eml"), metadata_filename="test")
|
|
|
|
|
|
|
|
assert len(elements) > 0
|
|
|
|
assert all(element.metadata.filename == "test" for element in elements)
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_from_filename_has_metadata():
|
|
|
|
elements = partition_email(example_doc_path("eml/fake-email.eml"))
|
|
|
|
parent_id = elements[0].metadata.parent_id
|
|
|
|
|
|
|
|
assert len(elements) > 0
|
|
|
|
assert (
|
|
|
|
elements[0].metadata.to_dict()
|
|
|
|
== ElementMetadata(
|
|
|
|
coordinates=None,
|
|
|
|
filename=example_doc_path("eml/fake-email.eml"),
|
|
|
|
last_modified="2022-12-16T17:04:16-05:00",
|
|
|
|
page_number=None,
|
|
|
|
url=None,
|
|
|
|
sent_from=["Matthew Robinson <mrobinson@unstructured.io>"],
|
|
|
|
sent_to=["NotMatthew <NotMatthew@notunstructured.com>"],
|
|
|
|
subject="Test Email",
|
|
|
|
filetype="message/rfc822",
|
|
|
|
parent_id=parent_id,
|
|
|
|
languages=["eng"],
|
|
|
|
email_message_id="CADc-_xaLB2FeVQ7mNsoX+NJb_7hAJhBKa_zet-rtgPGenj0uVw@mail.gmail.com",
|
|
|
|
).to_dict()
|
|
|
|
)
|
|
|
|
expected_dt = datetime.datetime.fromisoformat("2022-12-16T17:04:16-05:00")
|
|
|
|
assert parse_optional_datetime(elements[0].metadata.last_modified) == expected_dt
|
|
|
|
for element in elements:
|
|
|
|
assert element.metadata.filename == "fake-email.eml"
|
|
|
|
|
|
|
|
|
2023-06-30 09:44:46 -05:00
|
|
|
def test_partition_email_from_filename_exclude_metadata():
|
2024-08-01 15:24:17 -04:00
|
|
|
elements = partition_email(
|
|
|
|
example_doc_path("eml/fake-email-header.eml"), include_metadata=False
|
|
|
|
)
|
|
|
|
|
Dynamic ElementMetadata implementation (#2043)
### Executive Summary
The structure of element metadata is currently static, meaning only
predefined fields can appear in the metadata. We would like the
flexibility for end-users, at their own discretion, to define and use
additional metadata fields that make sense for their particular
use-case.
### Concepts
A key concept for dynamic metadata is _known field_. A known-field is
one of those explicitly defined on `ElementMetadata`. Each of these has
a type and can be specified when _constructing_ a new `ElementMetadata`
instance. This is in contrast to an _end-user defined_ (or _ad-hoc_)
metadata field, one not known at "compile" time and added at the
discretion of an end-user to suit the purposes of their application.
An ad-hoc field can only be added by _assignment_ on an already
constructed instance.
### End-user ad-hoc metadata field behaviors
An ad-hoc field can be added to an `ElementMetadata` instance by
assignment:
```python
>>> metadata = ElementMetadata()
>>> metadata.coefficient = 0.536
```
A field added in this way can be accessed by name:
```python
>>> metadata.coefficient
0.536
```
and that field will appear in the JSON/dict for that instance:
```python
>>> metadata = ElementMetadata()
>>> metadata.coefficient = 0.536
>>> metadata.to_dict()
{"coefficient": 0.536}
```
However, accessing a "user-defined" value that has _not_ been assigned
on that instance raises `AttributeError`:
```python
>>> metadata.coeffcient # -- misspelled "coefficient" --
AttributeError: 'ElementMetadata' object has no attribute 'coeffcient'
```
This makes "tagging" a metadata item with a value very convenient, but
entails the proviso that if an end-user wants to add a metadata field to
_some_ elements and not others (sparse population), AND they want to
access that field by name on ANY element and receive `None` where it has
not been assigned, they will need to use an expression like this:
```python
coefficient = metadata.coefficient if hasattr(metadata, "coefficient") else None
```
### Implementation Notes
- **ad-hoc metadata fields** are discarded during consolidation (for
chunking) because we don't have a consolidation strategy defined for
those. We could consider using a default consolidation strategy like
`FIRST` or possibly allow a user to register a strategy (although that
gets hairy in non-private and multiple-memory-space situations.)
- ad-hoc metadata fields **cannot start with an underscore**.
- We have no way to distinguish an ad-hoc field from any "noise" fields
that might appear in a JSON/dict loaded using `.from_dict()`, so unlike
the original (which only loaded known-fields), we'll rehydrate anything
that we find there.
- No real type-safety is possible on ad-hoc fields but the type-checker
does not complain because the type of all ad-hoc fields is `Any` (which
is the best available behavior in my view).
- We may want to consider whether end-users should be able to add ad-hoc
fields to "sub" metadata objects too, like `DataSourceMetadata` and
conceivably `CoordinatesMetadata` (although I'm not immediately seeing a
use-case for the second one).
2023-11-15 13:22:15 -08:00
|
|
|
assert parse_optional_datetime(elements[0].metadata.last_modified) is None
|
2023-06-30 09:44:46 -05:00
|
|
|
assert elements[0].metadata.filetype is None
|
|
|
|
assert elements[0].metadata.page_name is None
|
|
|
|
assert elements[0].metadata.filename is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_from_text_file_exclude_metadata():
|
2024-08-01 15:24:17 -04:00
|
|
|
with open(example_doc_path("eml/fake-email.txt"), "rb") as f:
|
|
|
|
elements = partition_email(file=f, content_source="text/plain", include_metadata=False)
|
|
|
|
|
Dynamic ElementMetadata implementation (#2043)
### Executive Summary
The structure of element metadata is currently static, meaning only
predefined fields can appear in the metadata. We would like the
flexibility for end-users, at their own discretion, to define and use
additional metadata fields that make sense for their particular
use-case.
### Concepts
A key concept for dynamic metadata is _known field_. A known-field is
one of those explicitly defined on `ElementMetadata`. Each of these has
a type and can be specified when _constructing_ a new `ElementMetadata`
instance. This is in contrast to an _end-user defined_ (or _ad-hoc_)
metadata field, one not known at "compile" time and added at the
discretion of an end-user to suit the purposes of their application.
An ad-hoc field can only be added by _assignment_ on an already
constructed instance.
### End-user ad-hoc metadata field behaviors
An ad-hoc field can be added to an `ElementMetadata` instance by
assignment:
```python
>>> metadata = ElementMetadata()
>>> metadata.coefficient = 0.536
```
A field added in this way can be accessed by name:
```python
>>> metadata.coefficient
0.536
```
and that field will appear in the JSON/dict for that instance:
```python
>>> metadata = ElementMetadata()
>>> metadata.coefficient = 0.536
>>> metadata.to_dict()
{"coefficient": 0.536}
```
However, accessing a "user-defined" value that has _not_ been assigned
on that instance raises `AttributeError`:
```python
>>> metadata.coeffcient # -- misspelled "coefficient" --
AttributeError: 'ElementMetadata' object has no attribute 'coeffcient'
```
This makes "tagging" a metadata item with a value very convenient, but
entails the proviso that if an end-user wants to add a metadata field to
_some_ elements and not others (sparse population), AND they want to
access that field by name on ANY element and receive `None` where it has
not been assigned, they will need to use an expression like this:
```python
coefficient = metadata.coefficient if hasattr(metadata, "coefficient") else None
```
### Implementation Notes
- **ad-hoc metadata fields** are discarded during consolidation (for
chunking) because we don't have a consolidation strategy defined for
those. We could consider using a default consolidation strategy like
`FIRST` or possibly allow a user to register a strategy (although that
gets hairy in non-private and multiple-memory-space situations.)
- ad-hoc metadata fields **cannot start with an underscore**.
- We have no way to distinguish an ad-hoc field from any "noise" fields
that might appear in a JSON/dict loaded using `.from_dict()`, so unlike
the original (which only loaded known-fields), we'll rehydrate anything
that we find there.
- No real type-safety is possible on ad-hoc fields but the type-checker
does not complain because the type of all ad-hoc fields is `Any` (which
is the best available behavior in my view).
- We may want to consider whether end-users should be able to add ad-hoc
fields to "sub" metadata objects too, like `DataSourceMetadata` and
conceivably `CoordinatesMetadata` (although I'm not immediately seeing a
use-case for the second one).
2023-11-15 13:22:15 -08:00
|
|
|
assert parse_optional_datetime(elements[0].metadata.last_modified) is None
|
2023-06-30 09:44:46 -05:00
|
|
|
assert elements[0].metadata.filetype is None
|
|
|
|
assert elements[0].metadata.page_name is None
|
|
|
|
assert elements[0].metadata.filename is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_from_file_exclude_metadata():
|
2024-08-01 15:24:17 -04:00
|
|
|
with open(example_doc_path("eml/fake-email.eml"), "rb") as f:
|
2023-06-30 09:44:46 -05:00
|
|
|
elements = partition_email(file=f, include_metadata=False)
|
2024-08-01 15:24:17 -04:00
|
|
|
|
Dynamic ElementMetadata implementation (#2043)
### Executive Summary
The structure of element metadata is currently static, meaning only
predefined fields can appear in the metadata. We would like the
flexibility for end-users, at their own discretion, to define and use
additional metadata fields that make sense for their particular
use-case.
### Concepts
A key concept for dynamic metadata is _known field_. A known-field is
one of those explicitly defined on `ElementMetadata`. Each of these has
a type and can be specified when _constructing_ a new `ElementMetadata`
instance. This is in contrast to an _end-user defined_ (or _ad-hoc_)
metadata field, one not known at "compile" time and added at the
discretion of an end-user to suit the purposes of their application.
An ad-hoc field can only be added by _assignment_ on an already
constructed instance.
### End-user ad-hoc metadata field behaviors
An ad-hoc field can be added to an `ElementMetadata` instance by
assignment:
```python
>>> metadata = ElementMetadata()
>>> metadata.coefficient = 0.536
```
A field added in this way can be accessed by name:
```python
>>> metadata.coefficient
0.536
```
and that field will appear in the JSON/dict for that instance:
```python
>>> metadata = ElementMetadata()
>>> metadata.coefficient = 0.536
>>> metadata.to_dict()
{"coefficient": 0.536}
```
However, accessing a "user-defined" value that has _not_ been assigned
on that instance raises `AttributeError`:
```python
>>> metadata.coeffcient # -- misspelled "coefficient" --
AttributeError: 'ElementMetadata' object has no attribute 'coeffcient'
```
This makes "tagging" a metadata item with a value very convenient, but
entails the proviso that if an end-user wants to add a metadata field to
_some_ elements and not others (sparse population), AND they want to
access that field by name on ANY element and receive `None` where it has
not been assigned, they will need to use an expression like this:
```python
coefficient = metadata.coefficient if hasattr(metadata, "coefficient") else None
```
### Implementation Notes
- **ad-hoc metadata fields** are discarded during consolidation (for
chunking) because we don't have a consolidation strategy defined for
those. We could consider using a default consolidation strategy like
`FIRST` or possibly allow a user to register a strategy (although that
gets hairy in non-private and multiple-memory-space situations.)
- ad-hoc metadata fields **cannot start with an underscore**.
- We have no way to distinguish an ad-hoc field from any "noise" fields
that might appear in a JSON/dict loaded using `.from_dict()`, so unlike
the original (which only loaded known-fields), we'll rehydrate anything
that we find there.
- No real type-safety is possible on ad-hoc fields but the type-checker
does not complain because the type of all ad-hoc fields is `Any` (which
is the best available behavior in my view).
- We may want to consider whether end-users should be able to add ad-hoc
fields to "sub" metadata objects too, like `DataSourceMetadata` and
conceivably `CoordinatesMetadata` (although I'm not immediately seeing a
use-case for the second one).
2023-11-15 13:22:15 -08:00
|
|
|
assert parse_optional_datetime(elements[0].metadata.last_modified) is None
|
2023-06-30 09:44:46 -05:00
|
|
|
assert elements[0].metadata.filetype is None
|
|
|
|
assert elements[0].metadata.page_name is None
|
|
|
|
assert elements[0].metadata.filename is None
|
|
|
|
|
|
|
|
|
2024-08-01 15:24:17 -04:00
|
|
|
def test_partition_email_metadata_date_from_header(mocker: MockFixture):
|
|
|
|
mocker.patch("unstructured.partition.email.get_last_modified_date", return_value=None)
|
|
|
|
mocker.patch("unstructured.partition.email.get_last_modified_date_from_file", return_value=None)
|
|
|
|
|
|
|
|
elements = partition_email(example_doc_path("eml/fake-email-attachment.eml"))
|
|
|
|
|
|
|
|
assert elements[0].metadata.last_modified == "2022-12-23T12:08:48-06:00"
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_from_file_custom_metadata_date():
|
|
|
|
with open(example_doc_path("eml/fake-email-attachment.eml"), "rb") as f:
|
|
|
|
elements = partition_email(file=f, metadata_last_modified="2020-07-05T09:24:28")
|
|
|
|
|
|
|
|
assert elements[0].metadata.last_modified == "2020-07-05T09:24:28"
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_custom_metadata_date():
|
|
|
|
elements = partition_email(
|
|
|
|
example_doc_path("eml/fake-email-attachment.eml"),
|
|
|
|
metadata_last_modified="2020-07-05T09:24:28",
|
|
|
|
)
|
|
|
|
|
|
|
|
assert elements[0].metadata.last_modified == "2020-07-05T09:24:28"
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_eml_add_signature_to_metadata():
|
|
|
|
elements = partition_email(example_doc_path("eml/signed-doc.p7s"))
|
|
|
|
|
|
|
|
assert len(elements) == 1
|
|
|
|
assert elements[0].text == "This is a test"
|
|
|
|
assert elements[0].metadata.signature == "<SIGNATURE>\n"
|
|
|
|
|
|
|
|
|
|
|
|
# -- attachment behaviors ------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def test_extract_attachment_info():
|
|
|
|
with open(example_doc_path("eml/fake-email-attachment.eml")) as f:
|
|
|
|
msg = email.message_from_file(f, policy=policy.default)
|
|
|
|
msg = cast(EmailMessage, msg)
|
|
|
|
attachment_info = extract_attachment_info(msg)
|
|
|
|
|
|
|
|
assert len(attachment_info) > 0
|
|
|
|
assert attachment_info == ATTACH_EXPECTED_OUTPUT
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_odd_attachment_filename():
|
|
|
|
elements = partition_email(
|
|
|
|
example_doc_path("eml/email-equals-attachment-filename.eml"),
|
|
|
|
process_attachments=True,
|
|
|
|
attachment_partitioner=partition_text,
|
|
|
|
)
|
|
|
|
|
|
|
|
assert elements[1].metadata.filename == "odd=file=name.txt"
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_can_process_attachments(tmp_path: pathlib.Path):
|
|
|
|
output_dir = tmp_path / "output"
|
|
|
|
output_dir.mkdir()
|
|
|
|
filename = example_doc_path("eml/fake-email-attachment.eml")
|
2023-06-29 18:01:12 -04:00
|
|
|
with open(filename) as f:
|
2024-08-01 15:24:17 -04:00
|
|
|
msg = email.message_from_file(f, policy=policy.default)
|
|
|
|
msg = cast(EmailMessage, msg)
|
|
|
|
extract_attachment_info(msg, output_dir=str(output_dir))
|
|
|
|
|
2023-07-26 15:10:14 -04:00
|
|
|
attachment_filename = os.path.join(
|
2024-08-01 15:24:17 -04:00
|
|
|
output_dir,
|
|
|
|
str(ATTACH_EXPECTED_OUTPUT[0]["filename"]),
|
2023-07-26 15:10:14 -04:00
|
|
|
)
|
2023-08-18 18:21:11 -05:00
|
|
|
|
|
|
|
mocked_last_modification_date = "0000-00-05T09:24:28"
|
|
|
|
|
2023-06-29 18:01:12 -04:00
|
|
|
attachment_elements = partition_text(
|
|
|
|
filename=attachment_filename,
|
|
|
|
metadata_filename=attachment_filename,
|
2023-08-18 18:21:11 -05:00
|
|
|
metadata_last_modified=mocked_last_modification_date,
|
2023-06-29 18:01:12 -04:00
|
|
|
)
|
|
|
|
expected_metadata = attachment_elements[0].metadata
|
|
|
|
expected_metadata.file_directory = None
|
|
|
|
expected_metadata.attached_to_filename = filename
|
|
|
|
|
|
|
|
elements = partition_email(
|
|
|
|
filename=filename,
|
|
|
|
attachment_partitioner=partition_text,
|
|
|
|
process_attachments=True,
|
2023-08-18 18:21:11 -05:00
|
|
|
metadata_last_modified=mocked_last_modification_date,
|
2023-06-29 18:01:12 -04:00
|
|
|
)
|
|
|
|
|
Feat: Create a naive hierarchy for elements (#1268)
## **Summary**
By adding hierarchy to unstructured elements, users will have more
information for implementing vector db/LLM chunking strategies. For
example, text elements could be queried by their preceding title
element. The hierarchy is implemented by a parent_id tag in the
element's metadata.
### Features
- Introduces a parent_id to ElementMetadata (The id of the parent
element, not a pointer)
- Creates a rule set for assigning hierarchies. Sensible default is
assigned, with an optional override parameter
- Sets element parent ids if there isn't an existing parent id or
matches the ruleset
### How it works
Hierarchies are assigned via a parent id field in element metadata.
Elements are read sequentially and evaluated against a ruleset. For
example take the following elements:
1. Title, "This is the Title"
2. Text, "this is the text"
And the ruleset: `{"title": ["text"]}`. When evaluated, the parent_id of
2 will be the id of 1. The algorithm for determining this is more
complex and resolves several edge cases, so please read the code for
further details.
### Schema Changes
```
@dataclass
class ElementMetadata:
coordinates: Optional[CoordinatesMetadata] = None
data_source: Optional[DataSourceMetadata] = None
filename: Optional[str] = None
file_directory: Optional[str] = None
last_modified: Optional[str] = None
filetype: Optional[str] = None
attached_to_filename: Optional[str] = None
+ parent_id: Optional[Union[str, uuid.UUID, NoID, UUID]] = None
+ category_depth: Optional[int] = None
...
```
### Testing
```
from unstructured.partition.auto import partition
from typing import List
elements = partition(filename="./unstructured/example-docs/fake-html.html", strategy="auto")
for element in elements:
print(
f"Category: {getattr(element, 'category', '')}\n"\
f"Text: {getattr(element, 'text', '')}\n"
f"ID: {element.id}\n" \
f"Parent ID: {element.metadata.parent_id}\n"\
f"Depth: {element.metadata.category_depth}\n" \
)
```
### Additional Notes
Implementing this feature revealed a possibly undesired side-effect in
how element metadata are processed. In
`unstructured/partition/common.py` the `_add_element_metadata` is
invoked as part of the `add_metadata_with_filetype` decorator for
filetype partitioning. This method is intended to add additional
information to the metadata generated with the element including
filename and filetype, however the existing metadata is merged into a
newly created metadata object rather than the other way around. Because
of the way it's structured, new metadata fields can easily be forgotten
and pose debugging challenges to developers. This likely warrants a new
issue.
I'm guessing that the implementation is done this way to avoid issues
with deserializing elements, but could be wrong.
---------
Co-authored-by: Benjamin Torres <benjats07@users.noreply.github.com>
2023-09-14 11:23:16 -04:00
|
|
|
# This test does not need to validate if hierarchy is working
|
|
|
|
# Patch to nullify parent_id
|
|
|
|
expected_metadata.parent_id = None
|
|
|
|
elements[-1].metadata.parent_id = None
|
|
|
|
|
2024-08-27 18:02:24 -04:00
|
|
|
assert [a.name for a in os.scandir(output_dir) if a.is_file()] == ["fake-attachment.txt"]
|
2023-06-29 18:01:12 -04:00
|
|
|
assert elements[0].text.startswith("Hello!")
|
|
|
|
for element in elements[:-1]:
|
|
|
|
assert element.metadata.filename == "fake-email-attachment.eml"
|
|
|
|
assert element.metadata.subject == "Fake email with attachment"
|
|
|
|
assert elements[-1].text == "Hey this is a fake attachment!"
|
2023-08-18 18:21:11 -05:00
|
|
|
assert elements[-1].metadata == expected_metadata
|
|
|
|
|
2023-08-13 10:58:46 -07:00
|
|
|
|
2024-08-01 15:24:17 -04:00
|
|
|
def test_partition_email_can_process_min_max_with_attachments(tmp_path: pathlib.Path):
|
|
|
|
output_dir = tmp_path / "output"
|
|
|
|
output_dir.mkdir()
|
|
|
|
filename = example_doc_path("eml/fake-email-attachment.eml")
|
2023-08-18 18:21:11 -05:00
|
|
|
with open(filename) as f:
|
2024-08-01 15:24:17 -04:00
|
|
|
msg = email.message_from_file(f, policy=policy.default)
|
|
|
|
msg = cast(EmailMessage, msg)
|
|
|
|
extract_attachment_info(msg, output_dir=str(output_dir))
|
|
|
|
|
|
|
|
attachment_filename = str(
|
|
|
|
os.path.join(
|
|
|
|
output_dir,
|
|
|
|
str(ATTACH_EXPECTED_OUTPUT[0]["filename"]),
|
|
|
|
)
|
2023-08-13 10:58:46 -07:00
|
|
|
)
|
2023-08-18 18:21:11 -05:00
|
|
|
|
|
|
|
attachment_elements = partition_text(
|
|
|
|
filename=attachment_filename,
|
|
|
|
metadata_filename=attachment_filename,
|
|
|
|
min_partition=6,
|
|
|
|
max_partition=12,
|
|
|
|
)
|
|
|
|
|
|
|
|
elements = partition_email(
|
|
|
|
filename=filename,
|
|
|
|
attachment_partitioner=partition_text,
|
|
|
|
process_attachments=True,
|
|
|
|
min_partition=6,
|
|
|
|
max_partition=12,
|
|
|
|
)
|
|
|
|
|
|
|
|
assert elements[0].text.startswith("Hello!")
|
|
|
|
assert elements[-1].text == attachment_elements[-1].text
|
|
|
|
assert elements[-2].text == attachment_elements[-2].text
|
|
|
|
for element in elements:
|
|
|
|
if element.metadata.attached_to_filename is not None:
|
|
|
|
assert len(element.text) <= 12
|
|
|
|
assert len(element.text) >= 6
|
2023-06-29 18:01:12 -04:00
|
|
|
|
|
|
|
|
2024-08-01 15:24:17 -04:00
|
|
|
# -- language behaviors --------------------------------------------------------------------------
|
2023-08-14 11:38:53 -07:00
|
|
|
|
2023-08-25 20:09:25 -04:00
|
|
|
|
2024-08-01 15:24:17 -04:00
|
|
|
def test_partition_email_element_metadata_has_languages():
|
2023-10-12 12:47:55 -07:00
|
|
|
elements = partition_email(example_doc_path("eml/fake-email.eml"))
|
2023-09-11 16:00:14 -05:00
|
|
|
|
2023-10-10 20:47:56 -05:00
|
|
|
assert elements[0].metadata.languages == ["eng"]
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_email_respects_languages_arg():
|
2024-08-01 15:24:17 -04:00
|
|
|
elements = partition_email(example_doc_path("eml/fake-email.eml"), languages=["deu"])
|
|
|
|
|
2023-10-10 20:47:56 -05:00
|
|
|
assert all(element.metadata.languages == ["deu"] for element in elements)
|
|
|
|
|
|
|
|
|
|
|
|
def test_partition_eml_respects_detect_language_per_element():
|
2024-08-01 15:24:17 -04:00
|
|
|
elements = partition_email(
|
|
|
|
example_doc_path("language-docs/eng_spa_mult.eml"),
|
|
|
|
detect_language_per_element=True,
|
|
|
|
)
|
2023-10-10 20:47:56 -05:00
|
|
|
# languages other than English and Spanish are detected by this partitioner,
|
|
|
|
# so this test is slightly different from the other partition tests
|
rfctr(html): replace html parser (#3218)
**Summary**
Replace legacy HTML parser with recursive version that captures all
content and provides flexibility to add new metadata. It's also
substantially faster although that's just a happy side-effect.
**Additional Context**
The prior HTML parsing algorithm that makes up the core of HTML
partitioning was buggy and very difficult to reason about because it did
not conform to the inherently recursive structure of HTML. The new
version retains `lxml` as the performant and reliable base library but
uses `lxml`'s custom element classes to efficiently classify HTML
elements by their behaviors (block-item and inline (phrasing) primarily)
and give those elements the desired partitioning behaviors.
This solves a host of existing problems with content being skipped and
elements (paragraphs) being divided improperly, but also provides a
clear domain model for reasoning about its behavior and reliably
adjusting it to suit our existing and future purposes.
The parser's operation is recursive, closely modeling the recursive
structure of HTML itself. It's behaviors are based on the HTML Standard
and reliably produce proper and explainable results even for novel
cases.
Fixes #2325
Fixes #2562
Fixes #2675
Fixes #3168
Fixes #3227
Fixes #3228
Fixes #3230
Fixes #3237
Fixes #3245
Fixes #3247
Fixes #3255
Fixes #3309
### BEHAVIOR DIFFERENCES
#### `emphasized_text_tags` encoding is changed:
- `<strong>` is encoded as `"b"` rather than `"strong"`.
- `<em>` is encoded as `"i"` rather than `"em"`.
- `<span>` is no longer recorded in `emphasized_text_tags` (because
without the CSS we can't tell whether it's used for emphasis or if so
what kind).
- nested emphasis (e.g. bold+italic) is encoded as multiple characters
("bi").
- `emphasized_text_contents` is broken on emphasis-change boundaries,
like:
```html
`<p>foo <b>bar <i>baz</i> bada</b> bing</p>`
```
produces:
```json
{
"emphasized_text_contents": ["bar", "baz", "bada"],
"emphasized_text_tags": ["b", "bi", "b"]
}
```
whereas previously it would have produced:
```json
{
"emphasized_text_contents": ["bar baz bada", "baz"],
"emphasized_text_tags": ["b", "i"]
}
```
#### `<pre>` text is preserved as it appears in the html
Except that a leading newline is removed if present (has to be in
position 0 of text). Also, a trailing newline is stripped but only if it
appears in the very last position ([-1]) of the `<pre>` text. Old parser
stripped all leading and trailing whitespace.
Result is that:
```html
<pre>
foo
bar
baz
</pre>
```
parses to `"foo\nbar\nbaz"` which is the same result produced for:
```html
<pre>foo
bar
baz</pre>
```
This equivalence is the same behavior exhibited by a browser, which is
why we did the extra work to make it this way.
#### Whitespace normalization
Leading and trailing whitespace are removed from element text, just as
it is removed in the browser. Runs of whitespace within the element text
are reduced to a single space character (like in the browser). Note this
means that `\t`, `\n`, and ` ` are replaced with a regular space
character. All text derived from elements is whitespace normalized
except the text within a `<pre>` tag. Any leading or trailing newline is
trimmed from `<pre>` element text; all other whitespace is preserved
just as it appeared in the HTML source.
#### `link_start_indexes` metadata is no longer captured. Rationale:
- It was frequently wrong, often `-1`.
- It was deprecated but then added back in a community PR.
- Maintaining it across any possible downstream transformations (e.g.
chunking) would be expensive and almost certainly lead to wrong values
as distant code evolves.
- It is complex to compute and recompute when whitespace is normalized,
adding substantial complexity to the code and reducing readability and
maintainability
#### `<br/>` element is replaced with a single newline (`"\n"`)
but that is usually replaced with a space in `Element.text` when it is
normalized. The newline is preserved within a `<pre>` element.
- Related: _No paragraph-break on `<br/><br/>`_
#### Empty `h1..h6` elements are dropped.
HTML heading elements (`<h1..h6>`) are "skipped" (do not generate a
`Title` element) when they contain no text or contain only whitespace.
---------
Co-authored-by: scanny <scanny@users.noreply.github.com>
2024-07-10 17:14:28 -07:00
|
|
|
langs = {e.metadata.languages[0] for e in elements if e.metadata.languages is not None}
|
2024-08-01 15:24:17 -04:00
|
|
|
|
2023-10-10 20:47:56 -05:00
|
|
|
assert "eng" in langs
|
|
|
|
assert "spa" in langs
|