|
From: <aa-...@us...> - 2024-08-02 01:16:45
|
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.
|