I'm working on generating large volumes of test data for some performance testing. Part of this is going to involve generating a lot of values that represent an "MBI", which is a certain kind of alpha-numeric identifier.
This identifier has the following rules:
- Is alpha-numeric (uppercase only letters)
- Excludes letters that are commonly confused for numbers or other letters (
SLOIBZ
) - Is 11 digits long
- Has specific rules on which values can go in which digit
- The first digit can be a number 1-9
- The second, fifth, eighth, and ninth digits can be any upper case letter besides
SLOIBZ
; I don't actually validate the upper-case requirement, and I just callmbi.upper()
as needed. - The fourth, seventh, tenth, and eleventh digits can be any number 0-9
- The remaining digits (third and sixth) can be any of the valid letters or numbers (including 0)
The following function validates that an MBI is valid (you can assume this is correct for your own review)
Assuming that the numbers are "before" the letters, the "first" MBI is then "1A00A00AA00"
. My task is to generate a whole bunch of valid and unique MBIs by "incrementing" them. For example, I want to be able to do this:
for _, mbi in zip(range(count_mbis_to_generate), mbi_generator("1A00A00AA99")):
print(mbi)
This should print count_mbis_to_generate
distinct MBIs, starting with "1A00A00AA99"
(assuming there are that many distinct MBIs that are greater than or equal to "1A00A00AA99"
. If I wanted 10000 of them, then the last one should be "1A00A00GA98"
.
To accomplish this, I started by writing a function to validate that a given MBI matches the expected format:
import re
def validate_mbi(mbi):
flags = re.IGNORECASE ^ re.VERBOSE
pattern = re.compile(
r"""
[1-9] # No leading zeros
[ac-hjkmnp-rt-z] # No letters that can be confused for numbers or each other
[ac-hjkmnp-rt-z\d] # Letter or number
\d # Any number
[ac-hjkmnp-rt-z]
[ac-hjkmnp-rt-z\d]
\d
[ac-hjkmnp-rt-z]{2} # Then two letters
\d{2} # Then two numbers
""",
flags=flags,
)
return mbi and len(mbi) == 11 and pattern.match(mbi)
I then created a generator that will start spitting out MBIs:
def mbi_generator(start=None):
if not start:
start = "1A00A00AA00"
while validate_mbi(start):
yield start
start = increment_mbi(start)
Now I actually had to do some thinking. To do this arithmatic by hand, I came up with the following algorithm:
- Starting with the final digit (e.g.
mbi[10]
), attempt to increment it by 1 (following all normal rules) - If that results in a "carrying" operation, then set this digit to the minimum allowed value for this digit, and move on to the next digit
- Repeat until I get to the first digit (e.g.
mbi[0]
). - If the first digit is the largest allowed value as well (
9
), then we cannot increment this MBI, so just returnNone
To accomplish this, I created dictionaries that I'm calling "pseudo-linked lists" (they are nowhere close to a true linked list, but it was the name that popped in my head when I made it). They would look like this:
- If my current value is
'A'
, then the next value is'B'
and we don't have to carry. - If my current value is
'Z'
, then that is the largest possible value, so the "next" value is either0
or'A'
, depending on digit, and we do have to carry.
This would end up looking like this kind of dictionary (for the 0-9
digits):
{'0': ('1', False),
'1': ('2', False),
'2': ('3', False),
'3': ('4', False),
'4': ('5', False),
'5': ('6', False),
'6': ('7', False),
'7': ('8', False),
'8': ('9', False),
'9': ('0', True)}
The key is the current value, and then the value's first index is the "next" value, and the second index is the "carry" flag. Note that the values are strings, not numbers, because we're always operating on strings and its easier to store it this way than do conversions all over the place.
The function to spit out one of these dictionaries is like so:
def build_pseudo_linked_list(value_list):
end_point = len(value_list) - 1
return {
current_value: (value_list[0] # Get the first value
if i == end_point # If we're at the end of the list
else value_list[i + 1], # Otherwise get the next value
i == end_point) # Whether or not we were at the end of the list
for i, current_value in enumerate(value_list)
}
From there, I just need to build up the different kinds of dictionaries, and then use them to increment the MBI.
def increment_mbi(mbi):
# Get the list of possible values for each digit
string_list = [char for char in string.ascii_uppercase if char not in "SLOIBZ"] # exclude easily confused letters
digit_list = [str(i) for i in range(0, 10)] # any digit
partial_digit_list = [val for val in digit_list if val != "0"] # Any non-zero digit
combined_list = digit_list + string_list # any value from any list above
# The values a given digit can have as a pseudo-linked list
# current_value : (next_value, should_advance_digit)
first_digit_range = build_pseudo_linked_list(partial_digit_list)
letter_range = build_pseudo_linked_list(string_list)
full_digit_range = build_pseudo_linked_list(digit_list)
combined_range = build_pseudo_linked_list(combined_list)
# Each digit's ranges
ranges = [
first_digit_range,
letter_range,
combined_range,
full_digit_range,
letter_range,
combined_range,
full_digit_range,
letter_range,
letter_range,
full_digit_range,
full_digit_range,
]
index = 10 # Start from the back
mbi = mbi.upper() # If they gave us lower-case values, just roll with it
# Starting from the back, try to increment digits until we don't have to carry
for i in range(index, 0, -1):
next_value, should_advance = ranges[i][mbi[i]]
# If we get to the front and we need to reset, then we can't go any further
if i == 0 and should_advance:
return None
# Otherwise, slide our value in
mbi = mbi[:i] + next_value + mbi[i + 1 :]
# If we need to advance to the next digit then we continue, otherwise return our current state
if not should_advance:
return mbi
At this point, the logic was pretty straightforward, and mostly just requires building up our dictionaries and lists.
I'm looking for a review on a few things:
- Does this make sense? I need to add docstrings that explain all of this still, but hopefully the reasoning makes sense
- Would making these classes actually help at all? I played around with it, but it didn't really seem to be any clearer than before
- Are there any obvious ways to make this faster? This is working well enough to generate test data, which can be a "run it over the weekend on rare occasions" kind of thing, but I'd love for it to be faster.
Bonus points if you come up with a really clever itertools
implementation; I played with it for a while but it eventually got a little too obtuse for me
/dev/null
, slower if you need them to go to a file. \$\endgroup\$