Skip to content

Commit 3ec5be1

Browse files
authored
Merge pull request #46 from asfadmin/mrp/feature/add-attribute-to-h5key
PR-7027 add h5 attribute syntax and bzip2 support
2 parents 802b25c + 69f7fcd commit 3ec5be1

7 files changed

Lines changed: 219 additions & 14 deletions

File tree

mandible/metadata_mapper/format/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from .format import (
22
FORMAT_REGISTRY,
3+
Bzip2File,
34
FileFormat,
45
Format,
56
FormatError,
@@ -21,6 +22,7 @@
2122

2223
__all__ = (
2324
"FORMAT_REGISTRY",
25+
"Bzip2File",
2426
"FileFormat",
2527
"Format",
2628
"FormatError",

mandible/metadata_mapper/format/format.py

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import bz2
12
import contextlib
23
import inspect
34
import json
@@ -67,10 +68,7 @@ def get_values(
6768
"""Get a list of values from a file"""
6869

6970
with self.parse_data(file) as data:
70-
return {
71-
key: self._eval_key_wrapper(data, key)
72-
for key in keys
73-
}
71+
return {key: self._eval_key_wrapper(data, key) for key in keys}
7472

7573
def get_value(self, file: IO[bytes], key: Key) -> Any:
7674
"""Convenience function for getting a single value"""
@@ -150,7 +148,7 @@ class ZipMember(Format):
150148
"""A member from a zip archive.
151149
152150
:param filters: A set of filters used to select the desired archive member
153-
:param format: The Format of the archive member
151+
:param format: The `Format` of the archive member
154152
"""
155153

156154
filters: dict[str, Any]
@@ -235,10 +233,7 @@ def parse_data(file: IO[bytes]) -> Generator[dict]:
235233
with zipfile.ZipFile(file, "r") as zf:
236234
yield {
237235
"infolist": [
238-
{
239-
k: getattr(info, k)
240-
for k in ZIP_INFO_ATTRS
241-
}
236+
{k: getattr(info, k) for k in ZIP_INFO_ATTRS}
242237
for info in zf.infolist()
243238
],
244239
"filename": zf.filename,
@@ -250,3 +245,29 @@ def eval_key(data: dict, key: Key) -> Any:
250245
values = jsonpath.get(data, key.key)
251246

252247
return key.resolve_list_match(values)
248+
249+
250+
@dataclass
251+
class Bzip2File(Format):
252+
"""A Bzip2 compressed file
253+
254+
:param format: The `Format` of the compressed file
255+
"""
256+
257+
format: Format
258+
259+
def get_values(
260+
self,
261+
file: IO[bytes],
262+
keys: Iterable[Key],
263+
) -> dict[Key, Any]:
264+
"""Get a list of values from a file"""
265+
266+
with bz2.BZ2File(file, mode="rb") as bz2f:
267+
return self.format.get_values(bz2f, keys)
268+
269+
def get_value(self, file: IO[bytes], key: Key) -> Any:
270+
"""Convenience function for getting a single value"""
271+
272+
with bz2.BZ2File(file, mode="rb") as bz2f:
273+
return self.format.get_value(bz2f, key)

mandible/metadata_mapper/format/h5.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import contextlib
22
from dataclasses import dataclass
3-
from typing import IO, Any
3+
from typing import IO, Any, Optional
44

55
import h5py
66
import numpy as np
@@ -18,7 +18,33 @@ def parse_data(file: IO[bytes]) -> contextlib.AbstractContextManager[Any]:
1818

1919
@staticmethod
2020
def eval_key(data: Any, key: Key) -> Any:
21-
return normalize(data[key.key][()])
21+
group_key, attribute_key = parse_key(key.key)
22+
if attribute_key is not None:
23+
return normalize(data[group_key].attrs.get(attribute_key))
24+
return normalize(data[group_key][()])
25+
26+
27+
def parse_key(key: str) -> tuple[str, Optional[str]]:
28+
"""Parse a HDF5 key where '@' separates the group name from an attribute name.
29+
30+
The special @ character can be escaped as @@ if the group or attribute name contains a literal '@'.
31+
:returns: (str, str | None) -- the group name and the attribute name (if any)
32+
"""
33+
34+
# HDF5 states null character is not a valid group name
35+
# https://docs.hdfgroup.org/documentation/hdf5/latest/_l_b_grp_create_names.html
36+
placeholder = "\0"
37+
temp = key.replace("@@", placeholder)
38+
39+
if temp.count("@") > 1:
40+
raise ValueError(f"Invalid key: multiple '@' in '{key}'")
41+
42+
if "@" not in temp:
43+
return temp.replace(placeholder, "@"), None
44+
45+
left, right = temp.split("@", 1)
46+
47+
return left.replace(placeholder, "@"), right.replace(placeholder, "@")
2248

2349

2450
def normalize(node_val: Any) -> Any:
@@ -30,8 +56,7 @@ def normalize(node_val: Any) -> Any:
3056
return float(node_val)
3157
if isinstance(node_val, np.ndarray):
3258
value = [
33-
x.decode("utf-8") if isinstance(x, bytes) else x
34-
for x in node_val.tolist()
59+
x.decode("utf-8") if isinstance(x, bytes) else x for x in node_val.tolist()
3560
]
3661
return value
3762
if isinstance(node_val, bytes):

tests/data/example.json.bz2

413 Bytes
Binary file not shown.

tests/integration_tests/test_full_example.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ def sources():
3131
"class": "Xml",
3232
},
3333
},
34+
"bzip2json": {
35+
"storage": {
36+
"class": "LocalFile",
37+
"filters": {
38+
"name": r"example\.json\.bz2",
39+
},
40+
},
41+
"format": {
42+
"class": "Bzip2File",
43+
"format": {
44+
"class": "Json",
45+
},
46+
},
47+
},
3448
}
3549

3650

@@ -83,6 +97,29 @@ def template():
8397
return_list=True,
8498
),
8599
},
100+
"Bzip2JsonMd": {
101+
"description": mapped("bzip2json", "description"),
102+
"total": mapped("bzip2json", "meta.summary.total"),
103+
"complete": mapped("bzip2json", "meta.summary.complete"),
104+
"null": mapped("bzip2json", "meta.null"),
105+
# JSONPath only queries
106+
"banana_price": mapped("bzip2json", "inventory[?name = 'Banana'].price"),
107+
"oreo_price": mapped(
108+
"bzip2json",
109+
"inventory[?name = 'Oreo'].price",
110+
default=4.49,
111+
),
112+
"first_red_item": mapped(
113+
"bzip2json",
114+
"inventory[?attributes.color = 'red'].name",
115+
return_first=True,
116+
),
117+
"in_stock_items": mapped(
118+
"bzip2json",
119+
"inventory[?in_stock = true].name",
120+
return_list=True,
121+
),
122+
},
86123
})
87124

88125

@@ -94,7 +131,7 @@ def context(data_path):
94131
"name": f"example.{ext}",
95132
"path": str(data_path / f"example.{ext}"),
96133
}
97-
for ext in ("json", "xml", "h5")
134+
for ext in ("json", "json.bz2", "xml", "h5")
98135
],
99136
meta={
100137
"json_file_name": r"example\.json",
@@ -129,4 +166,14 @@ def test_full_example(context, sources, template):
129166
"first_red_item": "Apple",
130167
"in_stock_items": ["Apple", "Banana", "Tomato", "Scotch Tape", "Oreo"],
131168
},
169+
"Bzip2JsonMd": {
170+
"description": "A store inventory",
171+
"total": 5,
172+
"complete": False,
173+
"null": None,
174+
"banana_price": 0.99,
175+
"oreo_price": 4.49,
176+
"first_red_item": "Apple",
177+
"in_stock_items": ["Apple", "Banana", "Tomato", "Scotch Tape", "Oreo"],
178+
},
132179
}

tests/test_format.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import bz2
12
import io
3+
import json
24
import zipfile
35
from unittest import mock
46

@@ -7,6 +9,7 @@
79
from mandible.metadata_mapper.format import (
810
FORMAT_REGISTRY,
911
H5,
12+
Bzip2File,
1013
Format,
1114
FormatError,
1215
Json,
@@ -24,6 +27,7 @@
2427

2528
def test_registry():
2629
assert FORMAT_REGISTRY == {
30+
"Bzip2File": Bzip2File,
2731
"H5": H5,
2832
"Json": Json,
2933
"Xml": Xml,
@@ -94,6 +98,47 @@ def test_h5_empty_key():
9498
format.get_value(file, Key(""))
9599

96100

101+
@pytest.mark.h5
102+
def test_h5_attribute():
103+
file = io.BytesIO()
104+
with h5py.File(file, "w") as f:
105+
f["foo"] = "foo value"
106+
f["bar"] = "bar value"
107+
f["list"] = ["list", "value"]
108+
f["foo@bar"] = "foo@bar value"
109+
new_group = f.create_group("foo_with_attribute")
110+
new_group.attrs["value"] = "foo_with_attribute value"
111+
new_group.attrs["foo@bar"] = "foo_with_attribute @bar value"
112+
new_group_with_at = f.create_group("bar@foo")
113+
new_group_with_at.attrs["attr@ibute"] = "testing_attribute@_group@"
114+
115+
format = H5()
116+
117+
assert format.get_values(
118+
file,
119+
[
120+
Key("/foo"),
121+
Key("bar"),
122+
Key("list"),
123+
Key("foo@@bar"),
124+
Key("foo_with_attribute@value"),
125+
Key("foo_with_attribute@foo@@bar"),
126+
Key("bar@@foo@attr@@ibute"),
127+
],
128+
) == {
129+
Key("/foo"): "foo value",
130+
Key("bar"): "bar value",
131+
Key("list"): ["list", "value"],
132+
Key("foo@@bar"): "foo@bar value",
133+
Key("foo_with_attribute@value"): "foo_with_attribute value",
134+
Key("foo_with_attribute@foo@@bar"): "foo_with_attribute @bar value",
135+
Key("bar@@foo@attr@@ibute"): "testing_attribute@_group@",
136+
}
137+
138+
with pytest.raises(FormatError, match="Invalid key: multiple '@'"):
139+
format.get_values(file, [Key("test@test@test")])
140+
141+
97142
@pytest.mark.h5
98143
def test_h5_key_error():
99144
file = io.BytesIO()
@@ -430,3 +475,49 @@ def test_xml_key_error():
430475

431476
with pytest.raises(FormatError, match="key not found 'foo'"):
432477
format.get_values(file, [Key("foo")])
478+
479+
480+
@pytest.mark.h5
481+
def test_bzip2_h5py():
482+
h5_buffer = io.BytesIO()
483+
with h5py.File(h5_buffer, "w") as f:
484+
f["foo"] = "foo value"
485+
f["bar"] = "bar value"
486+
f["list"] = ["list", "value"]
487+
488+
bz2_compressed_file = io.BytesIO(bz2.compress(h5_buffer.getvalue()))
489+
format = Bzip2File(format=H5())
490+
491+
assert format.get_value(bz2_compressed_file, Key("foo")) == "foo value"
492+
bz2_compressed_file.seek(0)
493+
assert format.get_value(bz2_compressed_file, Key("bar")) == "bar value"
494+
bz2_compressed_file.seek(0)
495+
assert format.get_value(bz2_compressed_file, Key("list")) == ["list", "value"]
496+
bz2_compressed_file.seek(0)
497+
assert format.get_values(bz2_compressed_file, [Key("foo"), Key("bar")]) == {
498+
Key("foo"): "foo value",
499+
Key("bar"): "bar value",
500+
}
501+
502+
503+
def test_bzip2_json():
504+
json_bytes = json.dumps(
505+
{
506+
"foo": "foo value",
507+
"bar": "bar value",
508+
},
509+
).encode("utf-8")
510+
511+
bz2_compressed_file = io.BytesIO(bz2.compress(json_bytes))
512+
format = Bzip2File(format=Json())
513+
514+
assert format.get_value(bz2_compressed_file, Key("$.foo")) == "foo value"
515+
bz2_compressed_file.seek(0)
516+
assert format.get_values(bz2_compressed_file, [Key("$.foo")]) == {
517+
Key("$.foo"): "foo value",
518+
}
519+
bz2_compressed_file.seek(0)
520+
assert format.get_value(bz2_compressed_file, Key("$")) == {
521+
"bar": "bar value",
522+
"foo": "foo value",
523+
}

tests/test_h5.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,22 @@ def test_normalize():
2323
assert normalize(np.array(["A", "B"], dtype="|S1")) == ["A", "B"]
2424
assert normalize(np.array(["A", "B"], dtype="O")) == ["A", "B"]
2525
assert normalize(np.array([], dtype="|S1")) == []
26+
27+
28+
def test_parse_key():
29+
from mandible.metadata_mapper.format.h5 import parse_key
30+
31+
assert parse_key("foo") == ("foo", None)
32+
assert parse_key("foo@@oo") == ("foo@oo", None)
33+
assert parse_key("foo@bar") == ("foo", "bar")
34+
assert parse_key("@bar") == ("", "bar")
35+
assert parse_key("foo@") == ("foo", "")
36+
assert parse_key("fo@@o@bar") == ("fo@o", "bar")
37+
assert parse_key("foo@@@bar") == ("foo@", "bar")
38+
assert parse_key("@@@@") == ("@@", None)
39+
assert parse_key("@@@foo@@") == ("@", "foo@")
40+
41+
with pytest.raises(ValueError):
42+
parse_key("a@b@c")
43+
with pytest.raises(ValueError):
44+
parse_key("@@a@b@c@@")

0 commit comments

Comments
 (0)