Skip to content

Commit

Permalink
Merge pull request #301 from jodal/extract-more-dates
Browse files Browse the repository at this point in the history
Extract date and datetime from more GS1 AIs
  • Loading branch information
jodal authored Jul 29, 2024
2 parents 5095ebe + 8cfeb3a commit 53a2743
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 40 deletions.
6 changes: 3 additions & 3 deletions src/biip/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
description='Company internal information', data_title='INTERNAL',
fnc1_required=True, format='N2+X..90'), value='385074',
pattern_groups=['385074'], gln=None, gln_error=None, gtin=None,
gtin_error=None, sscc=None, sscc_error=None, date=None, decimal=None,
money=None)])
gtin_error=None, sscc=None, sscc_error=None, date=None, datetime=None,
decimal=None, money=None)])
In the next example, the value is only valid as a GS1 Message and the GTIN
parser returns an error explaining why the value cannot be interpreted as a
Expand All @@ -37,7 +37,7 @@
BY', fnc1_required=False, format='N2+N6'), value='210527',
pattern_groups=['210527'], gln=None, gln_error=None, gtin=None,
gtin_error=None, sscc=None, sscc_error=None, date=datetime.date(2021, 5,
27), decimal=None, money=None)])
27), datetime=None, decimal=None, money=None)])
If a value cannot be interpreted as any supported format, an exception is
raised with a reason from each parser.
Expand Down
8 changes: 4 additions & 4 deletions src/biip/gs1/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@
format=GtinFormat.GTIN_13, prefix=GS1Prefix(value='703', usage='GS1
Norway'), company_prefix=GS1CompanyPrefix(value='703206'),
payload='703206980498', check_digit=8, packaging_level=None),
gtin_error=None, sscc=None, sscc_error=None, date=None, decimal=None,
money=None)
gtin_error=None, sscc=None, sscc_error=None, date=None, datetime=None,
decimal=None, money=None)
The message object has :meth:`~GS1Message.get` and :meth:`~GS1Message.filter`
methods to lookup element strings either by the Application Identifier's
Expand All @@ -61,13 +61,13 @@
fnc1_required=False, format='N2+N6'), value='210526',
pattern_groups=['210526'], gln=None, gln_error=None, gtin=None,
gtin_error=None, sscc=None, sscc_error=None, date=datetime.date(2021, 5,
26), decimal=None, money=None)
26), datetime=None, decimal=None, money=None)
>>> msg.get(ai="10")
GS1ElementString(ai=GS1ApplicationIdentifier(ai='10', description='Batch
or lot number', data_title='BATCH/LOT', fnc1_required=True,
format='N2+X..20'), value='0329', pattern_groups=['0329'], gln=None,
gln_error=None, gtin=None, gtin_error=None, sscc=None, sscc_error=None,
date=None, decimal=None, money=None)
date=None, datetime=None, decimal=None, money=None)
"""

from typing import Tuple
Expand Down
58 changes: 45 additions & 13 deletions src/biip/gs1/_element_strings.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@
from __future__ import annotations

import calendar
import datetime
import datetime as dt
import re
from dataclasses import dataclass
from decimal import Decimal
from typing import Iterable, List, Optional
from typing import Iterable, List, Optional, Tuple

from biip import ParseError
from biip.gln import Gln
Expand Down Expand Up @@ -44,7 +44,7 @@ class GS1ElementString:
prefix=GS1Prefix(value='703', usage='GS1 Norway'),
company_prefix=GS1CompanyPrefix(value='703206'), payload='703206980498',
check_digit=8, packaging_level=None), gtin_error=None, sscc=None,
sscc_error=None, date=None, decimal=None, money=None)
sscc_error=None, date=None, datetime=None, decimal=None, money=None)
>>> element_string.as_hri()
'(01)07032069804988'
"""
Expand Down Expand Up @@ -77,7 +77,10 @@ class GS1ElementString:
sscc_error: Optional[str] = None

#: A date created from the element string, if the AI represents a date.
date: Optional[datetime.date] = None
date: Optional[dt.date] = None

#: A datetime created from the element string, if the AI represents a datetime.
datetime: Optional[dt.datetime] = None

#: A decimal value created from the element string, if the AI represents a number.
decimal: Optional[Decimal] = None
Expand Down Expand Up @@ -157,7 +160,7 @@ def extract(
rcn_verify_variable_measure=rcn_verify_variable_measure,
)
element._set_sscc() # noqa: SLF001
element._set_date() # noqa: SLF001
element._set_date_and_datetime() # noqa: SLF001
element._set_decimal() # noqa: SLF001

return element
Expand Down Expand Up @@ -204,14 +207,29 @@ def _set_sscc(self) -> None:
self.sscc = None
self.sscc_error = str(exc)

def _set_date(self) -> None:
if self.ai.ai not in ("11", "12", "13", "15", "16", "17"):
def _set_date_and_datetime(self) -> None:
if self.ai.ai not in (
"11",
"12",
"13",
"15",
"16",
"17",
"4324",
"4325",
"4326",
"7003",
"7006",
"7007",
"7011",
"8008",
):
return

try:
self.date = _parse_date(self.value)
self.date, self.datetime = _parse_date_and_datetime(self.value)
except ValueError as exc:
msg = f"Failed to parse GS1 AI {self.ai} date from {self.value!r}."
msg = f"Failed to parse GS1 AI {self.ai} date/time from {self.value!r}."
raise ParseError(msg) from exc

def _set_decimal(self) -> None:
Expand Down Expand Up @@ -268,12 +286,26 @@ def as_hri(self) -> str:
return f"{self.ai}{self.value}"


def _parse_date(value: str) -> datetime.date:
year, month, day = int(value[0:2]), int(value[2:4]), int(value[4:6])
def _parse_date_and_datetime(value: str) -> Tuple[dt.date, Optional[dt.datetime]]:
pairs = [value[i : i + 2] for i in range(0, len(value), 2)]

year = int(pairs[0])
year += _get_century(year)
month = int(pairs[1])
day = int(pairs[2])
if day == 0:
day = _get_last_day_of_month(year, month)
return datetime.date(year, month, day)
date = dt.date(year, month, day)
if not pairs[3:]:
return date, None

hour = int(pairs[3])
minute = int(pairs[4] if pairs[4:] else 0)
seconds = int(pairs[5] if pairs[5:] else 0)
if hour == 99 and minute == 99:
return date, None

return date, dt.datetime(year, month, day, hour, minute, seconds) # noqa: DTZ001


def _get_century(two_digit_year: int) -> int:
Expand All @@ -291,7 +323,7 @@ def _get_century(two_digit_year: int) -> int:
References:
GS1 General Specifications, section 7.12
"""
current_year = datetime.datetime.now(tz=datetime.timezone.utc).year
current_year = dt.datetime.now(tz=dt.timezone.utc).year
current_century = current_year - current_year % 100
two_digit_current_year = current_year - current_century

Expand Down
59 changes: 48 additions & 11 deletions tests/gs1/test_element_strings.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import datetime
from datetime import date
import datetime as dt
from decimal import Decimal
from typing import Optional

Expand Down Expand Up @@ -81,6 +80,8 @@
ai=GS1ApplicationIdentifier.extract("7011"),
value="030102",
pattern_groups=["030102"],
date=dt.date(2003, 1, 2),
datetime=None,
),
),
(
Expand All @@ -89,6 +90,8 @@
ai=GS1ApplicationIdentifier.extract("7011"),
value="0301021430",
pattern_groups=["030102", "1430"],
date=dt.date(2003, 1, 2),
datetime=dt.datetime(2003, 1, 2, 14, 30), # noqa: DTZ001
),
),
(
Expand Down Expand Up @@ -173,6 +176,39 @@ def test_extract_fails_when_not_matching_pattern(ai_code: str, bad_value: str) -
)


@pytest.mark.parametrize(
("value", "expected_date", "expected_datetime"),
[
("11030201", dt.date(2003, 2, 1), None),
("12030201", dt.date(2003, 2, 1), None),
("13030201", dt.date(2003, 2, 1), None),
("15030201", dt.date(2003, 2, 1), None),
("16030201", dt.date(2003, 2, 1), None),
("17030201", dt.date(2003, 2, 1), None),
("43240701029999", dt.date(2007, 1, 2), None),
("43240701021430", dt.date(2007, 1, 2), dt.datetime(2007, 1, 2, 14, 30)), # noqa: DTZ001
("43250701029999", dt.date(2007, 1, 2), None),
("43250701021430", dt.date(2007, 1, 2), dt.datetime(2007, 1, 2, 14, 30)), # noqa: DTZ001
("4326070102", dt.date(2007, 1, 2), None),
("70030701021415", dt.date(2007, 1, 2), dt.datetime(2007, 1, 2, 14, 15)), # noqa: DTZ001
("7006030102", dt.date(2003, 1, 2), None),
("7007030102", dt.date(2003, 1, 2), None),
("7011030102", dt.date(2003, 1, 2), None),
("70110301021430", dt.date(2003, 1, 2), dt.datetime(2003, 1, 2, 14, 30)), # noqa: DTZ001
("800800010214", dt.date(2000, 1, 2), dt.datetime(2000, 1, 2, 14, 0)), # noqa: DTZ001
("80080001021415", dt.date(2000, 1, 2), dt.datetime(2000, 1, 2, 14, 15)), # noqa: DTZ001
("8008000102141516", dt.date(2000, 1, 2), dt.datetime(2000, 1, 2, 14, 15, 16)), # noqa: DTZ001
],
)
def test_extract_date_and_datetime(
value: str, expected_date: dt.date, expected_datetime: Optional[dt.datetime]
) -> None:
element_string = GS1ElementString.extract(value)

assert element_string.date == expected_date
assert element_string.datetime == expected_datetime


@pytest.mark.parametrize(
("ai_code", "bad_value"),
[
Expand All @@ -189,11 +225,12 @@ def test_extract_fails_with_invalid_date(ai_code: str, bad_value: str) -> None:
GS1ElementString.extract(f"{ai_code}{bad_value}")

assert (
str(exc_info.value) == f"Failed to parse GS1 AI {ai} date from {bad_value!r}."
str(exc_info.value)
== f"Failed to parse GS1 AI {ai} date/time from {bad_value!r}."
)


THIS_YEAR = datetime.datetime.now(tz=datetime.timezone.utc).year
THIS_YEAR = dt.datetime.now(tz=dt.timezone.utc).year
THIS_YEAR_SHORT = str(THIS_YEAR)[2:]
MIN_YEAR = THIS_YEAR - 49
MIN_YEAR_SHORT = str(MIN_YEAR)[2:]
Expand All @@ -211,7 +248,7 @@ def test_extract_fails_with_invalid_date(ai_code: str, bad_value: str) -> None:
ai=GS1ApplicationIdentifier.extract("15"),
value=f"{THIS_YEAR_SHORT}0526",
pattern_groups=[f"{THIS_YEAR_SHORT}0526"],
date=date(THIS_YEAR, 5, 26),
date=dt.date(THIS_YEAR, 5, 26),
),
),
(
Expand All @@ -221,7 +258,7 @@ def test_extract_fails_with_invalid_date(ai_code: str, bad_value: str) -> None:
ai=GS1ApplicationIdentifier.extract("15"),
value=f"{MIN_YEAR_SHORT}0526",
pattern_groups=[f"{MIN_YEAR_SHORT}0526"],
date=date(MIN_YEAR, 5, 26),
date=dt.date(MIN_YEAR, 5, 26),
),
),
(
Expand All @@ -231,7 +268,7 @@ def test_extract_fails_with_invalid_date(ai_code: str, bad_value: str) -> None:
ai=GS1ApplicationIdentifier.extract("15"),
value=f"{MAX_YEAR_SHORT}0526",
pattern_groups=[f"{MAX_YEAR_SHORT}0526"],
date=date(MAX_YEAR, 5, 26),
date=dt.date(MAX_YEAR, 5, 26),
),
),
],
Expand All @@ -245,13 +282,13 @@ def test_extract_handles_min_and_max_year_correctly(
@pytest.mark.parametrize(
("value", "expected"),
[
("15200200", date(2020, 2, 29)),
("15210200", date(2021, 2, 28)),
("17211200", date(2021, 12, 31)),
("15200200", dt.date(2020, 2, 29)),
("15210200", dt.date(2021, 2, 28)),
("17211200", dt.date(2021, 12, 31)),
],
)
def test_extract_handles_zero_day_as_last_day_of_month(
value: str, expected: date
value: str, expected: dt.date
) -> None:
assert GS1ElementString.extract(value).date == expected

Expand Down
12 changes: 6 additions & 6 deletions tests/gs1/test_messages.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import date
import datetime as dt
from decimal import Decimal
from typing import Iterable, List, Optional

Expand Down Expand Up @@ -41,7 +41,7 @@
ai=GS1ApplicationIdentifier.extract("15"),
value="210526",
pattern_groups=["210526"],
date=date(2021, 5, 26),
date=dt.date(2021, 5, 26),
),
GS1ElementString(
ai=GS1ApplicationIdentifier.extract("10"),
Expand Down Expand Up @@ -140,7 +140,7 @@ def test_parse_with_too_long_separator_char_fails() -> None:
"Failed to get GS1 Application Identifier from 'aaa'.",
),
# Too short to match optional time group (as this is really a GTIN-13)
("701197206489", "Failed to get GS1 Application Identifier from '89'."),
("701103020185", "Failed to get GS1 Application Identifier from '85'."),
],
)
def test_parse_fails_if_unparsed_data_left(value: str, error: str) -> None:
Expand Down Expand Up @@ -184,7 +184,7 @@ def test_parse_strips_surrounding_whitespace() -> None:
ai=GS1ApplicationIdentifier.extract("17"),
value="221231",
pattern_groups=["221231"],
date=date(2022, 12, 31),
date=dt.date(2022, 12, 31),
)
],
),
Expand All @@ -203,7 +203,7 @@ def test_parse_strips_surrounding_whitespace() -> None:
ai=GS1ApplicationIdentifier.extract("17"),
value="221231",
pattern_groups=["221231"],
date=date(2022, 12, 31),
date=dt.date(2022, 12, 31),
),
],
),
Expand All @@ -217,7 +217,7 @@ def test_parse_strips_surrounding_whitespace() -> None:
ai=GS1ApplicationIdentifier.extract("17"),
value="221231",
pattern_groups=["221231"],
date=date(2022, 12, 31),
date=dt.date(2022, 12, 31),
),
GS1ElementString(
ai=GS1ApplicationIdentifier.extract("10"),
Expand Down
6 changes: 3 additions & 3 deletions tests/test_parse.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import date
import datetime as dt
from decimal import Decimal

import pytest
Expand Down Expand Up @@ -539,7 +539,7 @@
ai=GS1ApplicationIdentifier.extract("15"),
value="210527",
pattern_groups=["210527"],
date=date(2021, 5, 27),
date=dt.date(2021, 5, 27),
)
],
),
Expand Down Expand Up @@ -584,7 +584,7 @@
ai=GS1ApplicationIdentifier.extract("15"),
value="210526",
pattern_groups=["210526"],
date=date(2021, 5, 26),
date=dt.date(2021, 5, 26),
),
],
),
Expand Down

0 comments on commit 53a2743

Please sign in to comment.