flow.record 3.22.dev8__tar.gz → 3.22.dev10__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/PKG-INFO +1 -1
  2. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/sqlite.py +3 -3
  3. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/base.py +17 -17
  4. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/fieldtypes/__init__.py +1 -0
  5. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/packer.py +1 -1
  6. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/selector.py +1 -1
  7. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/version.py +3 -3
  8. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow.record.egg-info/PKG-INFO +1 -1
  9. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/adapter/test_sqlite_duckdb.py +37 -1
  10. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/fieldtypes/test_fieldtypes.py +81 -1
  11. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/record/test_record.py +10 -10
  12. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/.git-blame-ignore-revs +0 -0
  13. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/.gitattributes +0 -0
  14. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/COPYRIGHT +0 -0
  15. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/LICENSE +0 -0
  16. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/MANIFEST.in +0 -0
  17. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/README.md +0 -0
  18. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/examples/__init__.py +0 -0
  19. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/examples/filesystem.py +0 -0
  20. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/examples/passivedns.py +0 -0
  21. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/examples/records.json +0 -0
  22. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/examples/selectors.py +0 -0
  23. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/examples/tcpconn.py +0 -0
  24. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/__init__.py +0 -0
  25. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/__init__.py +0 -0
  26. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/archive.py +0 -0
  27. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/avro.py +0 -0
  28. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/broker.py +0 -0
  29. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/csvfile.py +0 -0
  30. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/duckdb.py +0 -0
  31. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/elastic.py +0 -0
  32. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/jsonfile.py +0 -0
  33. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/line.py +0 -0
  34. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/mongo.py +0 -0
  35. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/split.py +0 -0
  36. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/splunk.py +0 -0
  37. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/stream.py +0 -0
  38. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/text.py +0 -0
  39. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/adapter/xlsx.py +0 -0
  40. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/context.py +0 -0
  41. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/exceptions.py +0 -0
  42. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/fieldtypes/credential.py +0 -0
  43. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/fieldtypes/net/__init__.py +0 -0
  44. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/fieldtypes/net/ip.py +0 -0
  45. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/fieldtypes/net/ipv4.py +0 -0
  46. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/fieldtypes/net/tcp.py +0 -0
  47. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/fieldtypes/net/udp.py +0 -0
  48. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/jsonpacker.py +0 -0
  49. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/stream.py +0 -0
  50. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/tools/__init__.py +0 -0
  51. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/tools/geoip.py +0 -0
  52. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/tools/rdump.py +0 -0
  53. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/utils.py +0 -0
  54. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow/record/whitelist.py +0 -0
  55. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow.record.egg-info/SOURCES.txt +0 -0
  56. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow.record.egg-info/dependency_links.txt +0 -0
  57. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow.record.egg-info/entry_points.txt +0 -0
  58. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow.record.egg-info/requires.txt +0 -0
  59. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/flow.record.egg-info/top_level.txt +0 -0
  60. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/pyproject.toml +0 -0
  61. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/setup.cfg +0 -0
  62. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/__init__.py +0 -0
  63. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/_data/.gitkeep +0 -0
  64. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/_docs/Makefile +0 -0
  65. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/_docs/conf.py +0 -0
  66. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/_docs/index.rst +0 -0
  67. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/_utils.py +0 -0
  68. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/adapter/__init__.py +0 -0
  69. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/adapter/test_avro.py +0 -0
  70. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/adapter/test_csv.py +0 -0
  71. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/adapter/test_elastic.py +0 -0
  72. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/adapter/test_json.py +0 -0
  73. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/adapter/test_line.py +0 -0
  74. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/adapter/test_splunk.py +0 -0
  75. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/adapter/test_text.py +0 -0
  76. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/adapter/test_xlsx.py +0 -0
  77. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/conftest.py +0 -0
  78. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/fieldtypes/__init__.py +0 -0
  79. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/fieldtypes/test_boolean.py +0 -0
  80. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/fieldtypes/test_ip.py +0 -0
  81. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/packer/__init__.py +0 -0
  82. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/packer/test_json_packer.py +0 -0
  83. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/packer/test_packer.py +0 -0
  84. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/record/__init__.py +0 -0
  85. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/record/test_adapter.py +0 -0
  86. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/record/test_context.py +0 -0
  87. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/record/test_descriptor.py +0 -0
  88. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/record/test_multi_timestamp.py +0 -0
  89. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/selector/__init__.py +0 -0
  90. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/selector/test_compiled.py +0 -0
  91. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/selector/test_selectors.py +0 -0
  92. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/test_deprecations.py +0 -0
  93. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/test_regressions.py +0 -0
  94. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/test_utils.py +0 -0
  95. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/tools/__init__.py +0 -0
  96. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tests/tools/test_rdump.py +0 -0
  97. {flow_record-3.22.dev8 → flow_record-3.22.dev10}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flow.record
3
- Version: 3.22.dev8
3
+ Version: 3.22.dev10
4
4
  Summary: A library for defining and creating structured data (called records) that can be streamed to disk or piped to other tools that use flow.record
5
5
  Author-email: Dissect Team <dissect@fox-it.com>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -109,10 +109,10 @@ def prepare_insert_sql(table_name: str, field_names: tuple[str]) -> str:
109
109
 
110
110
  def db_insert_record(con: sqlite3.Connection, record: Record) -> None:
111
111
  """Insert a record into the database."""
112
- table_name = record._desc.name
112
+ descriptor = record._desc
113
+ table_name = descriptor.name
113
114
  rdict = record._asdict()
114
-
115
- sql = prepare_insert_sql(table_name, record.__slots__)
115
+ sql = prepare_insert_sql(table_name, tuple(rdict.keys()))
116
116
 
117
117
  # Convert values to str() for types we don't support
118
118
  values = []
@@ -247,22 +247,22 @@ class GroupedRecord(Record):
247
247
 
248
248
  def __init__(self, name: str, records: list[Record | GroupedRecord]):
249
249
  super().__init__()
250
- self.name = to_str(name)
251
- self.records = []
252
- self.descriptors = []
253
- self.flat_fields = []
250
+ self.__name__ = to_str(name)
251
+ self.__records__ = []
252
+ self.__descriptors__ = []
253
+ self.__flat_fields__ = []
254
254
 
255
255
  # to avoid recursion in __setattr__ and __getattr__
256
256
  self.__dict__["fieldname_to_record"] = OrderedDict()
257
257
 
258
258
  for rec in records:
259
259
  if isinstance(rec, GroupedRecord):
260
- for r in rec.records:
261
- self.records.append(r)
262
- self.descriptors.append(r._desc)
260
+ for r in rec.__records__:
261
+ self.__records__.append(r)
262
+ self.__descriptors__.append(r._desc)
263
263
  else:
264
- self.records.append(rec)
265
- self.descriptors.append(rec._desc)
264
+ self.__records__.append(rec)
265
+ self.__descriptors__.append(rec._desc)
266
266
 
267
267
  all_fields = rec._desc.get_all_fields()
268
268
  required_fields = rec._desc.get_required_fields()
@@ -272,10 +272,10 @@ class GroupedRecord(Record):
272
272
  continue
273
273
  self.fieldname_to_record[fname] = rec
274
274
  if fname not in required_fields:
275
- self.flat_fields.append(field)
275
+ self.__flat_fields__.append(field)
276
276
  # Flat descriptor to maintain compatibility with Record
277
277
 
278
- self._desc = RecordDescriptor(self.name, [(f.typename, f.name) for f in self.flat_fields])
278
+ self._desc = RecordDescriptor(self.__name__, [(f.typename, f.name) for f in self.__flat_fields__])
279
279
 
280
280
  # _field_types to maintain compatibility with RecordDescriptor
281
281
  self._field_types = self._desc.recordType._field_types
@@ -291,7 +291,7 @@ class GroupedRecord(Record):
291
291
  None or the record
292
292
 
293
293
  """
294
- for record in self.records:
294
+ for record in self.__records__:
295
295
  if record._desc.name == type_name:
296
296
  return record
297
297
  return None
@@ -304,7 +304,7 @@ class GroupedRecord(Record):
304
304
  return OrderedDict((k, getattr(self, k)) for k in keys if k not in exclude)
305
305
 
306
306
  def __repr__(self) -> str:
307
- return f"<{self.name} {self.records}>"
307
+ return f"<{self.__name__} {self.__records__}>"
308
308
 
309
309
  def __setattr__(self, attr: str, val: Any) -> None:
310
310
  if attr in getattr(self, "fieldname_to_record", {}):
@@ -320,18 +320,18 @@ class GroupedRecord(Record):
320
320
 
321
321
  def _pack(self) -> tuple[str, tuple]:
322
322
  return (
323
- self.name,
324
- tuple(record._pack() for record in self.records),
323
+ self.__name__,
324
+ tuple(record._pack() for record in self.__records__),
325
325
  )
326
326
 
327
327
  def _replace(self, **kwds) -> GroupedRecord:
328
328
  new_records = [
329
329
  record.__class__(*map(kwds.pop, record.__slots__, (getattr(self, k) for k in record.__slots__)))
330
- for record in self.records
330
+ for record in self.__records__
331
331
  ]
332
332
  if kwds:
333
333
  raise ValueError(f"Got unexpected field names: {list(kwds)!r}")
334
- return GroupedRecord(self.name, new_records)
334
+ return GroupedRecord(self.__name__, new_records)
335
335
 
336
336
 
337
337
  def is_valid_field_name(name: str, check_reserved: bool = True) -> bool:
@@ -308,6 +308,7 @@ class datetime(_dt, FieldType):
308
308
  arg.second,
309
309
  arg.microsecond,
310
310
  tzinfo,
311
+ fold=arg.fold,
311
312
  )
312
313
  else:
313
314
  obj = _dt.__new__(cls, *args, **kwargs)
@@ -78,7 +78,7 @@ class RecordPacker:
78
78
  packed = RECORD_PACK_TYPE_VARINT, (neg, v.to_bytes((v.bit_length() + 7) // 8, "big"))
79
79
 
80
80
  elif isinstance(obj, GroupedRecord):
81
- for desc in obj.descriptors:
81
+ for desc in obj.__descriptors__:
82
82
  if desc.identifier not in self.descriptors:
83
83
  self.register(desc, True)
84
84
 
@@ -115,7 +115,7 @@ def upper(s: str | Any) -> str | Any:
115
115
  def names(r: Record | WrappedRecord | GroupedRecord) -> set[str]:
116
116
  """Return the available names as a set in the Record otherwise ['UnknownRecord']."""
117
117
  if isinstance(r, GroupedRecord):
118
- return {sub_record._desc.name for sub_record in r.records}
118
+ return {sub_record._desc.name for sub_record in r.__records__}
119
119
  if isinstance(r, (Record, WrappedRecord)):
120
120
  return {r._desc.name}
121
121
  return ["UnknownRecord"]
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '3.22.dev8'
32
- __version_tuple__ = version_tuple = (3, 22, 'dev8')
31
+ __version__ = version = '3.22.dev10'
32
+ __version_tuple__ = version_tuple = (3, 22, 'dev10')
33
33
 
34
- __commit_id__ = commit_id = 'g1ab6b5481'
34
+ __commit_id__ = commit_id = 'ga594c193b'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flow.record
3
- Version: 3.22.dev8
3
+ Version: 3.22.dev10
4
4
  Summary: A library for defining and creating structured data (called records) that can be streamed to disk or piped to other tools that use flow.record
5
5
  Author-email: Dissect Team <dissect@fox-it.com>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import hashlib
3
4
  import sqlite3
4
5
  from contextlib import closing
5
6
  from datetime import datetime, timezone
@@ -12,7 +13,7 @@ except ModuleNotFoundError:
12
13
 
13
14
  import pytest
14
15
 
15
- from flow.record import Record, RecordDescriptor, RecordReader, RecordWriter
16
+ from flow.record import GroupedRecord, Record, RecordDescriptor, RecordReader, RecordWriter
16
17
  from flow.record.adapter.sqlite import prepare_insert_sql
17
18
  from flow.record.base import normalize_fieldname
18
19
  from flow.record.exceptions import RecordDescriptorError
@@ -400,3 +401,38 @@ def test_selector(tmp_path: Path, db: Database) -> None:
400
401
  with RecordReader(f"{db.scheme}://{db_path}", selector="r.name == 'record12345'") as reader:
401
402
  records = list(reader)
402
403
  assert len(records) == 0
404
+
405
+
406
+ @sqlite_duckdb_parametrize
407
+ def test_grouped_record(tmp_path: Path, db: Database) -> None:
408
+ """Test adapter with grouped records."""
409
+ db_path = tmp_path / "records.db"
410
+
411
+ DigestRecord = RecordDescriptor(
412
+ "meta/record",
413
+ [
414
+ ("digest", "digest"),
415
+ ],
416
+ )
417
+
418
+ with RecordWriter(f"{db.scheme}://{db_path}") as writer:
419
+ for record in generate_records(10):
420
+ digest_record = DigestRecord(
421
+ digest=(
422
+ hashlib.md5(record.name.encode()).hexdigest(),
423
+ hashlib.sha1(record.name.encode()).hexdigest(),
424
+ hashlib.sha256(record.name.encode()).hexdigest(),
425
+ )
426
+ )
427
+ grouped = GroupedRecord("grouped/record", [digest_record, record])
428
+ writer.write(grouped)
429
+
430
+ with RecordReader(f"{db.scheme}://{db_path}", selector="r.name == 'record5'") as reader:
431
+ records = list(reader)
432
+ assert len(records) == 1
433
+ assert records[0].name == "record5"
434
+ assert records[0].digest == (
435
+ f"(md5={hashlib.md5(b'record5').hexdigest()}, "
436
+ f"sha1={hashlib.sha1(b'record5').hexdigest()}, "
437
+ f"sha256={hashlib.sha256(b'record5').hexdigest()})"
438
+ )
@@ -13,6 +13,7 @@ import pytest
13
13
  import flow.record.fieldtypes
14
14
  from flow.record import RecordDescriptor, RecordReader, RecordWriter, fieldtypes
15
15
  from flow.record.fieldtypes import (
16
+ HAS_ZONE_INFO,
16
17
  PY_312_OR_HIGHER,
17
18
  PY_313_OR_HIGHER,
18
19
  TYPE_POSIX,
@@ -28,6 +29,9 @@ from flow.record.fieldtypes import (
28
29
  )
29
30
  from flow.record.fieldtypes import datetime as dt
30
31
 
32
+ if HAS_ZONE_INFO:
33
+ from flow.record.fieldtypes import ZoneInfo
34
+
31
35
  if TYPE_CHECKING:
32
36
  from collections.abc import Callable
33
37
 
@@ -427,7 +431,7 @@ def test_datetime() -> None:
427
431
  ("2006-11-10T14:29:55.585192699999999-07:00", datetime(2006, 11, 10, 21, 29, 55, 585192, tzinfo=UTC)),
428
432
  ],
429
433
  )
430
- def test_datetime_formats(tmp_path: pathlib.Path, value: str, expected_dt: datetime) -> None:
434
+ def test_datetime_formats(tmp_path: pathlib.Path, value: str | datetime | float, expected_dt: datetime) -> None:
431
435
  TestRecord = RecordDescriptor(
432
436
  "test/datetime",
433
437
  [
@@ -448,6 +452,82 @@ def test_datetime_formats(tmp_path: pathlib.Path, value: str, expected_dt: datet
448
452
  assert record.dt == expected_dt
449
453
 
450
454
 
455
+ DATETIME_FOLD_PARAMS = [
456
+ (datetime(2023, 1, 1, tzinfo=UTC, fold=1), datetime(2023, 1, 1, tzinfo=UTC)),
457
+ ]
458
+ if HAS_ZONE_INFO:
459
+ DATETIME_FOLD_PARAMS.append(
460
+ (
461
+ datetime(2025, 10, 26, 2, 0, 3, tzinfo=ZoneInfo("Europe/Amsterdam"), fold=1),
462
+ datetime(2025, 10, 26, 1, 0, 3, tzinfo=UTC),
463
+ ),
464
+ )
465
+
466
+
467
+ @pytest.mark.skipif(not HAS_ZONE_INFO, reason="ZoneInfo is required for testing datetime fold parameter")
468
+ @pytest.mark.parametrize(("value", "expected_dt"), DATETIME_FOLD_PARAMS)
469
+ def test_datetime_formats_fold(tmp_path: pathlib.Path, value: datetime, expected_dt: datetime) -> None:
470
+ """test whether datetime accepts fold parameters and converts it correctly"""
471
+ TestRecord = RecordDescriptor(
472
+ "test/datetime",
473
+ [
474
+ ("datetime", "dt"),
475
+ ],
476
+ )
477
+ record = TestRecord(dt=value)
478
+ assert record.dt.fold == 1
479
+ assert record.dt.astimezone(UTC) == expected_dt
480
+
481
+ # test packing / serialization of datetime fields
482
+ path = tmp_path / "datetime.records"
483
+ with RecordWriter(path) as writer:
484
+ writer.write(record)
485
+
486
+ # test unpacking / deserialization of datetime fields
487
+ with RecordReader(path) as reader:
488
+ record = next(iter(reader))
489
+ # Need to convert it to UTC specifically as the timezones do not match
490
+ assert record.dt.astimezone(UTC) == expected_dt
491
+
492
+
493
+ @pytest.mark.skipif(not HAS_ZONE_INFO, reason="ZoneInfo is required for testing datetime fold parameter")
494
+ def test_datetime_fold_example() -> None:
495
+ """
496
+ Test datetime fold parameter during daylight saving time changes in the Netherlands, which has a
497
+ timezone offset of +01:00 during wintertime and +02:00 during summertime.
498
+ """
499
+
500
+ TestRecord = RecordDescriptor(
501
+ "test/datetime",
502
+ [
503
+ ("datetime", "dt"),
504
+ ],
505
+ )
506
+
507
+ # 2025-10-26 is the date of the end of daylight saving time in the Netherlands.
508
+ # At 3:00 AM the clock goes back to 2:00 AM, so 2:00 AM occurs twice. The first occurrence has fold=0
509
+ record = TestRecord("2025-10-26T00:00:00+00:00")
510
+ nl_dt = record.dt.astimezone(ZoneInfo("Europe/Amsterdam"))
511
+ assert nl_dt.isoformat() == "2025-10-26T02:00:00+02:00"
512
+ assert nl_dt.hour == 2
513
+ assert nl_dt.fold == 0
514
+
515
+ # test that both datetimes are considered equal when converted to UTC
516
+ record2 = TestRecord(nl_dt)
517
+ assert record.dt.astimezone(UTC) == record2.dt.astimezone(UTC)
518
+
519
+ # wintertime, the clock goes back from 3:00 AM to 2:00 AM, so 2:00 AM occurs twice. The second occurrence has fold=1
520
+ record = TestRecord("2025-10-26T01:00:00+00:00")
521
+ nl_dt = record.dt.astimezone(ZoneInfo("Europe/Amsterdam"))
522
+ assert nl_dt.isoformat() == "2025-10-26T02:00:00+01:00"
523
+ assert nl_dt.hour == 2
524
+ assert nl_dt.fold == 1
525
+
526
+ # test that both datetimes are considered equal when converted to UTC
527
+ record2 = TestRecord(nl_dt)
528
+ assert record.dt.astimezone(UTC) == record2.dt.astimezone(UTC)
529
+
530
+
451
531
  def test_digest() -> None:
452
532
  TestRecord = RecordDescriptor(
453
533
  "test/digest",
@@ -178,15 +178,15 @@ def test_grouped_record() -> None:
178
178
  grouped.hello = "new value"
179
179
  assert grouped.hello == "new value"
180
180
  assert grouped.profile == "omg"
181
- assert grouped.records[0].hello == "new value"
182
- assert grouped.records[1].hello == "other hello"
181
+ assert grouped.__records__[0].hello == "new value"
182
+ assert grouped.__records__[1].hello == "other hello"
183
183
 
184
- grouped.records[1].hello = "testing"
184
+ grouped.__records__[1].hello = "testing"
185
185
  assert grouped.hello != "testing"
186
186
  assert grouped.hello == "new value"
187
- assert grouped.records[1].hello == "testing"
187
+ assert grouped.__records__[1].hello == "testing"
188
188
 
189
- assert len(grouped.records) == 2
189
+ assert len(grouped.__records__) == 2
190
190
 
191
191
  # Test grouped._asdict
192
192
  rdict = grouped._asdict()
@@ -250,7 +250,7 @@ def test_grouped_records_packing(tmp_path: Path) -> None:
250
250
  assert isinstance(record, Record)
251
251
  assert isinstance(record, GroupedRecord)
252
252
  assert record.common == "world" # first 'key' has precendence
253
- assert record.name == "grouped/ab"
253
+ assert record.__name__ == "grouped/ab"
254
254
  assert record.a_string == "hello"
255
255
  assert record.a_count == 12345
256
256
  assert record.b_count == 54321
@@ -259,12 +259,12 @@ def test_grouped_records_packing(tmp_path: Path) -> None:
259
259
  assert record._classification == "CLASSIFIED"
260
260
 
261
261
  # access 'common' on second record directly
262
- assert record.records[1].common == "bye"
262
+ assert record.__records__[1].common == "bye"
263
263
 
264
264
  # access raw records directly
265
- assert len(record.records) == 2
266
- assert record.records[0]._desc.name == "test/a"
267
- assert record.records[1]._desc.name == "test/b"
265
+ assert len(record.__records__) == 2
266
+ assert record.__records__[0]._desc.name == "test/a"
267
+ assert record.__records__[1]._desc.name == "test/b"
268
268
 
269
269
  # test using selectors
270
270
  reader = RecordReader(path, selector="r.a_count == 12345")