GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — RomanNumbers.range + asciify · daveoncode/python-string-utils@d07853b

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
  • string_utils
    • manipulation.py
  • tests
    • test_asciify.py
    • test_roman_numbers.py
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎string_utils/manipulation.py‎

Copy file name to clipboardExpand all lines: string_utils/manipulation.py
+95-33Lines changed: 95 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import base64
44
import random
55
import unicodedata
6-
from typing import Union
6+
from typing import Union, Generator
77
from uuid import uuid4
88

99
__all__ = [
@@ -13,6 +13,7 @@
1313
'shuffle',
1414
'strip_html',
1515
'prettify',
16+
'asciify',
1617
'slugify',
1718
'booleanize',
1819
'strip_margin',
@@ -209,11 +210,40 @@ def placeholder_key():
209210
return prettified
210211

211212

213+
def asciify(input_string: str) -> str:
214+
"""
215+
Force string content to be ascii-only by translating all non-ascii chars into the closest possible representation
216+
(eg: ó -> o, Ë -> E, ç -> c...).
217+
218+
Some chars may be lost if impossible to translate.
219+
220+
*Example:*
221+
222+
>>> asciify('èéùúòóäåëýñÅÀÁÇÌÍÑÓË') # returns 'eeuuooaaeynAAACIINOE'
223+
224+
:param input_string: String to convert
225+
:return: Ascii utf-8 string
226+
"""
227+
if not is_string(input_string):
228+
raise InvalidInputError(input_string)
229+
230+
# "NFKD" is the algorithm which is able to successfully translate the most of non-ascii chars
231+
normalized = unicodedata.normalize('NFKD', input_string)
232+
233+
# encode string forcing ascii and ignore any errors (unrepresentable chars will be stripped out)
234+
ascii_bytes = normalized.encode('ascii', 'ignore')
235+
236+
# turns encoded bytes into an utf-8 string
237+
ascii_string = ascii_bytes.decode('utf-8')
238+
239+
return ascii_string
240+
241+
212242
def slugify(input_string: str, separator: str = '-') -> str:
213243
"""
214244
Converts a string into a slug using provided separator.
215245
216-
Example:
246+
*Example:*
217247
218248
>>> slugify(' This Is A "Test"! ') # returns: "this-is-a-test"
219249
@@ -227,18 +257,18 @@ def slugify(input_string: str, separator: str = '-') -> str:
227257
raise InvalidInputError(input_string)
228258

229259
# replace any character that is NOT letter or number with spaces
230-
s = NO_LETTERS_OR_NUMBERS_RE.sub(' ', input_string.lower()).strip()
260+
out = NO_LETTERS_OR_NUMBERS_RE.sub(' ', input_string.lower()).strip()
231261

232262
# replace spaces with join sign
233-
s = SPACES_RE.sub(separator, s)
263+
out = SPACES_RE.sub(separator, out)
234264

235265
# normalize joins (remove duplicates)
236-
s = re.sub(re.escape(separator) + r'+', separator, s)
266+
out = re.sub(re.escape(separator) + r'+', separator, out)
237267

238268
# translate non-ascii signs
239-
s = unicodedata.normalize('NFD', s).encode('ascii', 'ignore').decode('utf-8')
269+
out = asciify(out)
240270

241-
return s
271+
return out
242272

243273

244274
def booleanize(input_string: str) -> bool:
@@ -251,7 +281,7 @@ def booleanize(input_string: str) -> bool:
251281
- "y"
252282
Otherwise False is returned.
253283
254-
Example:
284+
*Example:*
255285
256286
>>> booleanize('true') # returns True
257287
@@ -320,7 +350,7 @@ def compress(cls, input_string: str, encoding: str = 'utf-8', compression_level:
320350
"""
321351
cls.__require_valid_input_and_encoding(input_string, encoding)
322352

323-
if not is_integer(str(compression_level)) or compression_level < 0 or compression_level > 9:
353+
if not isinstance(compression_level, int) or compression_level < 0 or compression_level > 9:
324354
raise ValueError('Invalid compression_level: it must be an "int" between 0 and 9')
325355

326356
# turns input string into a sequence of bytes using provided encoding
@@ -384,25 +414,25 @@ class RomanNumbers:
384414
__reversed_mappings = [{v: k for k, v in m.items()} for m in __mappings]
385415

386416
@classmethod
387-
def __convert_digit(cls, index: int, value: int) -> str:
417+
def __encode_digit(cls, index: int, value: int) -> str:
388418
# if digit is zero, there is no sign to display
389419
if value == 0:
390420
return ''
391421

392-
if value < 9:
393-
# from 1 to 3 we have just to repeat the sign N times (eg: III, XXX...)
394-
if value <= 3:
395-
return cls.__mappings[index][1] * value
422+
# from 1 to 3 we have just to repeat the sign N times (eg: III, XXX...)
423+
if value <= 3:
424+
return cls.__mappings[index][1] * value
396425

397-
# if 4 we have to add unit prefix
398-
if value == 4:
399-
return cls.__mappings[index][1] + cls.__mappings[index][5]
426+
# if 4 we have to add unit prefix
427+
if value == 4:
428+
return cls.__mappings[index][1] + cls.__mappings[index][5]
400429

401-
# if is 5, is a straight map
402-
if value == 5:
403-
return cls.__mappings[index][5]
430+
# if is 5, is a straight map
431+
if value == 5:
432+
return cls.__mappings[index][5]
404433

405-
# if 6, 7 or 8 we have to append unit suffixes
434+
# if 6, 7 or 8 we have to append unit suffixes
435+
if value <= 8:
406436
suffix = cls.__mappings[index][1] * (value - 5)
407437
return cls.__mappings[index][5] + suffix
408438

@@ -444,21 +474,20 @@ def encode(cls, input_number: Union[str, int]) -> str:
444474
if value < 1 or value > 3999:
445475
raise ValueError('Input must be >= 1 and <= 3999')
446476

447-
# turns the given number into a sequence of integers (eg. from 1256 to [1, 2, 5, 6])
448-
int_tokens = [int(v) for v in input_string]
449-
450-
# flip tokens in order to start from units first, then go up to tens, hundreds and thousands
451-
reversed_int_tokens = list(reversed(int_tokens))
477+
input_len = len(input_string)
478+
output = ''
452479

453-
# for each token obtain the relative roman string representation
454-
converted_tokens = [cls.__convert_digit(i, v) for i, v in enumerate(reversed_int_tokens)]
480+
# decode digits from right to left (start from units to thousands)
481+
for index in range(input_len):
482+
# get actual digit value as int
483+
digit = int(input_string[input_len - index - 1])
455484

456-
# flip back tokens in order to have them placed correctly
457-
# (units on the right then tens, hundreds, thousands on the left)
458-
sorted_tokens = list(reversed(converted_tokens))
485+
# encode digit to roman string
486+
encoded_digit = cls.__encode_digit(index, digit)
459487

460-
# final output string
461-
output = ''.join(sorted_tokens)
488+
# prepend encoded value to the current output in order to have the final string sorted
489+
# from thousands to units
490+
output = encoded_digit + output
462491

463492
return output
464493

@@ -523,3 +552,36 @@ def decode(cls, input_string: str) -> int:
523552
last_value = sign_value
524553

525554
return output
555+
556+
@classmethod
557+
def range(cls, stop: int, start: int = 1, step: int = 1) -> Generator:
558+
"""
559+
Similarly to native Python's `range()`, returns a Generator object which generates a new roman number
560+
on each iteration instead of an integer.
561+
562+
*Example:*
563+
564+
>>> for n in RomanNumbers.range(7): print(n) # prints: I, II, III, IV, V, VI, VII
565+
566+
:param stop: Number at which the generation must stop (must be <= 3999).
567+
:param start: Number at which the generation must start (must be >= 1).
568+
:param step: Increment of each generation step (default to 1).
569+
:return: Generator of roman numbers.
570+
"""
571+
572+
def validate(arg_value, arg_name):
573+
if not isinstance(arg_value, int) or (arg_value < 1 or arg_value > 3999):
574+
raise ValueError('"{}" must be an integer in the range 1-3999'.format(arg_name))
575+
576+
def generate():
577+
current_step = start
578+
579+
while current_step < stop + 1:
580+
yield cls.encode(current_step)
581+
current_step += step
582+
583+
validate(stop, 'stop')
584+
validate(start, 'start')
585+
validate(step, 'step')
586+
587+
return generate()
Collapse file

‎tests/test_asciify.py‎

Copy file name to clipboard
+34Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from unittest import TestCase
2+
3+
from string_utils.errors import InvalidInputError
4+
from string_utils.manipulation import asciify
5+
6+
7+
class AsciifyTestCase(TestCase):
8+
def test_raise_exception_if_not_string(self):
9+
# noinspection PyTypeChecker
10+
self.assertRaises(InvalidInputError, lambda: asciify(None))
11+
12+
# noinspection PyTypeChecker
13+
self.assertRaises(InvalidInputError, lambda: asciify(1))
14+
15+
# noinspection PyTypeChecker
16+
self.assertRaises(InvalidInputError, lambda: asciify(True))
17+
18+
# noinspection PyTypeChecker
19+
self.assertRaises(InvalidInputError, lambda: asciify(['nope']))
20+
21+
def test_returns_same_string_if_ascii(self):
22+
self.assertEqual(asciify(''), '')
23+
self.assertEqual(asciify(' '), ' ')
24+
self.assertEqual(asciify('Hello World!'), 'Hello World!')
25+
self.assertEqual(asciify('-12.99'), '-12.99')
26+
self.assertEqual(asciify('<foo></foo>'), '<foo></foo>')
27+
28+
def test_returns_asciified_string(self):
29+
input_string = 'èéùúòóäåëýñÅÀÁÇÌÍÑÓË'
30+
ascii_string = asciify(input_string)
31+
32+
self.assertFalse(input_string.isascii())
33+
self.assertTrue(ascii_string.isascii())
34+
self.assertEqual('eeuuooaaeynAAACIINOE', ascii_string)
Collapse file

‎tests/test_roman_numbers.py‎

Copy file name to clipboardExpand all lines: tests/test_roman_numbers.py
+63Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,3 +355,66 @@ def test_decode_does_not_care_about_case(self):
355355
self.assertEqual(82, RomanNumbers.decode('lxxxii'))
356356
self.assertEqual(RomanNumbers.decode('VII'), RomanNumbers.decode('vii'))
357357
self.assertEqual(RomanNumbers.decode('VII'), RomanNumbers.decode('Vii'))
358+
359+
def test_range_raise_exception_if_stop_is_invalid(self):
360+
msg = '"stop" must be an integer in the range 1-3999'
361+
362+
with self.assertRaises(ValueError) as raised:
363+
RomanNumbers.range(-1)
364+
365+
self.assertEqual(str(raised.exception), msg)
366+
367+
with self.assertRaises(ValueError) as raised:
368+
RomanNumbers.range(0)
369+
370+
self.assertEqual(str(raised.exception), msg)
371+
372+
with self.assertRaises(ValueError) as raised:
373+
RomanNumbers.range(4000)
374+
375+
self.assertEqual(str(raised.exception), msg)
376+
377+
def test_range_returns_expected_generator_for_count_call(self):
378+
generator = RomanNumbers.range(10)
379+
380+
self.assertEqual(generator.__next__(), 'I')
381+
self.assertEqual(generator.__next__(), 'II')
382+
self.assertEqual(generator.__next__(), 'III')
383+
self.assertEqual(generator.__next__(), 'IV')
384+
self.assertEqual(generator.__next__(), 'V')
385+
self.assertEqual(generator.__next__(), 'VI')
386+
self.assertEqual(generator.__next__(), 'VII')
387+
self.assertEqual(generator.__next__(), 'VIII')
388+
self.assertEqual(generator.__next__(), 'IX')
389+
self.assertEqual(generator.__next__(), 'X')
390+
391+
# generator has been consumed, so another call will raise an exception
392+
self.assertRaises(StopIteration, generator.__next__)
393+
394+
def test_range_returns_expected_generator_for_start_stop_call(self):
395+
generator = RomanNumbers.range(start=10, stop=18)
396+
397+
self.assertEqual(generator.__next__(), 'X')
398+
self.assertEqual(generator.__next__(), 'XI')
399+
self.assertEqual(generator.__next__(), 'XII')
400+
self.assertEqual(generator.__next__(), 'XIII')
401+
self.assertEqual(generator.__next__(), 'XIV')
402+
self.assertEqual(generator.__next__(), 'XV')
403+
self.assertEqual(generator.__next__(), 'XVI')
404+
self.assertEqual(generator.__next__(), 'XVII')
405+
self.assertEqual(generator.__next__(), 'XVIII')
406+
407+
# generator has been consumed, so another call will raise an exception
408+
self.assertRaises(StopIteration, generator.__next__)
409+
410+
def test_range_returns_expected_generator_for_start_stop_step_call(self):
411+
generator = RomanNumbers.range(start=10, stop=30, step=5)
412+
413+
self.assertEqual(generator.__next__(), 'X')
414+
self.assertEqual(generator.__next__(), 'XV')
415+
self.assertEqual(generator.__next__(), 'XX')
416+
self.assertEqual(generator.__next__(), 'XXV')
417+
self.assertEqual(generator.__next__(), 'XXX')
418+
419+
# generator has been consumed, so another call will raise an exception
420+
self.assertRaises(StopIteration, generator.__next__)

0 commit comments

Comments
0 (0)
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.