SF.net SVN: docutils:[9845 ] trunk/docutils

aa-turner--- via Docutils-checkins <[email protected]>
Newsgroups gmane.text.docutils.cvs
Message-ID <[email protected]>
Revision: 9845
          http://sourceforge.net/p/docutils/code/9845
Author:   aa-turner
Date:     2024-08-02 01:16:42 +0000 (Fri, 02 Aug 2024)
Log Message:
-----------
Add a new roman numeral implementation

Modified Paths:
--------------
    trunk/docutils/HISTORY.txt

Added Paths:
-----------
    trunk/docutils/docutils/utils/_roman_numerals.py
    trunk/docutils/test/test_utils/test__roman_numerals.py

Modified: trunk/docutils/HISTORY.txt
===================================================================
--- trunk/docutils/HISTORY.txt	2024-08-01 23:10:27 UTC (rev 9844)
+++ trunk/docutils/HISTORY.txt	2024-08-02 01:16:42 UTC (rev 9845)
@@ -128,6 +128,10 @@
   - Removed `Reporter.set_conditions()`.
     Set attributes via configuration settings or directly.
 
+* docutils/utils/_roman_numerals.py
+
+  - Added new implementation.
+
 * docutils/utils/error_reporting.py
 
   - Removed. Obsolete in Python 3.

Added: trunk/docutils/docutils/utils/_roman_numerals.py
===================================================================
--- trunk/docutils/docutils/utils/_roman_numerals.py	                        (rev 0)
+++ trunk/docutils/docutils/utils/_roman_numerals.py	2024-08-02 01:16:42 UTC (rev 9845)
@@ -0,0 +1,247 @@
+# $Id$
+# Author:  Adam Turner.
+# Copyright: This module is placed in the public domain
+#            or under the `Zero Clause BSD licence`_,
+#            whichever is more permissive.
+#
+# .. _Zero Clause BSD licence: https://opensource.org/license/0BSD
+
+"""Conversion between integers and roman numerals."""
+
+from __future__ import annotations
+
+import sys
+from typing import TYPE_CHECKING, final
+
+if TYPE_CHECKING:
+    from typing import Final, Self
+
+__all__ = (
+    'MIN',
+    'MAX',
+    'RomanNumeral',
+    'OutOfRangeError',
+    'InvalidRomanNumeralError',
+)
+
+MIN: Final = 1
+"""The value of the smallest well-formed roman numeral."""
+
+# Note that 4,999 (MMMMCMXCIX) breaks one of the rules of Roman numerals,
+# that the same character may not appear more than thrice consecutively,
+# meaning the largest 'well-formed' Roman numeral is 3,999 (MMMCMXCIX).
+# We use 4,999 for backwards compatibility reasons.
+MAX: Final = 4_999
+"""The value of the largest well-formed roman numeral."""
+
+
+class OutOfRangeError(TypeError):
+    """Number out of range (must be between 1 and 4,999)."""
+
+
+class InvalidRomanNumeralError(ValueError):
+    """Not a valid Roman numeral."""
+
+    def __init__(self, value, *args):
+        msg = f'Invalid Roman numeral: {value}'
+        super().__init__(msg, *args)
+
+
+@final
+class RomanNumeral:
+    """A Roman numeral.
+
+    Only values between 1 and 4,999 are valid.
+    Stores the value internally as an ``int``.
+
+    >>> answer = RomanNumeral(42)
+    >>> print(answer.to_uppercase())
+    XLII
+    """
+    __slots__ = ('_value',)
+    _value: int
+
+    def __init__(self, value: int, /) -> None:
+        if not isinstance(value, int):
+            msg = 'RomanNumeral: an integer is required, not %r'
+            raise TypeError(msg % type(value).__qualname__)
+        if value < MIN or value > MAX:
+            msg = 'Number out of range (must be between 1 and 4,999). Got %s.'
+            raise OutOfRangeError(msg % value)
+        super().__setattr__('_value', value)
+
+    def __int__(self) -> int:
+        return self._value
+
+    def __str__(self) -> str:
+        return self.to_uppercase()
+
+    def __repr__(self):
+        return f'{self.__class__.__name__}({self._value!r})'
+
+    def __eq__(self, other):
+        if isinstance(other, RomanNumeral):
+            return self._value == other._value
+        return NotImplemented
+
+    def __lt__(self, other):
+        if isinstance(other, RomanNumeral):
+            return self._value < other._value
+        return NotImplemented
+
+    def __hash__(self):
+        return hash(self._value)
+
+    def __setattr__(self, key, value):
+        if key == '_value':
+            raise AttributeError('Cannot set the value attribute.')
+        super().__setattr__(key, value)
+
+    def to_uppercase(self) -> str:
+        """Converts a ``RomanNumeral`` to an uppercase string.
+
+        >>> answer = RomanNumeral(42)
+        >>> assert answer.to_uppercase() == 'XLII'
+        """
+        out = []
+        n = int(self)
+        for value, name, _ in _ROMAN_NUMERAL_PREFIXES:
+            while n >= value:
+                n -= value
+                out.append(name)
+        return ''.join(out)
+
+    def to_lowercase(self) -> str:
+        """Converts a ``RomanNumeral`` to a lowercase string.
+
+        >>> answer = RomanNumeral(42)
+        >>> assert answer.to_lowercase() == 'xlii'
+        """
+        out = []
+        n = int(self)
+        for value, _, name in _ROMAN_NUMERAL_PREFIXES:
+            while n >= value:
+                n -= value
+                out.append(name)
+        return ''.join(out)
+
+    @classmethod
+    def from_string(self, string: str, /) -> Self:
+        """Creates a ``RomanNumeral`` from a well-formed string representation.
+
+        Returns ``RomanNumeral`` or raises ``InvalidRomanNumeralError``.
+
+        >>> answer = RomanNumeral.from_string('XLII')
+        >>> assert int(answer) == 42
+        """
+        # Not an empty string.
+        if not string or not isinstance(string, str):
+            raise InvalidRomanNumeralError(string)
+
+        # ASCII-only uppercase string.
+        if string.isascii() and string.isupper():
+            chars = string.encode('ascii')
+        elif string.isascii() and string.islower():
+            chars = string.upper().encode('ascii')
+        else:
+            # Either Non-ASCII or mixed-case ASCII.
+            raise InvalidRomanNumeralError(string)
+
+        # ASCII-only uppercase string only containing I, V, X, L, C, D, M.
+        if not frozenset(b'IVXLCDM').issuperset(chars):
+            raise InvalidRomanNumeralError(string)
+
+        result: int = 0
+        idx: int = 0
+
+        # Thousands: between 0 and 4 "M" characters at the start
+        for _ in range(4):
+            if chars[idx:idx + 1] == b'M':
+                result += 1000
+                idx += 1
+            else:
+                break
+        if len(chars) == idx:
+            return RomanNumeral(result)
+
+        # Hundreds: 900 ("CM"), 400 ("CD"), 0-300 (0 to 3 "C" chars),
+        # or 500-800 ("D", followed by 0 to 3 "C" chars)
+        if chars[idx:idx + 2] == b'CM':
+            result += 900
+            idx += 2
+        elif chars[idx:idx + 2] == b'CD':
+            result += 400
+            idx += 2
+        else:
+            if chars[idx:idx + 1] == b'D':
+                result += 500
+                idx += 1
+            for _ in range(3):
+                if chars[idx:idx + 1] == b'C':
+                    result += 100
+                    idx += 1
+                else:
+                    break
+        if len(chars) == idx:
+            return RomanNumeral(result)
+
+        # Tens: 90 ("XC"), 40 ("XL"), 0-30 (0 to 3 "X" chars),
+        # or 50-80 ("L", followed by 0 to 3 "X" chars)
+        if chars[idx:idx + 2] == b'XC':
+            result += 90
+            idx += 2
+        elif chars[idx:idx + 2] == b'XL':
+            result += 40
+            idx += 2
+        else:
+            if chars[idx:idx + 1] == b'L':
+                result += 50
+                idx += 1
+            for _ in range(3):
+                if chars[idx:idx + 1] == b'X':
+                    result += 10
+                    idx += 1
+                else:
+                    break
+        if len(chars) == idx:
+            return RomanNumeral(result)
+
+        # Ones: 9 ("IX"), 4 ("IV"), 0-3 (0 to 3 "I" chars),
+        # or 5-8 ("V", followed by 0 to 3 "I" chars)
+        if chars[idx:idx + 2] == b'IX':
+            result += 9
+            idx += 2
+        elif chars[idx:idx + 2] == b'IV':
+            result += 4
+            idx += 2
+        else:
+            if chars[idx:idx + 1] == b'V':
+                result += 5
+                idx += 1
+            for _ in range(3):
+                if chars[idx:idx + 1] == b'I':
+                    result += 1
+                    idx += 1
+                else:
+                    break
+        if len(chars) == idx:
+            return RomanNumeral(result)
+        raise InvalidRomanNumeralError(string)
+
+
+_ROMAN_NUMERAL_PREFIXES: Final = [
+    (1000, sys.intern('M'), sys.intern('m')),
+    (900, sys.intern('CM'), sys.intern('cm')),
+    (500, sys.intern('D'), sys.intern('d')),
+    (400, sys.intern('CD'), sys.intern('cd')),
+    (100, sys.intern('C'), sys.intern('c')),
+    (90, sys.intern('XC'), sys.intern('xc')),
+    (50, sys.intern('L'), sys.intern('l')),
+    (40, sys.intern('XL'), sys.intern('xl')),
+    (10, sys.intern('X'), sys.intern('x')),
+    (9, sys.intern('IX'), sys.intern('ix')),
+    (5, sys.intern('V'), sys.intern('v')),
+    (4, sys.intern('IV'), sys.intern('iv')),
+    (1, sys.intern('I'), sys.intern('i')),
+]
+"""Numeral value, uppercase character, and lowercase character."""


Property changes on: trunk/docutils/docutils/utils/_roman_numerals.py
___________________________________________________________________
Added: svn:executable
## -0,0 +1 ##
+*
\ No newline at end of property
Added: trunk/docutils/test/test_utils/test__roman_numerals.py
===================================================================
--- trunk/docutils/test/test_utils/test__roman_numerals.py	                        (rev 0)
+++ trunk/docutils/test/test_utils/test__roman_numerals.py	2024-08-02 01:16:42 UTC (rev 9845)
@@ -0,0 +1,210 @@
+#! /usr/bin/env python3
+
+# $Id$
+# Author:  Adam Turner.
+# Copyright: This module is placed in the public domain
+#            or under the `Zero Clause BSD licence`_,
+#            whichever is more permissive.
+#
+# .. _Zero Clause BSD licence: https://opensource.org/license/0BSD
+
+"""
+Tests for `docutils.utils._roman_numerals`.
+"""
+
+from pathlib import Path
+import sys
+import unittest
+
+if __name__ == '__main__':
+    # prepend the "docutils root" to the Python library path
+    # so we import the local `docutils` package.
+    sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+from docutils.utils._roman_numerals import (
+    MIN,
+    MAX,
+    RomanNumeral,
+    OutOfRangeError,
+    InvalidRomanNumeralError,
+)
+
+
+class NewRomanNumeralTestCase(unittest.TestCase):
+    def test_zero(self) -> None:
+        with self.assertRaises(OutOfRangeError) as ctx:
+            RomanNumeral(0)
+        msg = str(ctx.exception)
+        self.assertEqual(
+            msg,
+            'Number out of range (must be between 1 and 4,999). Got 0.',
+        )
+
+    def test_one(self) -> None:
+        self.assertEqual(int(RomanNumeral(1)), 1)
+
+    def test_MIN(self) -> None:
+        self.assertEqual(int(RomanNumeral(MIN)), MIN)
+
+    def test_forty_two(self) -> None:
+        self.assertEqual(int(RomanNumeral(42)), 42)
+
+    def test_four_thousand_nine_hundred_and_ninety_nine(self) -> None:
+        self.assertEqual(int(RomanNumeral(4_999)), 4_999)
+
+    def test_MAX(self) -> None:
+        self.assertEqual(int(RomanNumeral(MAX)), MAX)
+
+    def test_five_thousand(self) -> None:
+        with self.assertRaises(OutOfRangeError) as ctx:
+            RomanNumeral(5_000)
+        msg = str(ctx.exception)
+        self.assertEqual(
+            msg,
+            'Number out of range (must be between 1 and 4,999). Got 5000.',
+        )
+
+    def test_minus_one(self) -> None:
+        with self.assertRaises(OutOfRangeError) as ctx:
+            RomanNumeral(-1)
+        msg = str(ctx.exception)
+        self.assertEqual(
+            msg,
+            'Number out of range (must be between 1 and 4,999). Got -1.',
+        )
+
+    def test_float(self) -> None:
+        with self.assertRaises(TypeError) as ctx:
+            RomanNumeral(4.2)
+        msg = str(ctx.exception)
+        self.assertEqual(
+            msg,
+            "RomanNumeral: an integer is required, not 'float'",
+        )
+
+
+class ToStringTestCase(unittest.TestCase):
+    def test_str(self):
+        test_numerals = [
+            'I', 'II', 'III', 'IV', 'V',
+            'VI', 'VII', 'VIII', 'IX', 'X',
+            'XI', 'XII', 'XIII', 'XIV', 'XV',
+            'XVI', 'XVII', 'XVIII', 'XIX', 'XX',
+            'XXI', 'XXII', 'XXIII', 'XXIV',
+        ]
+        for n, roman_str in enumerate(test_numerals, start=1):
+            with self.subTest(id=n, roman_str=roman_str):
+                num = RomanNumeral(n)
+                self.assertEqual(f'{num}', roman_str)
+
+    def test_uppercase(self):
+        test_numerals = [
+            'I', 'II', 'III', 'IV', 'V',
+            'VI', 'VII', 'VIII', 'IX', 'X',
+            'XI', 'XII', 'XIII', 'XIV', 'XV',
+            'XVI', 'XVII', 'XVIII', 'XIX', 'XX',
+            'XXI', 'XXII', 'XXIII', 'XXIV',
+        ]
+        for n, roman_str in enumerate(test_numerals, start=1):
+            with self.subTest(id=n, roman_str=roman_str):
+                num = RomanNumeral(n)
+                self.assertEqual(num.to_uppercase(), roman_str)
+
+    def test_lowercase(self):
+        test_numerals = [
+            'i', 'ii', 'iii', 'iv', 'v',
+            'vi', 'vii', 'viii', 'ix', 'x',
+            'xi', 'xii', 'xiii', 'xiv', 'xv',
+            'xvi', 'xvii', 'xviii', 'xix', 'xx',
+            'xxi', 'xxii', 'xxiii', 'xxiv',
+        ]
+        for n, roman_str in enumerate(test_numerals, start=1):
+            with self.subTest(id=n, roman_str=roman_str):
+                num = RomanNumeral(n)
+                self.assertEqual(num.to_lowercase(), roman_str)
+
+    def test_minitrue(self):
+        # IGNORANCE IS STRENGTH
+        num = RomanNumeral(1984)
+        self.assertEqual(f'{num}', 'MCMLXXXIV')
+        self.assertEqual(num.to_uppercase(), 'MCMLXXXIV')
+        self.assertEqual(num.to_lowercase(), 'mcmlxxxiv')
+
+
+class FromStringTestCase(unittest.TestCase):
+    def test_uppercase(self):
+        test_numerals = [
+            'I', 'II', 'III', 'IV', 'V',
+            'VI', 'VII', 'VIII', 'IX', 'X',
+            'XI', 'XII', 'XIII', 'XIV', 'XV',
+            'XVI', 'XVII', 'XVIII', 'XIX', 'XX',
+            'XXI', 'XXII', 'XXIII', 'XXIV',
+        ]
+        for n, roman_str in enumerate(test_numerals, start=1):
+            with self.subTest(id=n, roman_str=roman_str):
+                expected = RomanNumeral(n)
+                parsed = RomanNumeral.from_string(roman_str)
+                self.assertEqual(expected, parsed)
+
+    def test_lowercase(self):
+        test_numerals = [
+            'i', 'ii', 'iii', 'iv', 'v',
+            'vi', 'vii', 'viii', 'ix', 'x',
+            'xi', 'xii', 'xiii', 'xiv', 'xv',
+            'xvi', 'xvii', 'xviii', 'xix', 'xx',
+            'xxi', 'xxii', 'xxiii', 'xxiv',
+        ]
+        for n, roman_str in enumerate(test_numerals, start=1):
+            with self.subTest(id=n, roman_str=roman_str):
+                expected = RomanNumeral(n)
+                parsed = RomanNumeral.from_string(roman_str)
+                self.assertEqual(expected, parsed)
+
+    def test_special(self):
+        parsed = RomanNumeral.from_string('MDCCCXXIII')
+        self.assertEqual(RomanNumeral(1823), parsed)
+
+        parsed = RomanNumeral.from_string('mdcccxxiii')
+        self.assertEqual(RomanNumeral(1823), parsed)
+
+        parsed = RomanNumeral.from_string('MCMLXXXIV')
+        self.assertEqual(RomanNumeral(1984), parsed)
+
+        parsed = RomanNumeral.from_string('mcmlxxxiv')
+        self.assertEqual(RomanNumeral(1984), parsed)
+
+        parsed = RomanNumeral.from_string('MM')
+        self.assertEqual(RomanNumeral(2000), parsed)
+
+        parsed = RomanNumeral.from_string('mm')
+        self.assertEqual(RomanNumeral(2000), parsed)
+
+        parsed = RomanNumeral.from_string('MMMMCMXCIX')
+        self.assertEqual(RomanNumeral(4_999), parsed)
+
+        parsed = RomanNumeral.from_string('mmmmcmxcix')
+        self.assertEqual(RomanNumeral(4_999), parsed)
+
+    def test_invalid(self):
+        with self.assertRaises(InvalidRomanNumeralError) as ctx:
+            RomanNumeral.from_string('Not a Roman numeral!')
+        msg = str(ctx.exception)
+        self.assertEqual(msg, 'Invalid Roman numeral: Not a Roman numeral!')
+
+    def test_mixed_case(self):
+        with self.assertRaises(InvalidRomanNumeralError) as ctx:
+            RomanNumeral.from_string('McMlXxXiV')
+        msg = str(ctx.exception)
+        self.assertEqual(msg, 'Invalid Roman numeral: McMlXxXiV')
+
+
+class RoundTripTestCase(unittest.TestCase):
+    def test_round_trip(self):
+        for n in range(MIN, MAX + 1, 19):
+            num = RomanNumeral(n)
+            parsed = RomanNumeral.from_string(str(num))
+            self.assertEqual(num, parsed)
+
+
+if __name__ == '__main__':
+    unittest.main()

This was sent by the SourceForge.net collaborative development platform, the world's largest Open Source development site.



_______________________________________________
Docutils-checkins mailing list
[email protected]
https://lists.sourceforge.net/lists/listinfo/docutils-checkins
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.