From 99087cedda90790af046f56039ce9b28b05b95c4 Mon Sep 17 00:00:00 2001 From: atulyaatul1999 Date: Sat, 17 Jan 2026 18:37:48 +0530 Subject: [PATCH 1/2] Add Shona (sn) language support - Add lang_SN.py with full Shona number-to-words implementation - Support for cardinal numbers (0 to trillions) - Support for ordinal numbers with Shona suffixes - Support for floating point numbers - Support for year conversion - Currency support for ZWL (Zimbabwe Dollar) and USD - Register 'sn' language code in __init__.py - Add Shona to supported languages list in README.rst - Add comprehensive test suite (17 test cases) Shona (chiShona) is a Bantu language spoken by ~7 million people in Zimbabwe where it is an official language. --- README.rst | 1 + num2words/__init__.py | 6 +- num2words/lang_SN.py | 262 ++++++++++++++++++++++++++++++++++++++++++ tests/test_sn.py | 152 ++++++++++++++++++++++++ 4 files changed, 419 insertions(+), 2 deletions(-) create mode 100644 num2words/lang_SN.py create mode 100644 tests/test_sn.py diff --git a/README.rst b/README.rst index 2d8ea4e3..048eafb7 100644 --- a/README.rst +++ b/README.rst @@ -125,6 +125,7 @@ Besides the numerical argument, there are two main optional arguments, ``to:`` a * ``ru`` (Russian) * ``sl`` (Slovene) * ``sk`` (Slovak) +* ``sn`` (Shona) * ``sr`` (Serbian) * ``sv`` (Swedish) * ``te`` (Telugu) diff --git a/num2words/__init__.py b/num2words/__init__.py index b6969060..b11ce007 100644 --- a/num2words/__init__.py +++ b/num2words/__init__.py @@ -25,8 +25,9 @@ lang_HY, lang_ID, lang_IS, lang_IT, lang_JA, lang_KN, lang_KO, lang_KZ, lang_LT, lang_LV, lang_MN, lang_NL, lang_NO, lang_PL, lang_PT, lang_PT_BR, lang_RO, lang_RU, lang_SK, lang_SL, - lang_SR, lang_SV, lang_TE, lang_TET, lang_TG, lang_TH, lang_TR, - lang_UK, lang_VI, lang_ZH, lang_ZH_CN, lang_ZH_HK, lang_ZH_TW) + lang_SN, lang_SR, lang_SV, lang_TE, lang_TET, lang_TG, lang_TH, + lang_TR, lang_UK, lang_VI, lang_ZH, lang_ZH_CN, lang_ZH_HK, + lang_ZH_TW) CONVERTER_CLASSES = { 'am': lang_AM.Num2Word_AM(), @@ -79,6 +80,7 @@ 'ru': lang_RU.Num2Word_RU(), 'sk': lang_SK.Num2Word_SK(), 'sl': lang_SL.Num2Word_SL(), + 'sn': lang_SN.Num2Word_SN(), 'sr': lang_SR.Num2Word_SR(), 'sv': lang_SV.Num2Word_SV(), 'te': lang_TE.Num2Word_TE(), diff --git a/num2words/lang_SN.py b/num2words/lang_SN.py new file mode 100644 index 00000000..bc2dc8b6 --- /dev/null +++ b/num2words/lang_SN.py @@ -0,0 +1,262 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2003, Taro Ogawa. All Rights Reserved. +# Copyright (c) 2013, Savoir-faire Linux inc. All Rights Reserved. + +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, +# MA 02110-1301 USA + +""" +Shona (chiShona) number to words conversion. + +Shona is a Bantu language spoken by approximately 7 million people, +primarily in Zimbabwe where it is an official language alongside +English and Ndebele. +""" + +from __future__ import division, print_function, unicode_literals + +from .base import Num2Word_Base + + +class Num2Word_SN(Num2Word_Base): + """Convert numbers to Shona words.""" + + CURRENCY_FORMS = { + 'ZWL': (('dora', 'madora'), ('sendi', 'masendi')), + 'USD': (('dora', 'madora'), ('sendi', 'masendi')), + } + + def setup(self): + super(Num2Word_SN, self).setup() + + self.negword = "mainasi " + self.pointword = "poindi" + self.exclude_title = ["ne", "poindi", "mainasi"] + + # Units 0-9 + self.ones = [ + "chiposhi", # 0 + "motsi", # 1 + "piri", # 2 + "tatu", # 3 + "ina", # 4 + "shanu", # 5 + "tanhatu", # 6 + "nomwe", # 7 + "sere", # 8 + "pfumbamwe", # 9 + ] + + # 10-19 + self.teens = [ + "gumi", # 10 + "gumi nemotsi", # 11 + "gumi nepiri", # 12 + "gumi netatu", # 13 + "gumi neina", # 14 + "gumi neshanu", # 15 + "gumi netanhatu", # 16 + "gumi nenomwe", # 17 + "gumi nesere", # 18 + "gumi nepfumbamwe", # 19 + ] + + # Tens 20-90 + self.tens = [ + "", # 0 (placeholder) + "gumi", # 10 + "makumi maviri", # 20 + "makumi matatu", # 30 + "makumi mana", # 40 + "makumi mashanu", # 50 + "makumi matanhatu", # 60 + "makumi manomwe", # 70 + "makumi masere", # 80 + "makumi mapfumbamwe", # 90 + ] + + # Ordinal mappings + self.ords = { + "motsi": "wokutanga", + "piri": "wechipiri", + "tatu": "wechitatu", + "ina": "wechina", + "shanu": "wechishanu", + "tanhatu": "wechitanhatu", + "nomwe": "wechinomwe", + "sere": "wechisere", + "pfumbamwe": "wechipfumbamwe", + "gumi": "wechigumi", + } + + self.MAXVAL = 10 ** 15 # Up to trillions + + def _int_to_word(self, n, level=0): + """ + Convert an integer to its Shona word representation. + + Args: + n: Integer to convert + level: Recursion level for handling larger numbers + + Returns: + String representation of the number in Shona + """ + if n < 0: + return self.negword.strip() + " " + self._int_to_word(-n, level) + + if n == 0: + return self.ones[0] if level == 0 else "" + + if n < 10: + return self.ones[n] + + if n < 20: + return self.teens[n - 10] + + if n < 100: + tens_digit = n // 10 + ones_digit = n % 10 + if ones_digit == 0: + return self.tens[tens_digit] + else: + return self.tens[tens_digit] + " ne" + self.ones[ones_digit] + + if n < 1000: + hundreds = n // 100 + remainder = n % 100 + if hundreds == 1: + result = "zana" + else: + result = "mazana " + self.ones[hundreds] + if remainder: + result += " ne" + self._int_to_word(remainder, level + 1) + return result + + if n < 1000000: + thousands = n // 1000 + remainder = n % 1000 + if thousands == 1: + result = "chiuru" + else: + result = "zviuru " + self._int_to_word(thousands, level + 1) + if remainder: + result += " ne" + self._int_to_word(remainder, level + 1) + return result + + if n < 1000000000: + millions = n // 1000000 + remainder = n % 1000000 + if millions == 1: + result = "miriyoni" + else: + result = "mamiriyoni " + self._int_to_word(millions, level + 1) + if remainder: + result += " ne" + self._int_to_word(remainder, level + 1) + return result + + if n < 1000000000000: + billions = n // 1000000000 + remainder = n % 1000000000 + if billions == 1: + result = "bhiriyoni" + else: + result = "mabhiriyoni " + self._int_to_word(billions, level + 1) + if remainder: + result += " ne" + self._int_to_word(remainder, level + 1) + return result + + if n < 1000000000000000: + trillions = n // 1000000000000 + remainder = n % 1000000000000 + if trillions == 1: + result = "tiriyoni" + else: + result = "matiriyoni " + self._int_to_word(trillions, level + 1) + if remainder: + result += " ne" + self._int_to_word(remainder, level + 1) + return result + + raise OverflowError(self.errmsg_toobig % (n, self.MAXVAL)) + + def to_cardinal(self, value): + """Convert a number to its cardinal representation in Shona.""" + try: + assert int(value) == value + except (ValueError, TypeError, AssertionError): + return self.to_cardinal_float(value) + + return self.title(self._int_to_word(int(value))) + + def to_cardinal_float(self, value): + """Convert a float to its Shona word representation.""" + try: + float(value) == value + except (ValueError, TypeError, AssertionError, AttributeError): + raise TypeError(self.errmsg_nonnum % value) + + pre, post = self.float2tuple(float(value)) + post = str(post) + post = '0' * (self.precision - len(post)) + post + + out = [self.to_cardinal(pre)] + if value < 0 and pre == 0: + out = [self.negword.strip()] + out + + if self.precision: + out.append(self.title(self.pointword)) + + for i in range(self.precision): + curr = int(post[i]) + out.append(self.to_cardinal(curr)) + + return " ".join(out) + + def to_ordinal(self, value): + """Convert a number to its ordinal representation in Shona.""" + self.verify_ordinal(value) + cardinal = self.to_cardinal(value) + + # Split the cardinal and get the last word + words = cardinal.split() + last_word = words[-1] + + # Check if we have a special ordinal form + if last_word in self.ords: + words[-1] = self.ords[last_word] + else: + # Default ordinal suffix + words[-1] = "wechi" + last_word + + return " ".join(words) + + def to_ordinal_num(self, value): + """Convert a number to ordinal number format (e.g., 1st, 2nd).""" + self.verify_ordinal(value) + # In Shona, we append a suffix indicator + return "%s%s" % (value, self.to_ordinal(value)[-2:]) + + def pluralize(self, n, forms): + """Return singular or plural form based on count.""" + if n == 1: + return forms[0] + return forms[1] + + def to_year(self, val, suffix=None, longval=True): + """Convert a year number to words.""" + if val < 0: + val = abs(val) + suffix = 'BC' if not suffix else suffix + + return self.to_cardinal(val) if not suffix else \ + "%s %s" % (self.to_cardinal(val), suffix) diff --git a/tests/test_sn.py b/tests/test_sn.py new file mode 100644 index 00000000..286d81ca --- /dev/null +++ b/tests/test_sn.py @@ -0,0 +1,152 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2003, Taro Ogawa. All Rights Reserved. +# Copyright (c) 2013, Savoir-faire Linux inc. All Rights Reserved. + +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, +# MA 02110-1301 USA + +from unittest import TestCase + +from num2words import num2words + + +class Num2WordsSNTest(TestCase): + """Test cases for Shona language (sn).""" + + def test_cardinal_zero(self): + self.assertEqual(num2words(0, lang='sn'), 'chiposhi') + + def test_cardinal_ones(self): + self.assertEqual(num2words(1, lang='sn'), 'motsi') + self.assertEqual(num2words(2, lang='sn'), 'piri') + self.assertEqual(num2words(3, lang='sn'), 'tatu') + self.assertEqual(num2words(4, lang='sn'), 'ina') + self.assertEqual(num2words(5, lang='sn'), 'shanu') + self.assertEqual(num2words(6, lang='sn'), 'tanhatu') + self.assertEqual(num2words(7, lang='sn'), 'nomwe') + self.assertEqual(num2words(8, lang='sn'), 'sere') + self.assertEqual(num2words(9, lang='sn'), 'pfumbamwe') + + def test_cardinal_teens(self): + self.assertEqual(num2words(10, lang='sn'), 'gumi') + self.assertEqual(num2words(11, lang='sn'), 'gumi nemotsi') + self.assertEqual(num2words(12, lang='sn'), 'gumi nepiri') + self.assertEqual(num2words(13, lang='sn'), 'gumi netatu') + self.assertEqual(num2words(14, lang='sn'), 'gumi neina') + self.assertEqual(num2words(15, lang='sn'), 'gumi neshanu') + self.assertEqual(num2words(16, lang='sn'), 'gumi netanhatu') + self.assertEqual(num2words(17, lang='sn'), 'gumi nenomwe') + self.assertEqual(num2words(18, lang='sn'), 'gumi nesere') + self.assertEqual(num2words(19, lang='sn'), 'gumi nepfumbamwe') + + def test_cardinal_tens(self): + self.assertEqual(num2words(20, lang='sn'), 'makumi maviri') + self.assertEqual(num2words(30, lang='sn'), 'makumi matatu') + self.assertEqual(num2words(40, lang='sn'), 'makumi mana') + self.assertEqual(num2words(50, lang='sn'), 'makumi mashanu') + self.assertEqual(num2words(60, lang='sn'), 'makumi matanhatu') + self.assertEqual(num2words(70, lang='sn'), 'makumi manomwe') + self.assertEqual(num2words(80, lang='sn'), 'makumi masere') + self.assertEqual(num2words(90, lang='sn'), 'makumi mapfumbamwe') + + def test_cardinal_compound(self): + self.assertEqual(num2words(21, lang='sn'), 'makumi maviri nemotsi') + self.assertEqual(num2words(35, lang='sn'), 'makumi matatu neshanu') + self.assertEqual(num2words(42, lang='sn'), 'makumi mana nepiri') + self.assertEqual(num2words(99, lang='sn'), + 'makumi mapfumbamwe nepfumbamwe') + + def test_cardinal_hundreds(self): + self.assertEqual(num2words(100, lang='sn'), 'zana') + self.assertEqual(num2words(101, lang='sn'), 'zana nemotsi') + self.assertEqual(num2words(110, lang='sn'), 'zana negumi') + self.assertEqual(num2words(123, lang='sn'), + 'zana nemakumi maviri netatu') + self.assertEqual(num2words(200, lang='sn'), 'mazana piri') + self.assertEqual(num2words(500, lang='sn'), 'mazana shanu') + self.assertEqual(num2words(999, lang='sn'), + 'mazana pfumbamwe nemakumi mapfumbamwe nepfumbamwe') + + def test_cardinal_thousands(self): + self.assertEqual(num2words(1000, lang='sn'), 'chiuru') + self.assertEqual(num2words(1001, lang='sn'), 'chiuru nemotsi') + self.assertEqual(num2words(1100, lang='sn'), 'chiuru nezana') + self.assertEqual(num2words(2000, lang='sn'), 'zviuru piri') + self.assertEqual(num2words(5000, lang='sn'), 'zviuru shanu') + self.assertEqual(num2words(10000, lang='sn'), 'zviuru gumi') + self.assertEqual(num2words(12345, lang='sn'), + 'zviuru gumi nepiri nemazana tatu ' + 'nemakumi mana neshanu') + + def test_cardinal_millions(self): + self.assertEqual(num2words(1000000, lang='sn'), 'miriyoni') + self.assertEqual(num2words(2000000, lang='sn'), 'mamiriyoni piri') + self.assertEqual(num2words(1234567, lang='sn'), + 'miriyoni nezviuru mazana piri nemakumi matatu neina ' + 'nemazana shanu nemakumi matanhatu nenomwe') + + def test_cardinal_billions(self): + self.assertEqual(num2words(1000000000, lang='sn'), 'bhiriyoni') + self.assertEqual(num2words(2000000000, lang='sn'), 'mabhiriyoni piri') + + def test_ordinal_basic(self): + self.assertEqual(num2words(1, lang='sn', to='ordinal'), 'wokutanga') + self.assertEqual(num2words(2, lang='sn', to='ordinal'), 'wechipiri') + self.assertEqual(num2words(3, lang='sn', to='ordinal'), 'wechitatu') + self.assertEqual(num2words(4, lang='sn', to='ordinal'), 'wechina') + self.assertEqual(num2words(5, lang='sn', to='ordinal'), 'wechishanu') + self.assertEqual(num2words(10, lang='sn', to='ordinal'), 'wechigumi') + + def test_ordinal_compound(self): + # For compound numbers, the last word gets the ordinal suffix + self.assertEqual(num2words(21, lang='sn', to='ordinal'), + 'makumi maviri wechinemotsi') + self.assertEqual(num2words(100, lang='sn', to='ordinal'), 'wechizana') + + def test_ordinal_num(self): + self.assertEqual(num2words(1, lang='sn', to='ordinal_num'), '1ga') + self.assertEqual(num2words(2, lang='sn', to='ordinal_num'), '2ri') + self.assertEqual(num2words(10, lang='sn', to='ordinal_num'), '10mi') + + def test_cardinal_float(self): + self.assertEqual(num2words(12.5, lang='sn'), + 'gumi nepiri poindi shanu') + self.assertEqual(num2words(0.75, lang='sn'), + 'chiposhi poindi nomwe shanu') + self.assertEqual(num2words(123.45, lang='sn'), + 'zana nemakumi maviri netatu poindi ina shanu') + + def test_negative(self): + self.assertEqual(num2words(-1, lang='sn'), 'mainasi motsi') + self.assertEqual(num2words(-42, lang='sn'), + 'mainasi makumi mana nepiri') + + def test_to_currency(self): + self.assertEqual( + num2words('1.50', lang='sn', to='currency', currency='ZWL'), + 'motsi dora, makumi mashanu masendi' + ) + self.assertEqual( + num2words('10.00', lang='sn', to='currency', currency='ZWL'), + 'gumi madora, chiposhi masendi' + ) + + def test_to_year(self): + self.assertEqual(num2words(2024, lang='sn', to='year'), + 'zviuru piri nemakumi maviri neina') + self.assertEqual(num2words(1990, lang='sn', to='year'), + 'chiuru nemazana pfumbamwe nemakumi mapfumbamwe') + + def test_overflow(self): + with self.assertRaises(OverflowError): + num2words(10 ** 16, lang='sn') From b3b2bc8c207fdfd3b0dd3539bed39bebb0836f0d Mon Sep 17 00:00:00 2001 From: atulyaatul1999 Date: Sat, 17 Jan 2026 18:41:36 +0530 Subject: [PATCH 2/2] refactor: Explicitly convert input to float once and streamline exception handling in `to_cardinal_float`. --- num2words/lang_SN.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/num2words/lang_SN.py b/num2words/lang_SN.py index bc2dc8b6..075cf7f3 100644 --- a/num2words/lang_SN.py +++ b/num2words/lang_SN.py @@ -201,11 +201,11 @@ def to_cardinal(self, value): def to_cardinal_float(self, value): """Convert a float to its Shona word representation.""" try: - float(value) == value - except (ValueError, TypeError, AssertionError, AttributeError): + value = float(value) + except (ValueError, TypeError, AttributeError): raise TypeError(self.errmsg_nonnum % value) - pre, post = self.float2tuple(float(value)) + pre, post = self.float2tuple(value) post = str(post) post = '0' * (self.precision - len(post)) + post