Skip to content

Commit

Permalink
Follow the v3 specs
Browse files Browse the repository at this point in the history
  • Loading branch information
dannyhajj committed Dec 8, 2023
1 parent 516c6a3 commit 5ff15ad
Show file tree
Hide file tree
Showing 3 changed files with 164 additions and 117 deletions.
177 changes: 99 additions & 78 deletions personnummer/personnummer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ class PersonnummerException(Exception):
pass


class PersonnummerInvalidException(PersonnummerException):
pass


class PersonnummerParseException(PersonnummerException):
pass


class Personnummer:
def __init__(self, ssn, options=None):
"""
Expand All @@ -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):
"""
Expand All @@ -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):
"""
Expand All @@ -59,48 +99,34 @@ 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))

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)
Expand All @@ -112,53 +138,61 @@ 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:
sep = '-'
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
Expand All @@ -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):
Expand All @@ -201,23 +233,12 @@ 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
: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()
102 changes: 64 additions & 38 deletions personnummer/tests/test_personnummer.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import date
from unittest import TestCase
from unittest import mock

Expand Down Expand Up @@ -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())
Loading

0 comments on commit 5ff15ad

Please sign in to comment.