diff --git a/python/README.md b/python/README.md
index 0f7c8778..74d350ab 100644
--- a/python/README.md
+++ b/python/README.md
@@ -44,3 +44,4 @@
- [roman-numerals](./roman-numerals/README.md)
- [scrabble-score](./scrabble-score/README.md)
- [difference-of-squares](./difference-of-squares/README.md)
+- [luhn](./luhn/README.md)
diff --git a/python/luhn/.coverage b/python/luhn/.coverage
new file mode 100644
index 00000000..bbbab86d
--- /dev/null
+++ b/python/luhn/.coverage
@@ -0,0 +1 @@
+!coverage.py: This is a private format, don't read it directly!{"arcs":{"/home/vpayno/git_vpayno/exercism-workspace/python/luhn/test/__init__.py":[[0,0],[0,-1]],"/home/vpayno/git_vpayno/exercism-workspace/python/luhn/luhn.py":[[0,1],[1,3],[3,4],[4,7],[7,7],[7,8],[8,10],[10,27],[27,-7],[7,55],[55,55],[55,56],[56,58],[58,61],[61,95],[95,137],[137,-55],[55,-1],[58,59],[59,-58],[61,87],[87,88],[88,89],[10,25],[25,-10],[89,93],[95,129],[27,37],[37,50],[50,52],[37,48],[48,-37],[52,52],[52,-27],[129,131],[137,175],[175,177],[177,178],[178,180],[180,182],[182,183],[183,185],[185,186],[186,188],[188,182],[182,191],[191,193],[193,195],[195,198],[198,-137],[131,133],[133,135],[135,-95],[93,-61],[195,196],[196,198],[87,91],[91,-61],[185,188],[89,91]]}}
\ No newline at end of file
diff --git a/python/luhn/.coverage.xml b/python/luhn/.coverage.xml
new file mode 100644
index 00000000..5e1c593b
--- /dev/null
+++ b/python/luhn/.coverage.xml
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/python/luhn/.coveragerc b/python/luhn/.coveragerc
new file mode 100644
index 00000000..883531db
--- /dev/null
+++ b/python/luhn/.coveragerc
@@ -0,0 +1,2 @@
+[run]
+omit = __init__.py, *_test.py
diff --git a/python/luhn/.exercism/config.json b/python/luhn/.exercism/config.json
new file mode 100644
index 00000000..c9a7c188
--- /dev/null
+++ b/python/luhn/.exercism/config.json
@@ -0,0 +1,39 @@
+{
+ "authors": [
+ "sjakobi"
+ ],
+ "contributors": [
+ "AnAccountForReportingBugs",
+ "behrtam",
+ "BethanyG",
+ "cmccandless",
+ "Dog",
+ "Grociu",
+ "ikhadykin",
+ "kytrinyx",
+ "mambocab",
+ "N-Parsons",
+ "Nishant23",
+ "olufotebig",
+ "pheanex",
+ "rootulp",
+ "thomasjpfan",
+ "tqa236",
+ "xitanggg",
+ "yawpitch"
+ ],
+ "files": {
+ "solution": [
+ "luhn.py"
+ ],
+ "test": [
+ "luhn_test.py"
+ ],
+ "example": [
+ ".meta/example.py"
+ ]
+ },
+ "blurb": "Given a number determine whether or not it is valid per the Luhn formula.",
+ "source": "The Luhn Algorithm on Wikipedia",
+ "source_url": "https://en.wikipedia.org/wiki/Luhn_algorithm"
+}
diff --git a/python/luhn/.exercism/metadata.json b/python/luhn/.exercism/metadata.json
new file mode 100644
index 00000000..79ee9904
--- /dev/null
+++ b/python/luhn/.exercism/metadata.json
@@ -0,0 +1 @@
+{"track":"python","exercise":"luhn","id":"fda87fb1d5324fef81e9bfc025d2666e","url":"https://exercism.org/tracks/python/exercises/luhn","handle":"vpayno","is_requester":true,"auto_approve":false}
\ No newline at end of file
diff --git a/python/luhn/.pylintrc b/python/luhn/.pylintrc
new file mode 120000
index 00000000..30b33b52
--- /dev/null
+++ b/python/luhn/.pylintrc
@@ -0,0 +1 @@
+../.pylintrc
\ No newline at end of file
diff --git a/python/luhn/HELP.md b/python/luhn/HELP.md
new file mode 100644
index 00000000..53cea3f1
--- /dev/null
+++ b/python/luhn/HELP.md
@@ -0,0 +1,130 @@
+# Help
+
+## Running the tests
+
+We use [pytest][pytest: Getting Started Guide] as our website test runner.
+You will need to install `pytest` on your development machine if you want to run tests for the Python track locally.
+You should also install the following `pytest` plugins:
+
+- [pytest-cache][pytest-cache]
+- [pytest-subtests][pytest-subtests]
+
+Extended information can be found in our website [Python testing guide][Python track tests page].
+
+
+### Running Tests
+
+To run the included tests, navigate to the folder where the exercise is stored using `cd` in your terminal (_replace `{exercise-folder-location}` below with your path_).
+Test files usually end in `_test.py`, and are the same tests that run on the website when a solution is uploaded.
+
+Linux/MacOS
+```bash
+$ cd {path/to/exercise-folder-location}
+```
+
+Windows
+```powershell
+PS C:\Users\foobar> cd {path\to\exercise-folder-location}
+```
+
+
+
+Next, run the `pytest` command in your terminal, replacing `{exercise_test.py}` with the name of the test file:
+
+Linux/MacOS
+```bash
+$ python3 -m pytest -o markers=task {exercise_test.py}
+==================== 7 passed in 0.08s ====================
+```
+
+Windows
+```powershell
+PS C:\Users\foobar> py -m pytest -o markers=task {exercise_test.py}
+==================== 7 passed in 0.08s ====================
+```
+
+
+### Common options
+- `-o` : override default `pytest.ini` (_you can use this to avoid marker warnings_)
+- `-v` : enable verbose output.
+- `-x` : stop running tests on first failure.
+- `--ff` : run failures from previous test before running other test cases.
+
+For additional options, use `python3 -m pytest -h` or `py -m pytest -h`.
+
+
+### Fixing warnings
+
+If you do not use `pytest -o markers=task` when invoking `pytest`, you might receive a `PytestUnknownMarkWarning` for tests that use our new syntax:
+
+```bash
+PytestUnknownMarkWarning: Unknown pytest.mark.task - is this a typo? You can register custom marks to avoid this warning - for details, see https://docs.pytest.org/en/stable/mark.html
+```
+
+To avoid typing `pytest -o markers=task` for every test you run, you can use a `pytest.ini` configuration file.
+We have made one that can be downloaded from the top level of the Python track directory: [pytest.ini][pytest.ini].
+
+You can also create your own `pytest.ini` file with the following content:
+
+```ini
+[pytest]
+markers =
+ task: A concept exercise task.
+```
+
+Placing the `pytest.ini` file in the _root_ or _working_ directory for your Python track exercises will register the marks and stop the warnings.
+More information on pytest marks can be found in the `pytest` documentation on [marking test functions][pytest: marking test functions with attributes] and the `pytest` documentation on [working with custom markers][pytest: working with custom markers].
+
+Information on customizing pytest configurations can be found in the `pytest` documentation on [configuration file formats][pytest: configuration file formats].
+
+
+### Extending your IDE or Code Editor
+
+Many IDEs and code editors have built-in support for using `pytest` and other code quality tools.
+Some community-sourced options can be found on our [Python track tools page][Python track tools page].
+
+[Pytest: Getting Started Guide]: https://docs.pytest.org/en/latest/getting-started.html
+[Python track tools page]: https://exercism.org/docs/tracks/python/tools
+[Python track tests page]: https://exercism.org/docs/tracks/python/tests
+[pytest-cache]:http://pythonhosted.org/pytest-cache/
+[pytest-subtests]:https://github.com/pytest-dev/pytest-subtests
+[pytest.ini]: https://github.com/exercism/python/blob/main/pytest.ini
+[pytest: configuration file formats]: https://docs.pytest.org/en/6.2.x/customize.html#configuration-file-formats
+[pytest: marking test functions with attributes]: https://docs.pytest.org/en/6.2.x/mark.html#raising-errors-on-unknown-marks
+[pytest: working with custom markers]: https://docs.pytest.org/en/6.2.x/example/markers.html#working-with-custom-markers
+
+## Submitting your solution
+
+You can submit your solution using the `exercism submit luhn.py` command.
+This command will upload your solution to the Exercism website and print the solution page's URL.
+
+It's possible to submit an incomplete solution which allows you to:
+
+- See how others have completed the exercise
+- Request help from a mentor
+
+## Need to get help?
+
+If you'd like help solving the exercise, check the following pages:
+
+- The [Python track's documentation](https://exercism.org/docs/tracks/python)
+- The [Python track's programming category on the forum](https://forum.exercism.org/c/programming/python)
+- [Exercism's programming category on the forum](https://forum.exercism.org/c/programming/5)
+- The [Frequently Asked Questions](https://exercism.org/docs/using/faqs)
+
+Should those resources not suffice, you could submit your (incomplete) solution to request mentoring.
+
+Below are some resources for getting help if you run into trouble:
+
+- [The PSF](https://www.python.org) hosts Python downloads, documentation, and community resources.
+- [The Exercism Community on Discord](https://exercism.org/r/discord)
+- [Python Community on Discord](https://pythondiscord.com/) is a very helpful and active community.
+- [/r/learnpython/](https://www.reddit.com/r/learnpython/) is a subreddit designed for Python learners.
+- [#python on Libera.chat](https://www.python.org/community/irc/) this is where the core developers for the language hang out and get work done.
+- [Python Community Forums](https://discuss.python.org/)
+- [Free Code Camp Community Forums](https://forum.freecodecamp.org/)
+- [CodeNewbie Community Help Tag](https://community.codenewbie.org/t/help)
+- [Pythontutor](http://pythontutor.com/) for stepping through small code snippets visually.
+
+Additionally, [StackOverflow](http://stackoverflow.com/questions/tagged/python) is a good spot to search for your problem/question to see if it has been answered already.
+ If not - you can always [ask](https://stackoverflow.com/help/how-to-ask) or [answer](https://stackoverflow.com/help/how-to-answer) someone else's question.
\ No newline at end of file
diff --git a/python/luhn/README.md b/python/luhn/README.md
new file mode 100644
index 00000000..df557c5b
--- /dev/null
+++ b/python/luhn/README.md
@@ -0,0 +1,105 @@
+# Luhn
+
+Welcome to Luhn on Exercism's Python Track.
+If you need help running the tests or submitting your code, check out `HELP.md`.
+
+## Instructions
+
+Given a number determine whether or not it is valid per the Luhn formula.
+
+The [Luhn algorithm][luhn] is a simple checksum formula used to validate a variety of identification numbers, such as credit card numbers and Canadian Social Insurance Numbers.
+
+The task is to check if a given string is valid.
+
+## Validating a Number
+
+Strings of length 1 or less are not valid.
+Spaces are allowed in the input, but they should be stripped before checking.
+All other non-digit characters are disallowed.
+
+### Example 1: valid credit card number
+
+```text
+4539 3195 0343 6467
+```
+
+The first step of the Luhn algorithm is to double every second digit, starting from the right.
+We will be doubling
+
+```text
+4_3_ 3_9_ 0_4_ 6_6_
+```
+
+If doubling the number results in a number greater than 9 then subtract 9 from the product.
+The results of our doubling:
+
+```text
+8569 6195 0383 3437
+```
+
+Then sum all of the digits:
+
+```text
+8+5+6+9+6+1+9+5+0+3+8+3+3+4+3+7 = 80
+```
+
+If the sum is evenly divisible by 10, then the number is valid.
+This number is valid!
+
+### Example 2: invalid credit card number
+
+```text
+8273 1232 7352 0569
+```
+
+Double the second digits, starting from the right
+
+```text
+7253 2262 5312 0539
+```
+
+Sum the digits
+
+```text
+7+2+5+3+2+2+6+2+5+3+1+2+0+5+3+9 = 57
+```
+
+57 is not evenly divisible by 10, so this number is not valid.
+
+[luhn]: https://en.wikipedia.org/wiki/Luhn_algorithm
+
+## Source
+
+### Created by
+
+- @sjakobi
+
+### Contributed to by
+
+- @AnAccountForReportingBugs
+- @behrtam
+- @BethanyG
+- @cmccandless
+- @Dog
+- @Grociu
+- @ikhadykin
+- @kytrinyx
+- @mambocab
+- @N-Parsons
+- @Nishant23
+- @olufotebig
+- @pheanex
+- @rootulp
+- @thomasjpfan
+- @tqa236
+- @xitanggg
+- @yawpitch
+
+### Based on
+
+The Luhn Algorithm on Wikipedia - https://en.wikipedia.org/wiki/Luhn_algorithm
+
+### My Solution
+
+- [my solution](./luhn.py)
+- [run-tests](./run-tests-python.txt)
diff --git a/python/luhn/luhn.py b/python/luhn/luhn.py
new file mode 100644
index 00000000..185e9f6d
--- /dev/null
+++ b/python/luhn/luhn.py
@@ -0,0 +1,198 @@
+"""Python Luhn Exercism"""
+
+import re
+from itertools import chain
+
+
+class String(str):
+ """Extending String class/type"""
+
+ def match(self, regex: str) -> bool:
+ """Does the String match a regex?
+
+ >>> s: String = String("1234 5678")
+ >>> s
+ '1234 5678'
+ >>> s.match(r"^[0-9 ]+$")
+ True
+ >>> s.match(r"^[a-z ]+$")
+ False
+
+ :param regex: rstr
+ :return: bool
+ """
+
+ return bool(re.match(regex, self))
+
+ def extract_digits(self) -> list[int]:
+ """Extracts digits from a code.
+
+ >>> s: String = String("1234 5678")
+ >>> s.extract_digits()
+ [1, 2, 3, 4, 5, 6, 7, 8]
+
+ :return: list[int]
+ """
+
+ def is_digit(rune: str) -> bool:
+ """Test function for filter.
+
+ >>> is_digit("a")
+ False
+ >>> is_digit("7")
+ True
+
+ :return: True if rune is a digit
+ """
+
+ return rune.isdigit()
+
+ filtered = filter(is_digit, list(self))
+
+ return [int(r) for r in filtered]
+
+
+class Luhn: # pylint: disable=too-few-public-methods
+ """Determine if a number is a valid Luhn number."""
+
+ def __init__(self, card_num: str) -> None:
+ self.code: String = String(card_num.strip())
+
+ def valid(self) -> bool:
+ """Is this a valid Luhn number?
+
+ >>> l: Luhn = Luhn("055 444 285")
+ >>> l.valid()
+ True
+ >>> l: Luhn = Luhn("055 444 286")
+ >>> l.valid()
+ False
+ >>> l: Luhn = Luhn("234 567 891 234")
+ >>> l.valid()
+ True
+ >>> n: String = String("234 567 891 234")
+ >>> n == "0"
+ False
+ >>> len(n) == 0
+ False
+ >>> n == "0"
+ False
+ >>> n.match(r"^([0-9 ])+$")
+ True
+
+ :return: bool
+ """
+
+ if (
+ self.code == "0"
+ or len(self.code) == 0
+ or not self.code.match(r"^([0-9 ])+$")
+ ):
+ return False
+
+ return self.__is_luhn_number()
+
+ def __is_luhn_number(self) -> bool:
+ """Is the code a valid luhn number?
+
+ >>> l: Luhn = Luhn("055 444 285")
+ >>> l._Luhn__is_luhn_number()
+ True
+ >>> l: Luhn = Luhn("055 444 286")
+ >>> l._Luhn__is_luhn_number()
+ False
+ >>> digits: list[int] = l.code.extract_digits()
+ >>> digits
+ [0, 5, 5, 4, 4, 4, 2, 8, 6]
+ >>> numbers: list[int] = l._Luhn__step_one_and_two(digits)
+ >>> numbers
+ [1, 5, 8, 4, 8, 2, 7, 6, 0]
+ >>> digit_sum: int = sum(numbers)
+ >>> digit_sum
+ 41
+ >>> l: Luhn = Luhn("234 567 891 234")
+ >>> l._Luhn__is_luhn_number()
+ True
+ >>> digits: list[int] = l.code.extract_digits()
+ >>> digits
+ [2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4]
+ >>> numbers: list[int] = l._Luhn__step_one_and_two(digits)
+ >>> numbers
+ [4, 3, 8, 5, 3, 7, 7, 9, 2, 2, 6, 4]
+ >>> digit_sum: int = sum(numbers)
+ >>> digit_sum
+ 60
+
+ :return: bool
+ """
+
+ digits: list[int] = self.code.extract_digits()
+
+ numbers: list[int] = self.__step_one_and_two(digits)
+
+ digit_sum: int = sum(numbers)
+
+ return (digit_sum % 10) == 0
+
+ def __step_one_and_two(self, digits: list[int]) -> list[int]:
+ """Performs steps one and two of the luhn number verification algorithm.
+
+ >>> l: Luhn = Luhn("0123456789")
+ >>> d: list[int] = l.code.extract_digits()
+ >>> d
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
+ >>> l._Luhn__step_one_and_two(d)
+ [0, 1, 4, 3, 8, 5, 3, 7, 7, 9]
+ >>> l: Luhn = Luhn("59")
+ >>> d: list[int] = l.code.extract_digits()
+ >>> d
+ [5, 9]
+ >>> s = l._Luhn__step_one_and_two(d)
+ >>> s
+ [1, 9]
+ >>> sum(s)
+ 10
+ >>> l: Luhn = Luhn(" 0")
+ >>> d: list[int] = l.code.extract_digits()
+ >>> d
+ [0]
+ >>> l._Luhn__step_one_and_two(d)
+ [0]
+ >>> sum(l._Luhn__step_one_and_two(d))
+ 0
+ >>> l: Luhn = Luhn("234 567 891 234")
+ >>> d: list[int] = l.code.extract_digits()
+ >>> d
+ [2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4]
+ >>> l._Luhn__step_one_and_two(d)
+ [4, 3, 8, 5, 3, 7, 7, 9, 2, 2, 6, 4]
+ >>> sum(l._Luhn__step_one_and_two(d))
+ 57
+
+ :return: list[int]
+ """
+
+ digits.reverse()
+
+ even: list[int] = digits[0::2]
+ odd: list[int] = digits[1::2]
+
+ new_odd: list[int] = []
+
+ for n in odd:
+ n *= 2
+
+ if n > 9:
+ n -= 9
+
+ new_odd.append(n)
+
+ # flatten the list of tuples
+ new_list: list[int] = list(chain.from_iterable(zip(even, new_odd)))
+
+ new_list.reverse()
+
+ if len(even) != len(odd):
+ new_list.append(even[-1])
+
+ return new_list
diff --git a/python/luhn/luhn.py,cover b/python/luhn/luhn.py,cover
new file mode 100644
index 00000000..dda3ede6
--- /dev/null
+++ b/python/luhn/luhn.py,cover
@@ -0,0 +1,198 @@
+> """Python Luhn Exercism"""
+
+> import re
+> from itertools import chain
+
+
+> class String(str):
+> """Extending String class/type"""
+
+> def match(self, regex: str) -> bool:
+> """Does the String match a regex?
+
+> >>> s: String = String("1234 5678")
+> >>> s
+> '1234 5678'
+> >>> s.match(r"^[0-9 ]+$")
+> True
+> >>> s.match(r"^[a-z ]+$")
+> False
+
+> :param regex: rstr
+> :return: bool
+> """
+
+> return bool(re.match(regex, self))
+
+> def extract_digits(self) -> list[int]:
+> """Extracts digits from a code.
+
+> >>> s: String = String("1234 5678")
+> >>> s.extract_digits()
+> [1, 2, 3, 4, 5, 6, 7, 8]
+
+> :return: list[int]
+> """
+
+> def is_digit(rune: str) -> bool:
+> """Test function for filter.
+
+> >>> is_digit("a")
+> False
+> >>> is_digit("7")
+> True
+
+> :return: True if rune is a digit
+> """
+
+> return rune.isdigit()
+
+> filtered = filter(is_digit, list(self))
+
+> return [int(r) for r in filtered]
+
+
+> class Luhn: # pylint: disable=too-few-public-methods
+> """Determine if a number is a valid Luhn number."""
+
+> def __init__(self, card_num: str) -> None:
+> self.code: String = String(card_num.strip())
+
+> def valid(self) -> bool:
+> """Is this a valid Luhn number?
+
+> >>> l: Luhn = Luhn("055 444 285")
+> >>> l.valid()
+> True
+> >>> l: Luhn = Luhn("055 444 286")
+> >>> l.valid()
+> False
+> >>> l: Luhn = Luhn("234 567 891 234")
+> >>> l.valid()
+> True
+> >>> n: String = String("234 567 891 234")
+> >>> n == "0"
+> False
+> >>> len(n) == 0
+> False
+> >>> n == "0"
+> False
+> >>> n.match(r"^([0-9 ])+$")
+> True
+
+> :return: bool
+> """
+
+> if (
+> self.code == "0"
+> or len(self.code) == 0
+> or not self.code.match(r"^([0-9 ])+$")
+> ):
+> return False
+
+> return self.__is_luhn_number()
+
+> def __is_luhn_number(self) -> bool:
+> """Is the code a valid luhn number?
+
+> >>> l: Luhn = Luhn("055 444 285")
+> >>> l._Luhn__is_luhn_number()
+> True
+> >>> l: Luhn = Luhn("055 444 286")
+> >>> l._Luhn__is_luhn_number()
+> False
+> >>> digits: list[int] = l.code.extract_digits()
+> >>> digits
+> [0, 5, 5, 4, 4, 4, 2, 8, 6]
+> >>> numbers: list[int] = l._Luhn__step_one_and_two(digits)
+> >>> numbers
+> [1, 5, 8, 4, 8, 2, 7, 6, 0]
+> >>> digit_sum: int = sum(numbers)
+> >>> digit_sum
+> 41
+> >>> l: Luhn = Luhn("234 567 891 234")
+> >>> l._Luhn__is_luhn_number()
+> True
+> >>> digits: list[int] = l.code.extract_digits()
+> >>> digits
+> [2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4]
+> >>> numbers: list[int] = l._Luhn__step_one_and_two(digits)
+> >>> numbers
+> [4, 3, 8, 5, 3, 7, 7, 9, 2, 2, 6, 4]
+> >>> digit_sum: int = sum(numbers)
+> >>> digit_sum
+> 60
+
+> :return: bool
+> """
+
+> digits: list[int] = self.code.extract_digits()
+
+> numbers: list[int] = self.__step_one_and_two(digits)
+
+> digit_sum: int = sum(numbers)
+
+> return (digit_sum % 10) == 0
+
+> def __step_one_and_two(self, digits: list[int]) -> list[int]:
+> """Performs steps one and two of the luhn number verification algorithm.
+
+> >>> l: Luhn = Luhn("0123456789")
+> >>> d: list[int] = l.code.extract_digits()
+> >>> d
+> [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
+> >>> l._Luhn__step_one_and_two(d)
+> [0, 1, 4, 3, 8, 5, 3, 7, 7, 9]
+> >>> l: Luhn = Luhn("59")
+> >>> d: list[int] = l.code.extract_digits()
+> >>> d
+> [5, 9]
+> >>> s = l._Luhn__step_one_and_two(d)
+> >>> s
+> [1, 9]
+> >>> sum(s)
+> 10
+> >>> l: Luhn = Luhn(" 0")
+> >>> d: list[int] = l.code.extract_digits()
+> >>> d
+> [0]
+> >>> l._Luhn__step_one_and_two(d)
+> [0]
+> >>> sum(l._Luhn__step_one_and_two(d))
+> 0
+> >>> l: Luhn = Luhn("234 567 891 234")
+> >>> d: list[int] = l.code.extract_digits()
+> >>> d
+> [2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4]
+> >>> l._Luhn__step_one_and_two(d)
+> [4, 3, 8, 5, 3, 7, 7, 9, 2, 2, 6, 4]
+> >>> sum(l._Luhn__step_one_and_two(d))
+> 57
+
+> :return: list[int]
+> """
+
+> digits.reverse()
+
+> even: list[int] = digits[0::2]
+> odd: list[int] = digits[1::2]
+
+> new_odd: list[int] = []
+
+> for n in odd:
+> n *= 2
+
+> if n > 9:
+> n -= 9
+
+> new_odd.append(n)
+
+ # flatten the list of tuples
+> new_list: list[int] = list(chain.from_iterable(zip(even, new_odd)))
+
+> new_list.reverse()
+
+> if len(even) != len(odd):
+> new_list.append(even[-1])
+
+> return new_list
diff --git a/python/luhn/luhn_test.py b/python/luhn/luhn_test.py
new file mode 100644
index 00000000..58234eb7
--- /dev/null
+++ b/python/luhn/luhn_test.py
@@ -0,0 +1,89 @@
+# These tests are auto-generated with test data from:
+# https://github.com/exercism/problem-specifications/tree/main/exercises/luhn/canonical-data.json
+# File last updated on 2023-07-19
+
+import unittest
+
+from luhn import (
+ Luhn,
+)
+
+
+class LuhnTest(unittest.TestCase):
+ def test_single_digit_strings_can_not_be_valid(self):
+ self.assertIs(Luhn("1").valid(), False)
+
+ def test_a_single_zero_is_invalid(self):
+ self.assertIs(Luhn("0").valid(), False)
+
+ def test_a_simple_valid_sin_that_remains_valid_if_reversed(self):
+ self.assertIs(Luhn("059").valid(), True)
+
+ def test_a_simple_valid_sin_that_becomes_invalid_if_reversed(self):
+ self.assertIs(Luhn("59").valid(), True)
+
+ def test_a_valid_canadian_sin(self):
+ self.assertIs(Luhn("055 444 285").valid(), True)
+
+ def test_invalid_canadian_sin(self):
+ self.assertIs(Luhn("055 444 286").valid(), False)
+
+ def test_invalid_credit_card(self):
+ self.assertIs(Luhn("8273 1232 7352 0569").valid(), False)
+
+ def test_invalid_long_number_with_an_even_remainder(self):
+ self.assertIs(Luhn("1 2345 6789 1234 5678 9012").valid(), False)
+
+ def test_invalid_long_number_with_a_remainder_divisible_by_5(self):
+ self.assertIs(Luhn("1 2345 6789 1234 5678 9013").valid(), False)
+
+ def test_valid_number_with_an_even_number_of_digits(self):
+ self.assertIs(Luhn("095 245 88").valid(), True)
+
+ def test_valid_number_with_an_odd_number_of_spaces(self):
+ self.assertIs(Luhn("234 567 891 234").valid(), True)
+
+ def test_valid_strings_with_a_non_digit_added_at_the_end_become_invalid(self):
+ self.assertIs(Luhn("059a").valid(), False)
+
+ def test_valid_strings_with_punctuation_included_become_invalid(self):
+ self.assertIs(Luhn("055-444-285").valid(), False)
+
+ def test_valid_strings_with_symbols_included_become_invalid(self):
+ self.assertIs(Luhn("055# 444$ 285").valid(), False)
+
+ def test_single_zero_with_space_is_invalid(self):
+ self.assertIs(Luhn(" 0").valid(), False)
+
+ def test_more_than_a_single_zero_is_valid(self):
+ self.assertIs(Luhn("0000 0").valid(), True)
+
+ def test_input_digit_9_is_correctly_converted_to_output_digit_9(self):
+ self.assertIs(Luhn("091").valid(), True)
+
+ def test_very_long_input_is_valid(self):
+ self.assertIs(Luhn("9999999999 9999999999 9999999999 9999999999").valid(), True)
+
+ def test_valid_luhn_with_an_odd_number_of_digits_and_non_zero_first_digit(self):
+ self.assertIs(Luhn("109").valid(), True)
+
+ def test_using_ascii_value_for_non_doubled_non_digit_isn_t_allowed(self):
+ self.assertIs(Luhn("055b 444 285").valid(), False)
+
+ def test_using_ascii_value_for_doubled_non_digit_isn_t_allowed(self):
+ self.assertIs(Luhn(":9").valid(), False)
+
+ def test_non_numeric_non_space_char_in_the_middle_with_a_sum_that_s_divisible_by_10_isn_t_allowed(
+ self,
+ ):
+ self.assertIs(Luhn("59%59").valid(), False)
+
+ # Additional tests for this track
+
+ def test_is_valid_can_be_called_repeatedly(self):
+ # This test was added, because we saw many implementations
+ # in which the first call to valid() worked, but the
+ # second call failed().
+ number = Luhn("055 444 285")
+ self.assertIs(number.valid(), True)
+ self.assertIs(number.valid(), True)
diff --git a/python/luhn/pytest.ini b/python/luhn/pytest.ini
new file mode 100644
index 00000000..8dc12a49
--- /dev/null
+++ b/python/luhn/pytest.ini
@@ -0,0 +1,5 @@
+[pytest]
+pythonpath = .
+addopts = --doctest-modules
+markers =
+ task: exercise task/step
diff --git a/python/luhn/run-tests-python.txt b/python/luhn/run-tests-python.txt
new file mode 100644
index 00000000..d024b267
--- /dev/null
+++ b/python/luhn/run-tests-python.txt
@@ -0,0 +1,764 @@
+Running automated test file(s):
+
+
+===============================================================================
+
+Running: ../../.github/citools/python/python-lint-pylint
+
+Running Python Lint - PyLint
+
+Python versions:
+
+ Python 3.12.1
+ pyenv 2.3.36-21-g7e550e31
+ pip 24.0 from /home/vpayno/.pyenv/versions/3.12.1/lib/python3.12/site-packages/pip (python 3.12)
+ PDM, version 2.13.2
+
+
+ ==============================================================================
+
+Running: pylint --version
+
+pylint 3.0.3
+astroid 3.0.2
+Python 3.12.1 (main, Dec 28 2023, 08:22:05) [GCC 10.2.1 20210110]
+
+real 0m0.231s
+user 0m0.162s
+sys 0m0.071s
+
+
+ ==============================================================================
+
+Running: pylint ./src
+
+
+--------------------------------------------------------------------
+Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00)
+
+
+real 0m0.624s
+user 0m0.547s
+sys 0m0.080s
+
+
+ ==============================================================================
+
+Exit code: 0
+
+real 0m1.805s
+user 0m1.361s
+sys 0m0.469s
+
+real 0m1.809s
+user 0m1.364s
+sys 0m0.470s
+
+===============================================================================
+
+Running: ../../.github/citools/python/python-lint-ruff
+
+Running Python Lint - Ruff
+
+Python versions:
+
+ Python 3.12.1
+ pyenv 2.3.36-21-g7e550e31
+ pip 24.0 from /home/vpayno/.pyenv/versions/3.12.1/lib/python3.12/site-packages/pip (python 3.12)
+ PDM, version 2.13.2
+
+
+ ==============================================================================
+
+Running: ruff --version
+
+ruff 0.3.5
+
+real 0m0.083s
+user 0m0.039s
+sys 0m0.048s
+
+
+ ==============================================================================
+
+Running: ruff check --ignore E501 ./src
+
+All checks passed!
+
+real 0m0.114s
+user 0m0.045s
+sys 0m0.073s
+
+
+ ==============================================================================
+
+Exit code: 0
+
+real 0m1.107s
+user 0m0.738s
+sys 0m0.392s
+
+real 0m1.109s
+user 0m0.738s
+sys 0m0.395s
+
+===============================================================================
+
+Running: ../../.github/citools/python/python-lint-pyright
+
+Running Python Lint - PyRight
+
+Python versions:
+
+ Python 3.12.1
+ pyenv 2.3.36-21-g7e550e31
+ pip 24.0 from /home/vpayno/.pyenv/versions/3.12.1/lib/python3.12/site-packages/pip (python 3.12)
+ PDM, version 2.13.2
+
+
+ ==============================================================================
+
+Running: pyright --version
+
+pyright 1.1.359
+
+real 0m0.880s
+user 0m0.556s
+sys 0m0.113s
+
+
+ ==============================================================================
+
+Running: pyright --stats ./src
+
+Found 2 source files
+pyright 1.1.359
+0 errors, 0 warnings, 0 informations
+Completed in 0.729sec
+
+Analysis stats
+Total files parsed and bound: 23
+Total files checked: 2
+
+Timing stats
+Find Source Files: 0sec
+Read Source Files: 0.01sec
+Tokenize: 0.05sec
+Parse: 0.07sec
+Resolve Imports: 0.06sec
+Bind: 0.08sec
+Check: 0.13sec
+Detect Cycles: 0sec
+
+real 0m1.417s
+user 0m1.709s
+sys 0m0.198s
+
+
+ ==============================================================================
+
+Exit code: 0
+
+real 0m3.213s
+user 0m2.902s
+sys 0m0.608s
+
+real 0m3.216s
+user 0m2.903s
+sys 0m0.610s
+
+===============================================================================
+
+Running: ../../.github/citools/python/python-lint-bandit
+
+Running Python Lint - Bandit
+
+Python versions:
+
+ Python 3.12.1
+ pyenv 2.3.36-21-g7e550e31
+ pip 24.0 from /home/vpayno/.pyenv/versions/3.12.1/lib/python3.12/site-packages/pip (python 3.12)
+ PDM, version 2.13.2
+
+
+ ==============================================================================
+
+Running: bandit --version
+
+bandit 1.7.7
+ python version = 3.12.1 (main, Dec 28 2023, 08:22:05) [GCC 10.2.1 20210110]
+
+real 0m0.284s
+user 0m0.211s
+sys 0m0.076s
+
+
+ ==============================================================================
+
+Running: bandit --verbose --recursive ./src
+
+[main] INFO profile include tests: None
+[main] INFO profile exclude tests: None
+[main] INFO cli include tests: None
+[main] INFO cli exclude tests: None
+[main] INFO running on Python 3.12.1
+Run started:2024-04-18 04:23:49.345884
+Files in scope (2):
+ ./src/luhn/__init__.py (score: {SEVERITY: 0, CONFIDENCE: 0})
+ ./src/luhn/luhn.py (score: {SEVERITY: 0, CONFIDENCE: 0})
+Files excluded (2):
+ ./src/luhn/__pycache__/__init__.cpython-312.pyc
+ ./src/luhn/__pycache__/luhn.cpython-312.pyc
+
+Test results:
+ No issues identified.
+
+Code scanned:
+ Total lines of code: 153
+ Total lines skipped (#nosec): 0
+ Total potential issues skipped due to specifically being disabled (e.g., #nosec BXXX): 0
+
+Run metrics:
+ Total issues (by severity):
+ Undefined: 0
+ Low: 0
+ Medium: 0
+ High: 0
+ Total issues (by confidence):
+ Undefined: 0
+ Low: 0
+ Medium: 0
+ High: 0
+Files skipped (0):
+
+real 0m0.248s
+user 0m0.173s
+sys 0m0.078s
+
+
+ ==============================================================================
+
+Exit code: 0
+
+real 0m1.449s
+user 0m1.042s
+sys 0m0.430s
+
+real 0m1.451s
+user 0m1.044s
+sys 0m0.430s
+
+===============================================================================
+
+Running: ../../.github/citools/python/python-lint-refurb
+
+Running Python Lint - Refurb
+
+Python versions:
+
+ Python 3.12.1
+ pyenv 2.3.36-21-g7e550e31
+ pip 24.0 from /home/vpayno/.pyenv/versions/3.12.1/lib/python3.12/site-packages/pip (python 3.12)
+ PDM, version 2.13.2
+
+
+ ==============================================================================
+
+Running: refurb --version
+
+Refurb: v1.26.0
+Mypy: v1.9.0
+
+real 0m0.195s
+user 0m0.125s
+sys 0m0.073s
+
+
+ ==============================================================================
+
+Running: refurb --quiet --ignore 183 ./src
+
+
+real 0m1.265s
+user 0m1.164s
+sys 0m0.104s
+
+
+ ==============================================================================
+
+Exit code: 0
+
+real 0m3.620s
+user 0m3.114s
+sys 0m0.532s
+
+real 0m3.623s
+user 0m3.115s
+sys 0m0.533s
+
+===============================================================================
+
+Running: ../../.github/citools/python/python-test-with-doctests
+
+Running Python DocTests
+
+Python versions:
+
+ Python 3.12.1
+ pyenv 2.3.36-21-g7e550e31
+ pip 24.0 from /home/vpayno/.pyenv/versions/3.12.1/lib/python3.12/site-packages/pip (python 3.12)
+ PDM, version 2.13.2
+
+
+ ==============================================================================
+
+PYTHONPATH="./src"
+
+ ==============================================================================
+
+Running: python -m doctest -v ./src/luhn/__init__.py ./src/luhn/luhn.py
+
+1 items had no tests:
+ __init__
+0 tests in 1 items.
+0 passed and 0 failed.
+Test passed.
+Trying:
+ l: Luhn = Luhn("055 444 285")
+Expecting nothing
+ok
+Trying:
+ l._Luhn__is_luhn_number()
+Expecting:
+ True
+ok
+Trying:
+ l: Luhn = Luhn("055 444 286")
+Expecting nothing
+ok
+Trying:
+ l._Luhn__is_luhn_number()
+Expecting:
+ False
+ok
+Trying:
+ digits: list[int] = l.code.extract_digits()
+Expecting nothing
+ok
+Trying:
+ digits
+Expecting:
+ [0, 5, 5, 4, 4, 4, 2, 8, 6]
+ok
+Trying:
+ numbers: list[int] = l._Luhn__step_one_and_two(digits)
+Expecting nothing
+ok
+Trying:
+ numbers
+Expecting:
+ [1, 5, 8, 4, 8, 2, 7, 6, 0]
+ok
+Trying:
+ digit_sum: int = sum(numbers)
+Expecting nothing
+ok
+Trying:
+ digit_sum
+Expecting:
+ 41
+ok
+Trying:
+ l: Luhn = Luhn("234 567 891 234")
+Expecting nothing
+ok
+Trying:
+ l._Luhn__is_luhn_number()
+Expecting:
+ True
+ok
+Trying:
+ digits: list[int] = l.code.extract_digits()
+Expecting nothing
+ok
+Trying:
+ digits
+Expecting:
+ [2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4]
+ok
+Trying:
+ numbers: list[int] = l._Luhn__step_one_and_two(digits)
+Expecting nothing
+ok
+Trying:
+ numbers
+Expecting:
+ [4, 3, 8, 5, 3, 7, 7, 9, 2, 2, 6, 4]
+ok
+Trying:
+ digit_sum: int = sum(numbers)
+Expecting nothing
+ok
+Trying:
+ digit_sum
+Expecting:
+ 60
+ok
+Trying:
+ l: Luhn = Luhn("0123456789")
+Expecting nothing
+ok
+Trying:
+ d: list[int] = l.code.extract_digits()
+Expecting nothing
+ok
+Trying:
+ d
+Expecting:
+ [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
+ok
+Trying:
+ l._Luhn__step_one_and_two(d)
+Expecting:
+ [0, 1, 4, 3, 8, 5, 3, 7, 7, 9]
+ok
+Trying:
+ l: Luhn = Luhn("59")
+Expecting nothing
+ok
+Trying:
+ d: list[int] = l.code.extract_digits()
+Expecting nothing
+ok
+Trying:
+ d
+Expecting:
+ [5, 9]
+ok
+Trying:
+ s = l._Luhn__step_one_and_two(d)
+Expecting nothing
+ok
+Trying:
+ s
+Expecting:
+ [1, 9]
+ok
+Trying:
+ sum(s)
+Expecting:
+ 10
+ok
+Trying:
+ l: Luhn = Luhn(" 0")
+Expecting nothing
+ok
+Trying:
+ d: list[int] = l.code.extract_digits()
+Expecting nothing
+ok
+Trying:
+ d
+Expecting:
+ [0]
+ok
+Trying:
+ l._Luhn__step_one_and_two(d)
+Expecting:
+ [0]
+ok
+Trying:
+ sum(l._Luhn__step_one_and_two(d))
+Expecting:
+ 0
+ok
+Trying:
+ l: Luhn = Luhn("234 567 891 234")
+Expecting nothing
+ok
+Trying:
+ d: list[int] = l.code.extract_digits()
+Expecting nothing
+ok
+Trying:
+ d
+Expecting:
+ [2, 3, 4, 5, 6, 7, 8, 9, 1, 2, 3, 4]
+ok
+Trying:
+ l._Luhn__step_one_and_two(d)
+Expecting:
+ [4, 3, 8, 5, 3, 7, 7, 9, 2, 2, 6, 4]
+ok
+Trying:
+ sum(l._Luhn__step_one_and_two(d))
+Expecting:
+ 57
+ok
+Trying:
+ l: Luhn = Luhn("055 444 285")
+Expecting nothing
+ok
+Trying:
+ l.valid()
+Expecting:
+ True
+ok
+Trying:
+ l: Luhn = Luhn("055 444 286")
+Expecting nothing
+ok
+Trying:
+ l.valid()
+Expecting:
+ False
+ok
+Trying:
+ l: Luhn = Luhn("234 567 891 234")
+Expecting nothing
+ok
+Trying:
+ l.valid()
+Expecting:
+ True
+ok
+Trying:
+ n: String = String("234 567 891 234")
+Expecting nothing
+ok
+Trying:
+ n == "0"
+Expecting:
+ False
+ok
+Trying:
+ len(n) == 0
+Expecting:
+ False
+ok
+Trying:
+ n == "0"
+Expecting:
+ False
+ok
+Trying:
+ n.match(r"^([0-9 ])+$")
+Expecting:
+ True
+ok
+Trying:
+ s: String = String("1234 5678")
+Expecting nothing
+ok
+Trying:
+ s.extract_digits()
+Expecting:
+ [1, 2, 3, 4, 5, 6, 7, 8]
+ok
+Trying:
+ s: String = String("1234 5678")
+Expecting nothing
+ok
+Trying:
+ s
+Expecting:
+ '1234 5678'
+ok
+Trying:
+ s.match(r"^[0-9 ]+$")
+Expecting:
+ True
+ok
+Trying:
+ s.match(r"^[a-z ]+$")
+Expecting:
+ False
+ok
+4 items had no tests:
+ luhn
+ luhn.Luhn
+ luhn.Luhn.__init__
+ luhn.String
+5 items passed all tests:
+ 18 tests in luhn.Luhn._Luhn__is_luhn_number
+ 20 tests in luhn.Luhn._Luhn__step_one_and_two
+ 11 tests in luhn.Luhn.valid
+ 2 tests in luhn.String.extract_digits
+ 4 tests in luhn.String.match
+55 tests in 9 items.
+55 passed and 0 failed.
+Test passed.
+
+real 0m0.129s
+user 0m0.083s
+sys 0m0.048s
+
+
+ ==============================================================================
+
+Exit code: 0
+
+real 0m1.067s
+user 0m0.748s
+sys 0m0.339s
+
+real 0m1.069s
+user 0m0.750s
+sys 0m0.339s
+
+===============================================================================
+
+Running: ../../.github/citools/python/python-test-with-coverage
+
+Running Python Tests With Coverage
+
+Python versions:
+
+ Python 3.12.1
+ pyenv 2.3.36-21-g7e550e31
+ pip 24.0 from /home/vpayno/.pyenv/versions/3.12.1/lib/python3.12/site-packages/pip (python 3.12)
+ PDM, version 2.13.2
+
+
+ ==============================================================================
+
+Running: rm -rf ./coverage
+
+
+real 0m0.001s
+user 0m0.000s
+sys 0m0.001s
+
+
+ ==============================================================================
+
+Running: pytest --version
+
+pytest 7.4.3
+
+real 0m0.819s
+user 0m0.863s
+sys 0m0.806s
+
+
+ ==============================================================================
+
+PYTHONPATH="./src"
+
+ ==============================================================================
+
+Running: pytest --verbose --cov=. --cov-branch --cov-report=term-missing --cov-report=xml:.coverage.xml -p no:randomly ./test
+
+============================= test session starts ==============================
+platform linux -- Python 3.12.1, pytest-7.4.3, pluggy-1.3.0 -- /home/vpayno/.pyenv/versions/3.12.1/bin/python
+cachedir: .pytest_cache
+hypothesis profile 'default' -> database=DirectoryBasedExampleDatabase(PosixPath('/home/vpayno/git_vpayno/exercism-workspace/python/luhn/.hypothesis/examples'))
+rootdir: /home/vpayno/git_vpayno/exercism-workspace/python/luhn
+configfile: pytest.ini
+plugins: anyio-4.2.0, libtmux-0.25.0, pylama-8.4.1, cov-4.1.0, datafiles-3.0.0, docker-2.0.1, subprocess-1.5.0, typeguard-4.1.5, hypothesis-6.98.17, snapshot-0.9.0
+collecting ... collected 23 items
+
+test/luhn_test.py::LuhnTest::test_a_simple_valid_sin_that_becomes_invalid_if_reversed PASSED [ 4%]
+test/luhn_test.py::LuhnTest::test_a_simple_valid_sin_that_remains_valid_if_reversed PASSED [ 8%]
+test/luhn_test.py::LuhnTest::test_a_single_zero_is_invalid PASSED [ 13%]
+test/luhn_test.py::LuhnTest::test_a_valid_canadian_sin PASSED [ 17%]
+test/luhn_test.py::LuhnTest::test_input_digit_9_is_correctly_converted_to_output_digit_9 PASSED [ 21%]
+test/luhn_test.py::LuhnTest::test_invalid_canadian_sin PASSED [ 26%]
+test/luhn_test.py::LuhnTest::test_invalid_credit_card PASSED [ 30%]
+test/luhn_test.py::LuhnTest::test_invalid_long_number_with_a_remainder_divisible_by_5 PASSED [ 34%]
+test/luhn_test.py::LuhnTest::test_invalid_long_number_with_an_even_remainder PASSED [ 39%]
+test/luhn_test.py::LuhnTest::test_is_valid_can_be_called_repeatedly PASSED [ 43%]
+test/luhn_test.py::LuhnTest::test_more_than_a_single_zero_is_valid PASSED [ 47%]
+test/luhn_test.py::LuhnTest::test_non_numeric_non_space_char_in_the_middle_with_a_sum_that_s_divisible_by_10_isn_t_allowed PASSED [ 52%]
+test/luhn_test.py::LuhnTest::test_single_digit_strings_can_not_be_valid PASSED [ 56%]
+test/luhn_test.py::LuhnTest::test_single_zero_with_space_is_invalid PASSED [ 60%]
+test/luhn_test.py::LuhnTest::test_using_ascii_value_for_doubled_non_digit_isn_t_allowed PASSED [ 65%]
+test/luhn_test.py::LuhnTest::test_using_ascii_value_for_non_doubled_non_digit_isn_t_allowed PASSED [ 69%]
+test/luhn_test.py::LuhnTest::test_valid_luhn_with_an_odd_number_of_digits_and_non_zero_first_digit PASSED [ 73%]
+test/luhn_test.py::LuhnTest::test_valid_number_with_an_even_number_of_digits PASSED [ 78%]
+test/luhn_test.py::LuhnTest::test_valid_number_with_an_odd_number_of_spaces PASSED [ 82%]
+test/luhn_test.py::LuhnTest::test_valid_strings_with_a_non_digit_added_at_the_end_become_invalid PASSED [ 86%]
+test/luhn_test.py::LuhnTest::test_valid_strings_with_punctuation_included_become_invalid PASSED [ 91%]
+test/luhn_test.py::LuhnTest::test_valid_strings_with_symbols_included_become_invalid PASSED [ 95%]
+test/luhn_test.py::LuhnTest::test_very_long_input_is_valid PASSED [100%]
+
+=============================== warnings summary ===============================
+../../../../.pyenv/versions/3.12.1/lib/python3.12/site-packages/coverage/pytracer.py:164
+ /home/vpayno/.pyenv/versions/3.12.1/lib/python3.12/site-packages/coverage/pytracer.py:164: DeprecationWarning: currentThread() is deprecated, use current_thread() instead
+ self.thread = self.threading.currentThread()
+
+-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
+
+---------- coverage: platform linux, python 3.12.1-final-0 -----------
+Name Stmts Miss Branch BrPart Cover Missing
+--------------------------------------------------------------
+luhn.py 38 1 10 1 96% 52->exit
+test/__init__.py 0 0 0 0 100%
+--------------------------------------------------------------
+TOTAL 38 1 10 1 96%
+Coverage XML written to file .coverage.xml
+
+======================== 23 passed, 1 warning in 0.99s =========================
+
+real 0m1.895s
+user 0m1.749s
+sys 0m0.148s
+
+
+ ==============================================================================
+
+Running: coverage report --show-missing
+
+Name Stmts Miss Branch BrPart Cover Missing
+--------------------------------------------------------------
+luhn.py 38 1 10 1 96% 52->exit
+test/__init__.py 0 0 0 0 100%
+--------------------------------------------------------------
+TOTAL 38 1 10 1 96%
+
+real 0m0.167s
+user 0m0.091s
+sys 0m0.079s
+
+
+ ==============================================================================
+
+Running: coverage annotate
+
+
+real 0m0.149s
+user 0m0.091s
+sys 0m0.061s
+
+
+ ==============================================================================
+
+Line Coverage: 97.4%
+Branch Coverage: 90.0%
+
+ ==============================================================================
+
+Exit code: 0
+
+real 0m3.971s
+user 0m3.466s
+sys 0m1.380s
+
+real 0m3.973s
+user 0m3.466s
+sys 0m1.382s
+
+===============================================================================
+
+tail -n 10000 ./*,cover | grep -E -C 3 '^> def |^! '
+
+===============================================================================
+
+Running: misspell ./src/luhn/__init__.py ./src/luhn/luhn.py
+
+real 0m0.019s
+user 0m0.017s
+sys 0m0.011s
+
+===============================================================================
+
diff --git a/python/luhn/src/luhn/__init__.py b/python/luhn/src/luhn/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/python/luhn/src/luhn/luhn.py b/python/luhn/src/luhn/luhn.py
new file mode 120000
index 00000000..0afe5d25
--- /dev/null
+++ b/python/luhn/src/luhn/luhn.py
@@ -0,0 +1 @@
+../../luhn.py
\ No newline at end of file
diff --git a/python/luhn/test/__init__.py b/python/luhn/test/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/python/luhn/test/__init__.py,cover b/python/luhn/test/__init__.py,cover
new file mode 100644
index 00000000..e69de29b
diff --git a/python/luhn/test/luhn_test.py b/python/luhn/test/luhn_test.py
new file mode 120000
index 00000000..578e7971
--- /dev/null
+++ b/python/luhn/test/luhn_test.py
@@ -0,0 +1 @@
+../luhn_test.py
\ No newline at end of file