|
1 | 1 | from __future__ import annotations |
2 | 2 |
|
| 3 | +import datetime |
3 | 4 | import io |
4 | 5 | import itertools |
| 6 | +import string |
5 | 7 |
|
6 | 8 | import pytest |
7 | 9 | from hypothesis import HealthCheck, given, settings |
|
16 | 18 | one_of, |
17 | 19 | tuples, |
18 | 20 | sampled_from, |
| 21 | + text, |
| 22 | + characters, |
| 23 | + dates, |
19 | 24 | ) |
20 | 25 |
|
21 | 26 | import shapefile as shp |
@@ -518,3 +523,119 @@ def test_shx_reader_writer_roundtrip(codes_and_shapes)-> None: |
518 | 523 | assert r.offsets == offsets_B |
519 | 524 | assert r.shape_lengths_B == sizes_B |
520 | 525 |
|
| 526 | + |
| 527 | + |
| 528 | +DBF_FIELD_TYPES = { |
| 529 | + "C": {}, |
| 530 | + "N": {"max_decimal" : 20, "max_length": 22}, # max length=23 to avoid error due to precision limit, e.g.: |
| 531 | + "F": {"max_decimal" : 20, "max_length": 22}, # hypothesis.errors.InvalidArgument: max_value=100000000000000000000000 |
| 532 | + # cannot be exactly represented as a float of |
| 533 | + # width 64 - use max_value=1e+23 instead. |
| 534 | + "L": {"max_length": 1}, |
| 535 | + "D": {"min_length": 8, "max_length": 8}, |
| 536 | +} |
| 537 | + |
| 538 | +@composite |
| 539 | +def dbf_field(draw): |
| 540 | + field_type, bounds_dict = draw(sampled_from(list(DBF_FIELD_TYPES.items()))) |
| 541 | + |
| 542 | + name = draw( |
| 543 | + text( |
| 544 | + alphabet=characters(codec="ascii"), |
| 545 | + min_size=1, |
| 546 | + max_size=10, |
| 547 | + ) |
| 548 | + ) |
| 549 | + |
| 550 | + max_length = bounds_dict.get("max_length", 254) |
| 551 | + min_length = bounds_dict.get("min_length", 1) |
| 552 | + max_decimal = bounds_dict.get("max_decimal", 0) |
| 553 | + size = draw(integers(min_value=min_length, max_value=max_length)) |
| 554 | + decimal = draw(integers(min_value=0, max_value=max(0,min(size - 3, max_decimal)))) |
| 555 | + |
| 556 | + |
| 557 | + return {"name": name, "field_type": field_type, "size": size, "decimal": decimal} |
| 558 | + |
| 559 | +ascii_printable = string.ascii_letters + string.digits + string.punctuation #+ " " |
| 560 | + |
| 561 | +def record_value_for_field(name: str, field_type: str, size: int, decimal: int = 0): |
| 562 | + |
| 563 | + if field_type == "C": |
| 564 | + return text( |
| 565 | + alphabet=ascii_printable, |
| 566 | + min_size=0, |
| 567 | + max_size=size, |
| 568 | + ) |
| 569 | + if field_type in {"N", "F"}: |
| 570 | + |
| 571 | + int_digits = size if decimal == 0 else size - decimal - 1 |
| 572 | + min_int = -(10 ** (int_digits - 1) - 1) |
| 573 | + max_int = 10 ** int_digits - 1 |
| 574 | + |
| 575 | + if decimal == 0: |
| 576 | + return integers(min_value=min_int, max_value=max_int) |
| 577 | + |
| 578 | + # Max finite float: 2**1023 * (2 - 2**(-52)) |
| 579 | + return floats( |
| 580 | + min_value=min_int - 1, |
| 581 | + max_value=max_int + 1, |
| 582 | + exclude_min=True, |
| 583 | + exclude_max=True, |
| 584 | + ) |
| 585 | + if field_type == "L": |
| 586 | + return sampled_from([True, False, None]) |
| 587 | + if field_type == "D": |
| 588 | + return one_of(dates(), dates().map(lambda d: d.strftime("%Y%m%d"))) |
| 589 | + |
| 590 | + raise ValueError(f"Unsupported: {field_type=}") |
| 591 | + |
| 592 | + |
| 593 | +@composite |
| 594 | +def dbf_fields_and_records( |
| 595 | + draw, |
| 596 | + max_fields=10, # In DbfWriter.__init__, max_num_fields: int = 2046, |
| 597 | + max_records=20, |
| 598 | + ): |
| 599 | + |
| 600 | + fields = draw(lists(dbf_field(), min_size=1, max_size=max_fields)) |
| 601 | + |
| 602 | + record_strategy = tuples(*(record_value_for_field(**field) for field in fields)) |
| 603 | + |
| 604 | + records = draw(lists(record_strategy, min_size=0, max_size=max_records)) |
| 605 | + |
| 606 | + return fields, records |
| 607 | + |
| 608 | + |
| 609 | + |
| 610 | +@pytest.mark.hypothesis |
| 611 | +@given(fields_and_records=dbf_fields_and_records()) |
| 612 | +def test_dbf_reader_writer_roundtrip(fields_and_records)-> None: |
| 613 | + fields, records = fields_and_records |
| 614 | + stream = io.BytesIO() |
| 615 | + with shp.DbfWriter(dbf=stream) as dbf_w: |
| 616 | + for field in fields: |
| 617 | + dbf_w.field(**field) |
| 618 | + for record in records: |
| 619 | + dbf_w.record(*record) |
| 620 | + stream.seek(0) |
| 621 | + with shp.DbfReader(dbf=stream) as r: |
| 622 | + actual_fields = iter(r.fields) |
| 623 | + next(actual_fields) # skip deletion flag |
| 624 | + for f_r, f_w in itertools.zip_longest(actual_fields, fields): |
| 625 | + actual_field_dict = f_r._asdict() |
| 626 | + for k in ("field_type", "size", "decimal"): |
| 627 | + assert actual_field_dict[k] == f_w[k], f"{k=}, {actual_field_dict[k]=}, {f_w[k]=}" |
| 628 | + for exp_rec, actual_rec in itertools.zip_longest(records, r.records()): |
| 629 | + for expected, actual, field in itertools.zip_longest(exp_rec, actual_rec, fields): |
| 630 | + field_type = field["field_type"] |
| 631 | + decimal = field["decimal"] |
| 632 | + if field_type == "D": |
| 633 | + if isinstance(expected, datetime.date): |
| 634 | + expected = expected.strftime("%Y%m%d") |
| 635 | + if isinstance(actual, datetime.date): |
| 636 | + actual = actual.strftime("%Y%m%d") |
| 637 | + elif field_type in ("N", "F"): |
| 638 | + expected = float(format(expected, f".{decimal}f")) |
| 639 | + # elif field_type == "C": |
| 640 | + # expected = expected.strip() |
| 641 | + assert actual == expected, f"{actual=}, {expected=}, {field_type=}, {type(actual)=}, {type(expected)=}" |
0 commit comments