|
3 | 3 | import base64 |
4 | 4 | import random |
5 | 5 | import unicodedata |
6 | | -from typing import Union |
| 6 | +from typing import Union, Generator |
7 | 7 | from uuid import uuid4 |
8 | 8 |
|
9 | 9 | __all__ = [ |
|
13 | 13 | 'shuffle', |
14 | 14 | 'strip_html', |
15 | 15 | 'prettify', |
| 16 | + 'asciify', |
16 | 17 | 'slugify', |
17 | 18 | 'booleanize', |
18 | 19 | 'strip_margin', |
@@ -209,11 +210,40 @@ def placeholder_key(): |
209 | 210 | return prettified |
210 | 211 |
|
211 | 212 |
|
| 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 | + |
212 | 242 | def slugify(input_string: str, separator: str = '-') -> str: |
213 | 243 | """ |
214 | 244 | Converts a string into a slug using provided separator. |
215 | 245 |
|
216 | | - Example: |
| 246 | + *Example:* |
217 | 247 |
|
218 | 248 | >>> slugify(' This Is A "Test"! ') # returns: "this-is-a-test" |
219 | 249 |
|
@@ -227,18 +257,18 @@ def slugify(input_string: str, separator: str = '-') -> str: |
227 | 257 | raise InvalidInputError(input_string) |
228 | 258 |
|
229 | 259 | # 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() |
231 | 261 |
|
232 | 262 | # replace spaces with join sign |
233 | | - s = SPACES_RE.sub(separator, s) |
| 263 | + out = SPACES_RE.sub(separator, out) |
234 | 264 |
|
235 | 265 | # normalize joins (remove duplicates) |
236 | | - s = re.sub(re.escape(separator) + r'+', separator, s) |
| 266 | + out = re.sub(re.escape(separator) + r'+', separator, out) |
237 | 267 |
|
238 | 268 | # translate non-ascii signs |
239 | | - s = unicodedata.normalize('NFD', s).encode('ascii', 'ignore').decode('utf-8') |
| 269 | + out = asciify(out) |
240 | 270 |
|
241 | | - return s |
| 271 | + return out |
242 | 272 |
|
243 | 273 |
|
244 | 274 | def booleanize(input_string: str) -> bool: |
@@ -251,7 +281,7 @@ def booleanize(input_string: str) -> bool: |
251 | 281 | - "y" |
252 | 282 | Otherwise False is returned. |
253 | 283 |
|
254 | | - Example: |
| 284 | + *Example:* |
255 | 285 |
|
256 | 286 | >>> booleanize('true') # returns True |
257 | 287 |
|
@@ -320,7 +350,7 @@ def compress(cls, input_string: str, encoding: str = 'utf-8', compression_level: |
320 | 350 | """ |
321 | 351 | cls.__require_valid_input_and_encoding(input_string, encoding) |
322 | 352 |
|
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: |
324 | 354 | raise ValueError('Invalid compression_level: it must be an "int" between 0 and 9') |
325 | 355 |
|
326 | 356 | # turns input string into a sequence of bytes using provided encoding |
@@ -384,25 +414,25 @@ class RomanNumbers: |
384 | 414 | __reversed_mappings = [{v: k for k, v in m.items()} for m in __mappings] |
385 | 415 |
|
386 | 416 | @classmethod |
387 | | - def __convert_digit(cls, index: int, value: int) -> str: |
| 417 | + def __encode_digit(cls, index: int, value: int) -> str: |
388 | 418 | # if digit is zero, there is no sign to display |
389 | 419 | if value == 0: |
390 | 420 | return '' |
391 | 421 |
|
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 |
396 | 425 |
|
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] |
400 | 429 |
|
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] |
404 | 433 |
|
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: |
406 | 436 | suffix = cls.__mappings[index][1] * (value - 5) |
407 | 437 | return cls.__mappings[index][5] + suffix |
408 | 438 |
|
@@ -444,21 +474,20 @@ def encode(cls, input_number: Union[str, int]) -> str: |
444 | 474 | if value < 1 or value > 3999: |
445 | 475 | raise ValueError('Input must be >= 1 and <= 3999') |
446 | 476 |
|
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 = '' |
452 | 479 |
|
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]) |
455 | 484 |
|
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) |
459 | 487 |
|
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 |
462 | 491 |
|
463 | 492 | return output |
464 | 493 |
|
@@ -523,3 +552,36 @@ def decode(cls, input_string: str) -> int: |
523 | 552 | last_value = sign_value |
524 | 553 |
|
525 | 554 | 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() |