From 5ff15ad141f076f85a90270068f0a54f8ce165b4 Mon Sep 17 00:00:00 2001 From: Danny Hajj Date: Wed, 6 Dec 2023 16:08:27 +0100 Subject: [PATCH] Follow the v3 specs --- personnummer/personnummer.py | 177 +++++++++++++----------- personnummer/tests/test_personnummer.py | 102 +++++++++----- pyproject.toml | 2 +- 3 files changed, 164 insertions(+), 117 deletions(-) diff --git a/personnummer/personnummer.py b/personnummer/personnummer.py index 308d0c8..35a7523 100644 --- a/personnummer/personnummer.py +++ b/personnummer/personnummer.py @@ -9,6 +9,14 @@ class PersonnummerException(Exception): pass +class PersonnummerInvalidException(PersonnummerException): + pass + + +class PersonnummerParseException(PersonnummerException): + pass + + class Personnummer: def __init__(self, ssn, options=None): """ @@ -24,11 +32,23 @@ def __init__(self, ssn, options=None): self.options = options self._ssn = ssn - self.parts = self.get_parts(ssn) + self._parse_parts(ssn) + self._validate() - if self.valid() is False: - raise PersonnummerException( - str(ssn) + ' Not a valid Swedish personal identity number!') + @property + def parts(self) -> dict: + return { + 'century': self.century, + 'year': self.year, + 'month': self.month, + 'day': self.day, + 'sep': self.sep, + 'num': self.num, + 'check': self.check, + } + + def is_coordination_number(self): + return int(self.day) > 60 def format(self, long_format=False): """ @@ -50,7 +70,27 @@ def format(self, long_format=False): else: ssn_format = '{year}{month}{day}{sep}{num}{check}' - return ssn_format.format(**self.parts) + return ssn_format.format( + century=self.century, + year=self.year, + month=self.month, + day=self.day, + sep=self.sep, + num=self.num, + check=self.check, + ) + + def get_date(self): + """ + Get the underlying date from a social security number + + :rtype: datetime.date + """ + year = int(self.full_year) + month = int(self.month) + day = int(self.day) + day = day - 60 if self.is_coordination_number() else day + return datetime.date(year, month, day) def get_age(self): """ @@ -59,16 +99,12 @@ def get_age(self): :rtype: int :return: """ - today = get_current_datetime() + today = _get_current_date() - year = int('{century}{year}'.format( - century=self.parts['century'], - year=self.parts['year']) - ) - month = int(self.parts['month']) - day = int(self.parts['day']) - if self.is_coordination_number(): - day -= 60 + year = int(self.full_year) + month = int(self.month) + day = int(self.day) + day = day - 60 if self.is_coordination_number() else day return today.year - year - ((today.month, today.day) < (month, day)) @@ -76,31 +112,21 @@ def is_female(self): return not self.is_male() def is_male(self): - gender_digit = self.parts['num'] + gender_digit = int(self.num) - return int(gender_digit) % 2 != 0 + return gender_digit % 2 != 0 - def is_coordination_number(self): - return test_date( - int(self.parts['century'] + self.parts['year']), - int(self.parts['month']), - int(self.parts['day']) - 60, - ) - - @staticmethod - def get_parts(ssn): + def _parse_parts(self, ssn): """ Get different parts of a Swedish personal identity number - :rtype: dict - :return: Returns a dictionary of the different parts of a Swedish SSN. - The dict keys are: - 'century', 'year', 'month', 'day', 'sep', 'num', 'check' + :param ssn + :type ssn str|int """ reg = r"^(\d{2}){0,1}(\d{2})(\d{2})(\d{2})([\-\+]{0,1})?((?!000)\d{3})(\d{0,1})$" match = re.match(reg, str(ssn)) if not match: - raise PersonnummerException( + raise PersonnummerParseException( 'Could not parse "{}" as a valid Swedish SSN.'.format(ssn)) century = match.group(1) @@ -112,7 +138,7 @@ def get_parts(ssn): check = match.group(7) if not century: - base_year = get_current_datetime().year + base_year = _get_current_date().year if sep == '+': base_year -= 100 else: @@ -120,45 +146,53 @@ def get_parts(ssn): full_year = base_year - ((base_year - int(year)) % 100) century = str(int(full_year / 100)) else: - sep = '-' if get_current_datetime().year - int(century + year) < 100 else '+' - return { - 'century': century, - 'year': year, - 'month': month, - 'day': day, - 'sep': sep, - 'num': num, - 'check': check - } - - def valid(self): + sep = '-' if _get_current_date().year - int(century + year) < 100 else '+' + + self.century = century + self.full_year = century + year + self.year = year + self.month = month + self.day = day + self.sep = sep + self.num = num + self.check = check + + def _validate(self): """ Validate a Swedish personal identity number - :rtype: bool - :return: """ + if len(self.check) == 0: + raise PersonnummerInvalidException - century = self.parts['century'] - year = self.parts['year'] - month = self.parts['month'] - day = self.parts['day'] - num = self.parts['num'] - check = self.parts['check'] - - if len(check) == 0: - return False + is_valid = _luhn(self.year + self.month + self.day + self.num) == int(self.check) + if not is_valid: + raise PersonnummerInvalidException - is_valid = luhn(year + month + day + num) == int(check) + try: + self.get_date() + except ValueError: + raise PersonnummerInvalidException - if is_valid and test_date(int(century + year), int(month), int(day)): - return True - - return is_valid and test_date(int(century + year), int(month), int(day) - 60) + @staticmethod + def parse(ssn, options=None): + """ + Returns a new Personnummer object + :param ssn + :type ssn str/int + :param options + :type options dict + :rtype: Personnummer + :return: + """ + return Personnummer(ssn, options) -def luhn(data): +def _luhn(data): """ Calculates the Luhn checksum of a string of digits + :param data + :type data str + :rtype: int :return: """ calculation = 0 @@ -180,12 +214,10 @@ def parse(ssn, options=None): :type ssn str/int :param options :type options dict - :rtype: object - :return: Personnummer object + :rtype: Personnummer + :return: """ - if options is None: - options = {} - return Personnummer(ssn, options) + return Personnummer.parse(ssn, options) def valid(ssn): @@ -201,7 +233,7 @@ def valid(ssn): return False -def get_current_datetime(): +def _get_current_date(): """ Get current time. The purpose of this function is to be able to mock current time during tests @@ -209,15 +241,4 @@ def get_current_datetime(): :return: :rtype datetime.datetime: """ - return datetime.datetime.now() - - -def test_date(year, month, day): - """ - Test if the input parameters are a valid date or not - """ - try: - date = datetime.date(year, month, day) - return date.year == year and date.month == month and date.day == day - except ValueError: - return False + return datetime.date.today() diff --git a/personnummer/tests/test_personnummer.py b/personnummer/tests/test_personnummer.py index edecac5..98fb10d 100644 --- a/personnummer/tests/test_personnummer.py +++ b/personnummer/tests/test_personnummer.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import date from unittest import TestCase from unittest import mock @@ -26,62 +26,88 @@ def get_test_data(): class TestPersonnummer(TestCase): def testPersonnummerList(self): for item in test_data: - for format in availableListFormats: + for available_format in availableListFormats: self.assertEqual(personnummer.valid( - item[format]), item['valid']) + item[available_format]), item['valid']) def testPersonnummerFormat(self): for item in test_data: if not item['valid']: - return - - for format in availableListFormats: - if format != 'short_format': - self.assertEqual(personnummer.parse( - item[format]).format(), item['separated_format']) - self.assertEqual(personnummer.parse( - item[format]).format(True), item['long_format']) + continue + + expected_long_format = item['long_format'] + expected_separated_format: str = item['separated_format'] + for available_format in availableListFormats: + if available_format == 'short_format' and '+' in expected_separated_format: + # Since the short format is missing the separator, + # the library will never use the `+` separator + # in the outputted format + continue + self.assertEqual( + expected_separated_format, + personnummer.parse(item[available_format]).format() + ) + self.assertEqual( + expected_long_format, + personnummer.parse(item[available_format]).format(True) + ) def testPersonnummerError(self): for item in test_data: if item['valid']: - return + continue - for format in availableListFormats: - try: - personnummer.parse(item[format]) - self.assertTrue(False) - except: - self.assertTrue(True) + for available_format in availableListFormats: + self.assertRaises( + personnummer.PersonnummerException, + personnummer.parse, + item[available_format], + ) def testPersonnummerSex(self): for item in test_data: if not item['valid']: - return + continue - for format in availableListFormats: + for available_format in availableListFormats: self.assertEqual(personnummer.parse( - item[format]).isMale(), item['isMale']) + item[available_format]).is_male(), item['isMale']) self.assertEqual(personnummer.parse( - item[format]).isFemale(), item['isFemale']) + item[available_format]).is_female(), item['isFemale']) def testPersonnummerAge(self): for item in test_data: if not item['valid']: - return - - for format in availableListFormats: - if format != 'short_format': - pin = item['separated_long'] - year = int(pin[0:4]) - month = int(pin[4:6]) - day = int(pin[6:8]) - - if item['type'] == 'con': - day -= 60 - - date = datetime(year=year, month=month, day=day) - p = personnummer.parse(item[format]) - - with mock.patch('personnummer.personnummer.get_current_datetime', mock.Mock(return_value=date)): + continue + + separated_format = item['separated_format'] + pin = item['separated_long'] + year = int(pin[0:4]) + month = int(pin[4:6]) + day = int(pin[6:8]) + + if item['type'] == 'con': + day -= 60 + if '+' in separated_format: + # This is needed in order for the age to be the same + # when testing the 'long_format' and any of the separated_* + # formats. Otherwise, the long format will have an age of 0 + # and the separated ones will have an age of 100. + year += 100 + + mocked_date = date(year=year, month=month, day=day) + for available_format in availableListFormats: + if available_format == 'short_format' and '+' in separated_format: + # Since the short format is missing the separator, + # the library will never use the `+` separator + # in the outputted format + continue + p = personnummer.parse(item[available_format]) + with mock.patch( + 'personnummer.personnummer._get_current_date', + mock.Mock(return_value=mocked_date) + ): + if '+' in separated_format: + self.assertEqual(100, p.get_age()) + else: self.assertEqual(0, p.get_age()) diff --git a/pyproject.toml b/pyproject.toml index ece43a3..ab4198e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=61.2", "wheel"] build-backend = "setuptools.build_meta" [project] -version = "3.1.0" +version = "3.2.0" name = "personnummer" description = "Validate Swedish personal identity numbers" license = { file = "./LICENSE" }