From 06fc4cabb7242b8eee76a87eeb099c5a46deb73b Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Sat, 28 Mar 2026 17:48:22 -0400 Subject: [PATCH] Drop numeral dependency, inline Roman numeral helpers Replace the numeral package with ~30 lines of int2roman()/roman2int() in _statics.py. Reduces supply chain surface. Fixes #47. Co-Authored-By: Claude Opus 4.6 (1M context) --- pyproject.toml | 1 - pytheory/_statics.py | 37 +++++++++++++++++++++++++++++++++++++ pytheory/chords.py | 4 ++-- pytheory/scales.py | 12 ++++++------ uv.lock | 10 ---------- 5 files changed, 45 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c885dd1..fac7ce6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,6 @@ classifiers = [ "Programming Language :: Python :: 3.13", ] dependencies = [ - "numeral", "sounddevice", "scipy", ] diff --git a/pytheory/_statics.py b/pytheory/_statics.py index 0b820da..a6e0c40 100644 --- a/pytheory/_statics.py +++ b/pytheory/_statics.py @@ -2,6 +2,43 @@ import math REFERENCE_A = 440 +# ── Roman numeral helpers (replaces `numeral` package) ─────────────────── + +_ROMAN_MAP = [ + (1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), + (100, "C"), (90, "XC"), (50, "L"), (40, "XL"), + (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"), +] + +_ROMAN_VALUES = { + "I": 1, "V": 5, "X": 10, "L": 50, "C": 100, "D": 500, "M": 1000, +} + + +def int2roman(n: int) -> str: + """Convert an integer to an uppercase Roman numeral string.""" + result = [] + for value, numeral in _ROMAN_MAP: + while n >= value: + result.append(numeral) + n -= value + return "".join(result) + + +def roman2int(s: str) -> int: + """Convert a Roman numeral string (case-insensitive) to an integer.""" + s = s.upper() + total = 0 + prev = 0 + for ch in reversed(s): + val = _ROMAN_VALUES.get(ch, 0) + if val < prev: + total -= val + else: + total += val + prev = val + return total + # Index of C in the Western tone list (A=0, A#=1, B=2, C=3, ...). # Scientific pitch notation changes octave at C, not A, so this offset # is needed for all octave arithmetic. diff --git a/pytheory/chords.py b/pytheory/chords.py index 11564a8..ee934c5 100644 --- a/pytheory/chords.py +++ b/pytheory/chords.py @@ -849,7 +849,7 @@ class Chord: >>> Chord([D4, F4, A4]).analyze("C") 'ii' """ - import numeral as numeral_mod + from ._statics import int2roman from .scales import TonedScale from .systems import SYSTEMS from .tones import Tone @@ -874,7 +874,7 @@ class Chord: scale_names = [t.name for t in scale.tones[:-1]] def _build_numeral(root, quality, degree_idx, prefix=""): - numeral_str = numeral_mod.int2roman(degree_idx + 1, only_ascii=True) + numeral_str = int2roman(degree_idx + 1) suffix = "" if "minor" in quality: numeral_str = numeral_str.lower() diff --git a/pytheory/scales.py b/pytheory/scales.py index c47de49..5248ed7 100644 --- a/pytheory/scales.py +++ b/pytheory/scales.py @@ -2,8 +2,6 @@ from __future__ import annotations from typing import Optional, Union -import numeral - from .systems import SYSTEMS, System from .tones import Tone @@ -49,7 +47,8 @@ class Scale: def __repr__(self) -> str: r = [] for (i, tone) in enumerate(self.tones): - degree = numeral.int2roman(i + 1, only_ascii=True) + from ._statics import int2roman + degree = int2roman(i + 1) r += [f"{degree}={tone.full_name}"] r = " ".join(r) @@ -200,7 +199,7 @@ class Scale: >>> scale.progression("I", "IV", "V", "I") [, , , ] """ - import numeral as numeral_mod + from ._statics import roman2int chords = [] for num in numerals: is_seventh = num.endswith("7") @@ -213,7 +212,7 @@ class Scale: elif clean.startswith("#") and len(clean) > 1: clean = clean[1:] flat_offset = 1 # one semitone up - degree = numeral_mod.roman2int(clean.upper()) - 1 + degree = roman2int(clean.upper()) - 1 if is_seventh: chord = self.seventh(degree) else: @@ -406,7 +405,8 @@ class Scale: if isinstance(item, str): degrees = [] for (i, tone) in enumerate(self.tones): - degrees.append(numeral.int2roman(i + 1, only_ascii=True)) + from ._statics import int2roman + degrees.append(int2roman(i + 1)) if item in degrees: item = degrees.index(item) diff --git a/uv.lock b/uv.lock index 72bc081..aaac68f 100644 --- a/uv.lock +++ b/uv.lock @@ -486,14 +486,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/ac/686789b9145413f1a61878c407210e41bfdb097976864e0913078b24098c/myst_parser-5.0.0-py3-none-any.whl", hash = "sha256:ab31e516024918296e169139072b81592336f2fef55b8986aa31c9f04b5f7211", size = 84533, upload-time = "2026-01-15T09:08:16.788Z" }, ] -[[package]] -name = "numeral" -version = "0.1.0.17" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/17/0d/ac6a186e169fcbdfea316f78fb5e34981bcf8d5c1d7cc8b6581f597e1e4c/numeral-0.1.0.17-py2.py3-none-any.whl", hash = "sha256:7dff0c1efb9b3655c9c1dc93b4666993741b15abcac0dc01dcb96b21cc20f6ae", size = 22066, upload-time = "2020-04-12T08:24:59.129Z" }, -] - [[package]] name = "numpy" version = "2.2.6" @@ -701,7 +693,6 @@ name = "pytheory" version = "0.38.2" source = { editable = "." } dependencies = [ - { name = "numeral" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "sounddevice" }, @@ -721,7 +712,6 @@ docs = [ [package.metadata] requires-dist = [ - { name = "numeral" }, { name = "scipy" }, { name = "sounddevice" }, ]