Repository: PlasmaPy/PlasmaPy
Pull request #514 (2018-07-16 23:00:07)

Incorporate roman package into PlasmaPy
Earlier this year, we added the roman package as a dependency so that we can deal with spectroscopic notation for ions (#286, #290, #464, #468, #504).  While roman is available on PyPI, it is not available on conda-forge.  Because of that, we ran into problems while trying to make PlasmaPy available on conda-forge as well (related to #93).  Our options were to put roman on conda-forge to fix this, or to incorporate roman directly into PlasmaPy.  Because all of the needed functionality in roman is contained in its `src/roman.py` file, it makes a lot of sense to just incorporate that file into PlasmaPy under the terms of its license.  

This PR incorporates roman into PlasmaPy as `plasmapy.utils.roman` and removes roman as one of PlasmaPy's dependencies.  I put this into `plasmapy.utils` instead of something like `plasmapy.extern` because I thought this was where it would make the most sense to put the functionality.  I added detailed docstrings with examples, and did some refactoring.  I also added some detailed tests in order to help with reliability and maintainability (in particular by testing the full range from 1 to ~120 because those are the numbers we would need for spectroscopic notation).

The roman package is copyright 2001 by Mark Pilgrim and is available under the terms of the [Python 2.1.1 license](https://www.python.org/download/releases/2.1.1/license/).


Patch:
diff --git a/appveyor.yml b/appveyor.yml
index 4b435e88..2958ff89 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -9,7 +9,7 @@ environment:
     CONDA_DEPENDENCIES: "Cython astropy scipy h5py matplotlib mpmath pytest"
     NUMPY_VERSION: "stable"
     CONDA_ALL_DEPENDENCIES: "Cython astropy scipy h5py matplotlib mpmath pytest"
-    PIP_DEPENDENCIES: "roman lmfit colorama codecov"
+    PIP_DEPENDENCIES: "lmfit colorama codecov"
 
   matrix:
     - PYTHON_VERSION: "3.6"
diff --git a/licenses/roman_license.rst b/licenses/roman_license.rst
new file mode 100644
index 00000000..0f15cc6c
--- /dev/null
+++ b/licenses/roman_license.rst
@@ -0,0 +1,56 @@
+License for roman package
+=========================
+
+Copyright (c) 2001 Mark Pilgrim
+
+This program is free software; you can redistribute it and/or modify
+it under the terms of the Python 2.1.1 license, available at
+https://www.python.org/download/releases/2.1.1/license/
+
+PSF LICENSE AGREEMENT
+---------------------
+
+1. This LICENSE AGREEMENT is between the Python Software Foundation
+   ("PSF"), and the Individual or Organization ("Licensee") accessing
+   and otherwise using Python 2.1.1 software in source or binary form
+   and its associated documentation.
+
+2. Subject to the terms and conditions of this License Agreement, PSF
+   hereby grants Licensee a nonexclusive, royalty-free, world-wide
+   license to reproduce, analyze, test, perform and/or display publicly,
+   prepare derivative works, distribute, and otherwise use Python 2.1.1
+   alone or in any derivative version, provided, however, that PSF's
+   License Agreement and PSF's notice of copyright, i.e., "Copyright (c)
+   2001 Python Software Foundation; All Rights Reserved" are retained in
+   Python 2.1.1 alone or in any derivative version prepared by Licensee.
+
+3. In the event Licensee prepares a derivative work that is based on
+   or incorporates Python 2.1.1 or any part thereof, and wants to make
+   the derivative work available to others as provided herein, then
+   Licensee hereby agrees to include in any such work a brief summary of
+   the changes made to Python 2.1.1.
+
+4. PSF is making Python 2.1.1 available to Licensee on an "AS IS"
+   basis.  PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR
+   IMPLIED.  BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND
+   DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS
+   FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 2.1.1 WILL NOT
+   INFRINGE ANY THIRD PARTY RIGHTS.
+
+5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON
+   2.1.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS
+   A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 2.1.1,
+   OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF.
+
+6. This License Agreement will automatically terminate upon a material
+   breach of its terms and conditions.
+
+7. Nothing in this License Agreement shall be deemed to create any
+   relationship of agency, partnership, or joint venture between PSF and
+   Licensee.  This License Agreement does not grant permission to use PSF
+   trademarks or trade name in a trademark sense to endorse or promote
+   products or services of Licensee, or any third party.
+
+8. By copying, installing or otherwise using Python 2.1.1, Licensee
+   agrees to be bound by the terms and conditions of this License
+   Agreement.
diff --git a/plasmapy/atomic/parsing.py b/plasmapy/atomic/parsing.py
index 4e3a5bed..40492cec 100644
--- a/plasmapy/atomic/parsing.py
+++ b/plasmapy/atomic/parsing.py
@@ -1,6 +1,6 @@
 """Functionality to parse representations of particles into standard form."""
 
-import roman
+from plasmapy.utils import roman
 import numpy as np
 import re
 import warnings
@@ -210,8 +210,8 @@ def _extract_charge(arg: str):
             charge_str = charge_info.strip('+-')
 
             try:
-                if roman.romanNumeralPattern.match(charge_info):
-                    Z_from_arg = roman.fromRoman(charge_info) - 1
+                if roman.is_roman_numeral(charge_info):
+                    Z_from_arg = roman.from_roman(charge_info) - 1
                 elif '-' in charge_info:
                     Z_from_arg = - int(charge_str)
                 elif '+' in charge_info:
diff --git a/plasmapy/atomic/particle_class.py b/plasmapy/atomic/particle_class.py
index 0a5d2c92..e0e4558c 100644
--- a/plasmapy/atomic/particle_class.py
+++ b/plasmapy/atomic/particle_class.py
@@ -4,7 +4,7 @@
 import warnings
 from typing import (Union, Set, Tuple, List, Optional)
 import collections
-import roman
+import plasmapy.utils.roman as roman
 
 import astropy.units as u
 import astropy.constants as const
@@ -637,7 +637,7 @@ def roman_symbol(self) -> Optional[str]:
 
         symbol = self.isotope if self.isotope else self.element
         integer_charge = self._attributes['integer charge']
-        roman_charge = roman.toRoman(integer_charge + 1)
+        roman_charge = roman.to_roman(integer_charge + 1)
         return f"{symbol} {roman_charge}"
 
     @property
diff --git a/plasmapy/utils/__init__.py b/plasmapy/utils/__init__.py
index a1bcf0a5..1fe40615 100644
--- a/plasmapy/utils/__init__.py
+++ b/plasmapy/utils/__init__.py
@@ -34,3 +34,5 @@
     MissingWarningError,
     assert_can_handle_nparray,
 )
+
+from . import roman
diff --git a/plasmapy/utils/roman/__init__.py b/plasmapy/utils/roman/__init__.py
new file mode 100644
index 00000000..ec9f17a3
--- /dev/null
+++ b/plasmapy/utils/roman/__init__.py
@@ -0,0 +1,10 @@
+"""Utilities to convert integers to Roman numerals and vice versa."""
+
+from .roman import (
+    to_roman,
+    from_roman,
+    is_roman_numeral,
+    RomanError,
+    OutOfRangeError,
+    InvalidRomanNumeralError,
+)
diff --git a/plasmapy/utils/roman/roman.py b/plasmapy/utils/roman/roman.py
new file mode 100644
index 00000000..2414cd60
--- /dev/null
+++ b/plasmapy/utils/roman/roman.py
@@ -0,0 +1,213 @@
+"""
+Convert to and from Roman numerals.
+
+This file is adapted from the roman package that is available on PyPI,
+which has the following copyright/license information:
+
+Copyright (c) 2001 Mark Pilgrim
+
+This program is free software; you can redistribute it and/or modify
+it under the terms of the Python 2.1.1 license, available at
+https://www.python.org/download/releases/2.1.1/license/
+
+"""
+
+import re
+import numpy as np
+import typing
+
+
+__all__ = [
+    'RomanError',
+    'OutOfRangeError',
+    'InvalidRomanNumeralError',
+    'to_roman',
+    'from_roman',
+    'is_roman_numeral',
+]
+
+
+class RomanError(Exception):
+    """A base exception for errors from `~plasmapy.utils.roman`."""
+    pass
+
+
+class OutOfRangeError(RomanError):
+    """
+    An exception to be raised for integers that outside of the range
+    that can be converted to Roman numerals.
+    """
+    pass
+
+
+class InvalidRomanNumeralError(RomanError):
+    """
+    An exception to be raised when the input is not a valid Roman
+    numeral.
+    """
+    pass
+
+
+# Define digit mapping
+_romanNumeralMap = (('M', 1000),
+                    ('CM', 900),
+                    ('D',  500),
+                    ('CD', 400),
+                    ('C',  100),
+                    ('XC', 90),
+                    ('L',  50),
+                    ('XL', 40),
+                    ('X',  10),
+                    ('IX', 9),
+                    ('V',  5),
+                    ('IV', 4),
+                    ('I',  1))
+
+# Define pattern to detect valid Roman numerals
+_romanNumeralPattern = re.compile("""
+    ^                   # beginning of string
+    M{0,4}              # thousands - 0 to 4 M's
+    (CM|CD|D?C{0,3})    # hundreds - 900 (CM), 400 (CD), 0-300 (0 to 3 C's),
+                        #            or 500-800 (D, followed by 0 to 3 C's)
+    (XC|XL|L?X{0,3})    # tens - 90 (XC), 40 (XL), 0-30 (0 to 3 X's),
+                        #        or 50-80 (L, followed by 0 to 3 X's)
+    (IX|IV|V?I{0,3})    # ones - 9 (IX), 4 (IV), 0-3 (0 to 3 I's),
+                        #        or 5-8 (V, followed by 0 to 3 I's)
+    $                   # end of string
+    """, re.VERBOSE)
+
+
+def to_roman(n: typing.Union[int, np.integer]) -> str:
+    """
+    Convert an integer to a Roman numeral.
+
+    Parameters
+    ----------
+    n : int or `~numpy.integer`
+        The integer to be converted to a Roman numeral that must be
+        between 1 and 4999, inclusive.
+
+    Returns
+    -------
+    result : str
+        The number in Roman numeral notation.
+
+    Raises
+    ------
+    TypeError
+        If the input is not an integer.
+
+    ~plasmapy.utils.roman.OutOfRangeError
+        If the number is not between 1 and 4999, inclusive.
+
+    See Also
+    --------
+    from_roman
+
+    Examples
+    --------
+    >>> to_roman(5)
+    'V'
+    >>> to_roman(2525)
+    'MMDXXV'
+
+    """
+    if not isinstance(n, (int, np.integer)):
+        raise TypeError(f"{n} cannot be converted to a Roman numeral.")
+    if not (0 < n < 5000):
+        raise OutOfRangeError("Number is out of range (need 0 < n < 5000)")
+
+    result = ""
+    for numeral, integer in _romanNumeralMap:
+        while n >= integer:
+            result += numeral
+            n -= integer
+    return result
+
+
+def from_roman(s: str) -> int:
+    """
+    Convert a Roman numeral to an integer.
+
+    Parameters
+    ----------
+    s : str
+        A Roman numeral.
+
+    Returns
+    -------
+    result : int
+        The integer corresponding to the Roman numeral.
+
+    Raises
+    ------
+    TypeError
+        The argument is not a `str`.
+
+    ~plasmapy.utils.roman.InvalidRomanNumeralError
+        The argument is not a valid Roman numeral.
+
+    See Also
+    --------
+    to_roman
+
+    Examples
+    --------
+    >>> from_roman('V')
+    5
+    >>> from_roman('MMMMCCCLXVII')
+    4367
+
+    """
+    if not isinstance(s, str):
+        raise TypeError('The argument to from_roman must be a string.')
+    if not _romanNumeralPattern.search(s):
+        raise InvalidRomanNumeralError('Invalid Roman numeral: %s' % s)
+
+    result = 0
+    index = 0
+    for numeral, integer in _romanNumeralMap:
+        while s[index:index+len(numeral)] == numeral:
+            result += integer
+            index += len(numeral)
+    return result
+
+
+def is_roman_numeral(s: str) -> bool:
+    """
+    Check whether or not a string is a valid Roman numeral.
+
+    Parameters
+    ----------
+    s : str
+        The possible Roman numeral.
+
+    Returns
+    -------
+    result : bool
+        `True` if the `str` input is a valid Roman numeral, and `False`
+        if it is not.
+
+    Raises
+    ------
+    TypeError
+        If the argument is not a `str`.
+
+    See Also
+    --------
+    to_roman
+
+    from_roman
+
+    Examples
+    --------
+    >>> is_roman_numeral("CXVII")
+    True
+    >>> is_roman_numeral("42")
+    False
+
+    """
+    if not isinstance(s, str):
+        raise TypeError("Only strings may be tested ")
+    result = bool(_romanNumeralPattern.match(s))
+    return result
diff --git a/requirements/environment.yml b/requirements/environment.yml
index d3f31418..d61dc71a 100644
--- a/requirements/environment.yml
+++ b/requirements/environment.yml
@@ -13,7 +13,6 @@ dependencies:
     - numpydoc
     - pytest
     - mpmath
-    - roman
     - h5py
     - lmfit
     - matplotlib
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
index 8bf053be..06362286 100644
--- a/requirements/requirements.txt
+++ b/requirements/requirements.txt
@@ -2,7 +2,6 @@ numpy (>= 1.13)
 scipy (>= 0.19)
 astropy (>= 2.0)
 mpmath (>= 1.0.0)
-roman (>= 1.4)
 h5py (>= 2.8)
 lmfit (>= 0.9.7)
 matplotlib (>= 2.0)
diff --git a/setup.cfg b/setup.cfg
index 08356b6d..6d2d5f57 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -9,7 +9,7 @@ edit_on_github = True
 github_project = PlasmaPy/PlasmaPy
 # install_requires should be formatted as a comma-separated list, e.g.:
 # install_requires = astropy, scipy, matplotlib
-install_requires = numpy, scipy, astropy, cython, lmfit, matplotlib, colorama, roman, mpmath, h5py
+install_requires = numpy, scipy, astropy, cython, lmfit, matplotlib, colorama, mpmath, h5py
 # version should be PEP386 compatible (http://www.python.org/dev/peps/pep-0386)
 version = 0.1.1.dev
 


Test patch:
diff --git a/plasmapy/atomic/tests/test_particle_class.py b/plasmapy/atomic/tests/test_particle_class.py
index 17558a58..2cfcedde 100644
--- a/plasmapy/atomic/tests/test_particle_class.py
+++ b/plasmapy/atomic/tests/test_particle_class.py
@@ -2,7 +2,7 @@
 import numpy as np
 from astropy import units as u
 import inspect
-import roman
+from plasmapy.utils import roman
 
 from ...constants import m_p, m_e, m_n, e
 
diff --git a/plasmapy/utils/roman/tests/__init__.py b/plasmapy/utils/roman/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/plasmapy/utils/roman/tests/test_roman.py b/plasmapy/utils/roman/tests/test_roman.py
new file mode 100644
index 00000000..c026d9b6
--- /dev/null
+++ b/plasmapy/utils/roman/tests/test_roman.py
@@ -0,0 +1,205 @@
+import pytest
+import numpy as np
+import plasmapy.utils.roman as roman
+from plasmapy.utils.pytest_helpers import run_test
+
+
+ints_and_roman_numerals = [
+    (1, 'I'),
+    (2, 'II'),
+    (3, 'III'),
+    (4, 'IV'),
+    (5, 'V'),
+    (6, 'VI'),
+    (7, 'VII'),
+    (8, 'VIII'),
+    (9, 'IX'),
+    (10, 'X'),
+    (11, 'XI'),
+    (12, 'XII'),
+    (13, 'XIII'),
+    (14, 'XIV'),
+    (15, 'XV'),
+    (16, 'XVI'),
+    (17, 'XVII'),
+    (18, 'XVIII'),
+    (19, 'XIX'),
+    (20, 'XX'),
+    (21, 'XXI'),
+    (22, 'XXII'),
+    (23, 'XXIII'),
+    (24, 'XXIV'),
+    (25, 'XXV'),
+    (26, 'XXVI'),
+    (27, 'XXVII'),
+    (28, 'XXVIII'),
+    (29, 'XXIX'),
+    (30, 'XXX'),
+    (31, 'XXXI'),
+    (32, 'XXXII'),
+    (33, 'XXXIII'),
+    (34, 'XXXIV'),
+    (35, 'XXXV'),
+    (36, 'XXXVI'),
+    (37, 'XXXVII'),
+    (38, 'XXXVIII'),
+    (39, 'XXXIX'),
+    (40, 'XL'),
+    (41, 'XLI'),
+    (42, 'XLII'),
+    (43, 'XLIII'),
+    (44, 'XLIV'),
+    (45, 'XLV'),
+    (46, 'XLVI'),
+    (47, 'XLVII'),
+    (48, 'XLVIII'),
+    (49, 'XLIX'),
+    (50, 'L'),
+    (51, 'LI'),
+    (52, 'LII'),
+    (53, 'LIII'),
+    (54, 'LIV'),
+    (55, 'LV'),
+    (56, 'LVI'),
+    (57, 'LVII'),
+    (58, 'LVIII'),
+    (59, 'LIX'),
+    (60, 'LX'),
+    (61, 'LXI'),
+    (62, 'LXII'),
+    (63, 'LXIII'),
+    (64, 'LXIV'),
+    (65, 'LXV'),
+    (66, 'LXVI'),
+    (67, 'LXVII'),
+    (68, 'LXVIII'),
+    (69, 'LXIX'),
+    (70, 'LXX'),
+    (71, 'LXXI'),
+    (72, 'LXXII'),
+    (73, 'LXXIII'),
+    (74, 'LXXIV'),
+    (75, 'LXXV'),
+    (76, 'LXXVI'),
+    (77, 'LXXVII'),
+    (78, 'LXXVIII'),
+    (79, 'LXXIX'),
+    (80, 'LXXX'),
+    (81, 'LXXXI'),
+    (82, 'LXXXII'),
+    (83, 'LXXXIII'),
+    (84, 'LXXXIV'),
+    (85, 'LXXXV'),
+    (86, 'LXXXVI'),
+    (87, 'LXXXVII'),
+    (88, 'LXXXVIII'),
+    (89, 'LXXXIX'),
+    (90, 'XC'),
+    (91, 'XCI'),
+    (92, 'XCII'),
+    (93, 'XCIII'),
+    (94, 'XCIV'),
+    (95, 'XCV'),
+    (96, 'XCVI'),
+    (97, 'XCVII'),
+    (98, 'XCVIII'),
+    (99, 'XCIX'),
+    (100, 'C'),
+    (101, 'CI'),
+    (102, 'CII'),
+    (103, 'CIII'),
+    (104, 'CIV'),
+    (105, 'CV'),
+    (106, 'CVI'),
+    (107, 'CVII'),
+    (108, 'CVIII'),
+    (109, 'CIX'),
+    (110, 'CX'),
+    (111, 'CXI'),
+    (112, 'CXII'),
+    (113, 'CXIII'),
+    (114, 'CXIV'),
+    (115, 'CXV'),
+    (116, 'CXVI'),
+    (117, 'CXVII'),
+    (118, 'CXVIII'),
+    (119, 'CXIX'),
+    (120, 'CXX'),
+    (121, 'CXXI'),
+    (122, 'CXXII'),
+    (188, 'CLXXXVIII'),
+    (189, 'CLXXXIX'),
+    (198, 'CXCVIII'),
+    (199, 'CXCIX'),
+    (200, 'CC'),
+    (np.int(9), 'IX'),
+    (np.int16(10), 'X'),
+    (np.int32(11), 'XI'),
+    (np.int64(14), 'XIV'),
+]
+
+toRoman_exceptions_table = [
+    ('X', TypeError),
+    (-1, roman.OutOfRangeError),
+    (0, roman.OutOfRangeError),
+    (5000, roman.OutOfRangeError),
+]
+
+fromRoman_exceptions_table = [
+    ('asdfasd', roman.InvalidRomanNumeralError),
+    (1, TypeError),
+    ('xi', roman.InvalidRomanNumeralError),
+]
+
+
+@pytest.mark.parametrize('integer, roman_numeral', ints_and_roman_numerals)
+def test_to_roman(integer, roman_numeral):
+    """
+    Test that `~plasmapy.utils.roman.to_roman` correctly converts
+    integers to Roman numerals.
+    """
+    run_test(func=roman.to_roman, args=integer, expected_outcome=roman_numeral)
+
+
+@pytest.mark.parametrize('integer, roman_numeral', ints_and_roman_numerals)
+def test_from_roman(integer, roman_numeral):
+    """
+    Test that `~plasmapy.utils.roman.from_roman` correctly converts
+    Roman numerals to integers.
+    """
+    run_test(func=roman.to_roman, args=integer, expected_outcome=roman_numeral)
+
+
+@pytest.mark.parametrize('input, expected_exception', toRoman_exceptions_table)
+def test_to_roman_exceptions(input, expected_exception):
+    """
+    Test that `~plasmapy.utils.roman.to_roman` raises the correct
+    exceptions when necessary.
+    """
+    run_test(func=roman.to_roman, args=input, expected_outcome=expected_exception)
+
+
+@pytest.mark.parametrize('input, expected_exception', fromRoman_exceptions_table)
+def test_from_roman_exceptions(input, expected_exception):
+    """
+    Test that `~plasmapy.utils.roman.from_roman` raises the correct
+    exceptions when necessary.
+    """
+    run_test(func=roman.from_roman, args=input, expected_outcome=expected_exception)
+
+
+test_is_roman_numeral_table = [
+    ('I', True),
+    ('i', False),
+    ('CLXXXVIII', True),
+    (1, TypeError),
+    ('khjfda', False),
+    ('VIIII', False),
+    ('IXX', False),
+    (('I', 'II'), TypeError),
+]
+
+
+@pytest.mark.parametrize('input, expected', test_is_roman_numeral_table)
+def test_is_roman_numeral(input, expected):
+    run_test(func=roman.is_roman_numeral, args=input, expected_outcome=expected)