How to Submit a Package to PyPI - Part 2

Refactoring, simplifying package definitions using setup.cfg and supporting Windows

Posted 2019-05-06 17:26:47 by Ronie Martinez

Amortization 0.2.0

This is a follow up on my previous blog, How to Submit a Package to PyPI. As the blog gained a lot of requests and feedback on Reddit, I reviewed all the comments and made improvements to our previous package.

Refactoring

While new features are added to a software or library, the code base becomes bigger and maintenaing it becomes harder. This time, developers will start to notice code smells which might cause issues in the long run. To solve this problem, we need to refactor our code. Code refactoring is a process of restructuring a code base without changing the behavior of a software, but fixing possible bugs, removing duplicates and improving readability, among others. As a result, the code base will be easier to maintain.

There are several principles that we can use when refactoring. One of which is the Single Responsibility Principle. This means that a module, class or function should be doing one thing only over the entire functionality of the software.

We will begin by converting our module into a package. First is to create a directory amortization and inside this directory, write a file __init__.py and move the function calculate_amortization_amount() into it.

#!/usr/bin/env python


def calculate_amortization_amount(principal, interest_rate, period):
    """
    Calculates Amortization Amount per period

    :param principal: Principal amount
    :param interest_rate: Interest rate per period
    :param period: Total number of periods
    :return: Amortization amount per period
    """
    x = (1 + interest_rate) ** period
    return principal * (interest_rate * x) / (x - 1)

The second step is to write a file amortization/schedule.py and move the function amortization_schedule().

#!/usr/bin/env python
from amortization import calculate_amortization_amount


def amortization_schedule(principal, interest_rate, period):
    """
    Generates amortization schedule

    :param principal: Principal amount
    :param interest_rate: Interest rate per period
    :param period: Total number of periods
    :return: Rows containing period, interest, principal, balance, etc
    """
    amortization_amount = calculate_amortization_amount(principal, interest_rate, period)
    number = 1
    balance = principal
    while number <= period:
        interest = balance * interest_rate
        principal = amortization_amount - interest
        balance -= principal
        yield number, amortization_amount, interest, principal, balance if balance > 0 else 0
        number += 1

Next step is to write another file, amortization/amortize.py. Here we will move the main() function.

#!/usr/bin/env python
from amortization import calculate_amortization_amount
from amortization.schedule import amortization_schedule


def main():  # pragma: no cover
    import argparse
    from tabulate import tabulate

    parser = argparse.ArgumentParser(
        description='Python library for calculating amortizations and generating amortization schedules')
    # required parameters
    required = parser.add_argument_group('required arguments')
    required.add_argument('-P', '--principal', dest='principal', type=float, required=True, help='Principal amount')
    required.add_argument('-n', '--period', dest='period', type=int, required=True, help='Total number of periods')
    required.add_argument('-r', '--interest-rate', dest='interest_rate', type=float, required=True,
                          help='Interest rate per period')
    # optional parameters
    parser.add_argument('-s', '--schedule', dest='schedule', default=False, action='store_true',
                        help='Generate amortization schedule')
    arguments = parser.parse_args()
    if arguments.schedule:
        table = (x for x in amortization_schedule(arguments.principal, arguments.interest_rate, arguments.period))
        print(
            tabulate(
                table,
                headers=["Number", "Amount", "Interest", "Principal", "Balance"],
                floatfmt=",.2f",
                numalign="right"
            )
        )
    else:
        amount = calculate_amortization_amount(arguments.principal, arguments.interest_rate, arguments.period)
        print("Amortization amount: {:,.2f}".format(amount))

Imports to our tests/test_amortization.py should be updated too.

#!/usr/bin/env python
from amortization import calculate_amortization_amount
from amortization.schedule import amortization_schedule

Test again!

We need to make sure that our tests will still pass. Run pytest.

tests\test_amortization.py ..                                            [100%]

========================== 2 passed in 0.11 seconds ===========================

Moving definitions to setup.cfg

We can move all the package definitions to setup.cfg like this.

[metadata]
name=amortization
version=0.1.1
download_url=https://github.com/roniemartinez/amortization/tarball/0.1.1
description=Python library for calculating amortizations and generating amortization schedules
long_description=file:README.md
long_description_content_type=text/markdown
author=Ronie Martinez
author_email=ronmarti18@gmail.com
url=https://github.com/roniemartinez/amortization
license=MIT
license_files=LICENSE
classifiers=
    Development Status :: 4 - Beta
    License :: OSI Approved :: MIT License
    Topic :: Office/Business :: Financial
    Topic :: Scientific/Engineering :: Mathematics
    Topic :: Software Development :: Libraries :: Python Modules
    Programming Language :: Python :: 2
    Programming Language :: Python :: 2.7
    Programming Language :: Python :: 3
    Programming Language :: Python :: 3.5
    Programming Language :: Python :: 3.6
    Programming Language :: Python :: 3.7
    Programming Language :: Python :: Implementation :: CPython

[options]
packages=
    amortization
install_requires=
    tabulate ~= 0.8.3

[options.entry_points]
console_scripts=
    amortize=amortization.amortize:main

[bdist_wheel]
universal=1

This will leave setup.py with only a few lines of code.

#!/usr/bin/env python
from setuptools import setup

setup()

Why not use scripts in the definition?

As of this writing, scripts does not create an executable on Windows. We will retain using entry_points.

Bonus: Supporting other platforms

Adding PyPy

To support PyPy, we only need to add pypy to our .travis.yml file.

python:
  - 2.7
  - 3.5
  - 3.6
  - pypy

Supporting Windows

We will use AppVeyor to test our library on Windows. AppVeyor is another CI/CD platform similar to Travis CI that supports Windows environments. Register to AppVeyor using your Github account and create a new project. After adding a new project, go to https://ci.appveyor.com/project/<owner>/<repo>/settings, click Environment and click Add variable to add the CODECOV_TOKEN that we obtained from CodeCov (see previous blog). 

The last step is to include an appveyor.yml file to our project. The contents are almost similar to our Travis CI settings except that we are testing on 32-bit and 64-bit platforms.

environment:
  matrix:
    - PYTHON: "C:\\Python27"
    - PYTHON: "C:\\Python27-x64"
    - PYTHON: "C:\\Python35"
    - PYTHON: "C:\\Python35-x64"
    - PYTHON: "C:\\Python36"
    - PYTHON: "C:\\Python36-x64"
    - PYTHON: "C:\\Python37"
    - PYTHON: "C:\\Python37-x64"

branches:
  except:
    - /^[0-9]+\.[0-9]+\.[0-9]+/

install:
  - "%PYTHON%\\Scripts\\pip.exe install pipenv"
  - "%PYTHON%\\Scripts\\pipenv.exe install --dev --skip-lock"

build: off

cache:
  - '%LOCALAPPDATA%\pip\Cache'

test_script:
  - "%PYTHON%\\Scripts\\pipenv.exe run pytest --cov=amortization --cov-report=xml -v"

on_success:
  - "%PYTHON%\\Scripts\\pipenv.exe run codecov -f coverage.xml"

Key takeaways

  • Code refactoring is one important process that software developers have to do in order to maintain a working software. 
  • We can move Python package definitions from setup.py to setup.cfg with minimal effort.

Thanks to all the Redditors who gave their feedback!

python appveyor testpypi pypi pypy


Share