feat: referentiel projets dynamique + validation nommage by design

Durcit la convention de nommage des projets (dérive constatée : 'Sliding
Automation', 'code_versioning'... au lieu des formes canoniques).

- trilium_api.py : projets_canoniques() lit le référentiel = valeurs du label
  projet sur les notes de type=projet (source unique, pas de constante en dur).
  Note-projet CodeVersioning créée (manquait).
- mcp_server.py : _valider_projet() branché dans les 6 tools de création
  (add_decision/history/backlog, new_conversation, create_entite, add_skill).
  Refuse un projet non canonique (suggestion si faute) ou inconnu (renvoi au
  processus de création de projet). Ne verrouille pas si référentiel illisible.
- lint_audit.py : VAL-nommage aligné sur le référentiel (attrape casse, espace
  ET snake_case ; l'ancien 'contient un espace' ratait code_versioning).
- Données : 79 notes ré-étiquetées vers les 3 formes canoniques.

Quality by design : l'erreur de nommage devient impossible à l'écriture, le
Lint n'est plus que le filet de sécurité.
This commit is contained in:
2026-07-17 14:59:33 +02:00
parent d1e67431b6
commit 943acbc573
2425 changed files with 710525 additions and 6 deletions
@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
from natsort.natsort import (
NatsortKeyType,
OSSortKeyType,
as_ascii,
as_utf8,
decoder,
humansorted,
index_humansorted,
index_natsorted,
index_realsorted,
natsort_key,
natsort_keygen,
natsorted,
numeric_regex_chooser,
order_by_index,
os_sort_key,
os_sort_keygen,
os_sorted,
realsorted,
)
from natsort.ns_enum import NSType, ns
from natsort.utils import KeyType, NatsortInType, NatsortOutType, chain_functions
__version__ = "8.4.0"
__all__ = [
"natsort_key",
"natsort_keygen",
"natsorted",
"humansorted",
"realsorted",
"index_natsorted",
"index_humansorted",
"index_realsorted",
"order_by_index",
"decoder",
"as_ascii",
"as_utf8",
"ns",
"chain_functions",
"numeric_regex_chooser",
"os_sort_key",
"os_sort_keygen",
"os_sorted",
"NatsortKeyType",
"OSSortKeyType",
"KeyType",
"NatsortInType",
"NatsortOutType",
"NSType",
]
# Add the ns keys to this namespace for convenience.
globals().update({name: value for name, value in ns.__members__.items()})
@@ -0,0 +1,363 @@
# -*- coding: utf-8 -*-
import argparse
import sys
from typing import Callable, Iterable, List, Optional, Pattern, Tuple, Union, cast
import natsort
from natsort.utils import regex_chooser
Num = Union[float, int]
NumIter = Iterable[Num]
NumPair = Tuple[Num, Num]
NumPairIter = Iterable[NumPair]
NumConverter = Callable[[str], Num]
class TypedArgs(argparse.Namespace):
paths: bool
filter: Optional[List[NumPair]]
reverse_filter: Optional[List[NumPair]]
exclude: List[Num]
reverse: bool
number_type: str
nosign: bool
sign: bool
noexp: bool
locale: bool
entries: List[str]
def __init__(
self,
filter: Optional[List[NumPair]] = None,
reverse_filter: Optional[List[NumPair]] = None,
exclude: Optional[List[Num]] = None,
paths: bool = False,
reverse: bool = False,
) -> None:
"""Used by testing only"""
self.filter = filter
self.reverse_filter = reverse_filter
self.exclude = [] if exclude is None else exclude
self.paths = paths
self.reverse = reverse
self.number_type = "int"
self.signed = False
self.exp = True
self.locale = False
def main(*arguments: str) -> None:
"""
Performs a natural sort on entries given on the command-line.
Arguments are read from sys.argv.
"""
from argparse import ArgumentParser, RawDescriptionHelpFormatter
from textwrap import dedent
parser = ArgumentParser(
description=dedent(cast(str, main.__doc__)),
formatter_class=RawDescriptionHelpFormatter,
)
parser.add_argument(
"--version",
action="version",
version="%(prog)s {}".format(natsort.__version__),
)
parser.add_argument(
"-p",
"--paths",
default=False,
action="store_true",
help="Interpret the input as file paths. This is not "
"strictly necessary to sort all file paths, but in cases "
'where there are OS-generated file paths like "Folder/" '
'and "Folder (1)/", this option is needed to make the '
'paths sorted in the order you expect ("Folder/" before '
'"Folder (1)/").',
)
parser.add_argument(
"-f",
"--filter",
nargs=2,
type=float,
metavar=("LOW", "HIGH"),
action="append",
help="Used for keeping only the entries that have a number "
"falling in the given range.",
)
parser.add_argument(
"-F",
"--reverse-filter",
nargs=2,
type=float,
metavar=("LOW", "HIGH"),
action="append",
dest="reverse_filter",
help="Used for excluding the entries that have a number "
"falling in the given range.",
)
parser.add_argument(
"-e",
"--exclude",
type=float,
action="append",
help="Used to exclude an entry that contains a specific number.",
)
parser.add_argument(
"-r",
"--reverse",
action="store_true",
default=False,
help="Returns in reversed order.",
)
parser.add_argument(
"-t",
"--number-type",
"--number_type",
dest="number_type",
choices=("int", "float", "real", "f", "i", "r"),
default="int",
help='Choose the type of number to search for. "float" will search '
'for floating-point numbers. "int" will only search for '
'integers. "real" is a shortcut for "float" with --sign. '
'"i" is a synonym for "int", "f" is a synonym for '
'"float", and "r" is a synonym for "real".'
"The default is %(default)s.",
)
parser.add_argument(
"--nosign",
default=False,
action="store_false",
dest="signed",
help='Do not consider "+" or "-" as part of a number, i.e. do not '
"take sign into consideration. This is the default.",
)
parser.add_argument(
"-s",
"--sign",
default=False,
action="store_true",
dest="signed",
help='Consider "+" or "-" as part of a number, i.e. '
"take sign into consideration. The default is unsigned.",
)
parser.add_argument(
"--noexp",
default=True,
action="store_false",
dest="exp",
help="Do not consider an exponential as part of a number, i.e. 1e4, "
'would be considered as 1, "e", and 4, not as 10000. This only '
"effects the --number-type=float.",
)
parser.add_argument(
"-l",
"--locale",
action="store_true",
default=False,
help="Causes natsort to use locale-aware sorting. You will get the "
"best results if you install PyICU.",
)
parser.add_argument(
"entries",
nargs="*",
default=sys.stdin,
help="The entries to sort. Taken from stdin if nothing is given on "
"the command line.",
)
args = parser.parse_args(arguments or None, namespace=TypedArgs())
# Make sure the filter range is given properly. Does nothing if no filter
args.filter = check_filters(args.filter)
args.reverse_filter = check_filters(args.reverse_filter)
# Remove trailing whitespace from all the entries
entries = [e.strip() for e in args.entries]
# Sort by directory then by file within directory and print.
sort_and_print_entries(entries, args)
def range_check(low: Num, high: Num) -> NumPair:
"""
Verify that that given range has a low lower than the high.
Parameters
----------
low : {float, int}
high : {float, int}
Returns
-------
tuple : low, high
Raises
------
ValueError
Low is greater than or equal to high.
"""
if low >= high:
raise ValueError("low >= high")
else:
return low, high
def check_filters(filters: Optional[NumPairIter]) -> Optional[List[NumPair]]:
"""
Execute range_check for every element of an iterable.
Parameters
----------
filters : iterable
The collection of filters to check. Each element
must be a two-element tuple of floats or ints.
Returns
-------
The input as-is, or None if it evaluates to False.
Raises
------
ValueError
Low is greater than or equal to high for any element.
"""
if not filters:
return None
try:
return [range_check(f[0], f[1]) for f in filters]
except ValueError as err:
raise ValueError("Error in --filter: " + str(err))
def keep_entry_range(
entry: str,
lows: NumIter,
highs: NumIter,
converter: NumConverter,
regex: Pattern[str],
) -> bool:
"""
Check if an entry falls into a desired range.
Every number in the entry will be extracted using *regex*,
if any are within a given low to high range the entry will
be kept.
Parameters
----------
entry : str
lows : iterable
Collection of low values against which to compare the entry.
highs : iterable
Collection of high values against which to compare the entry.
converter : callable
Function to convert a string to a number.
regex : regex object
Regular expression to locate numbers in a string.
Returns
-------
True if the entry should be kept, False otherwise.
"""
return any(
low <= converter(num) <= high
for num in regex.findall(entry)
for low, high in zip(lows, highs)
)
def keep_entry_value(
entry: str, values: NumIter, converter: NumConverter, regex: Pattern[str]
) -> bool:
"""
Check if an entry does not match a given value.
Every number in the entry will be extracted using *regex*,
if any match a given value the entry will not be kept.
Parameters
----------
entry : str
values : iterable
Collection of values against which to compare the entry.
converter : callable
Function to convert a string to a number.
regex : regex object
Regular expression to locate numbers in a string.
Returns
-------
True if the entry should be kept, False otherwise.
"""
return not any(converter(num) in values for num in regex.findall(entry))
def sort_and_print_entries(entries: List[str], args: TypedArgs) -> None:
"""Sort the entries, applying the filters first if necessary."""
# Extract the proper number type.
is_float = args.number_type in ("float", "real", "f", "r")
signed = args.signed or args.number_type in ("real", "r")
alg: int = (
natsort.ns.FLOAT * is_float
| natsort.ns.SIGNED * signed
| natsort.ns.NOEXP * (not args.exp)
| natsort.ns.PATH * args.paths
| natsort.ns.LOCALE * args.locale
)
# Pre-remove entries that don't pass the filtering criteria
# Make sure we use the same searching algorithm for filtering
# as for sorting.
do_filter = args.filter is not None or args.reverse_filter is not None
if do_filter or args.exclude:
inp_options = (
natsort.ns.FLOAT * is_float
| natsort.ns.SIGNED * signed
| natsort.ns.NOEXP * (not args.exp)
)
regex = regex_chooser(inp_options)
if args.filter is not None:
lows, highs = ([f[0] for f in args.filter], [f[1] for f in args.filter])
entries = [
entry
for entry in entries
if keep_entry_range(entry, lows, highs, float, regex)
]
if args.reverse_filter is not None:
lows, highs = (
[f[0] for f in args.reverse_filter],
[f[1] for f in args.reverse_filter],
)
entries = [
entry
for entry in entries
if not keep_entry_range(entry, lows, highs, float, regex)
]
if args.exclude:
exclude = set(args.exclude)
entries = [
entry
for entry in entries
if keep_entry_value(entry, exclude, float, regex)
]
# Print off the sorted results
for entry in natsort.natsorted(entries, reverse=args.reverse, alg=alg):
print(entry)
if __name__ == "__main__":
try:
main()
except ValueError as a:
sys.exit(str(a))
except KeyboardInterrupt:
sys.exit(1)
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
"""
This module is intended to replicate some of the functionality
from the fastnumbers module in the event that module is not installed.
"""
import unicodedata
from typing import Callable, FrozenSet, Union
from natsort.unicode_numbers import decimal_chars
_NAN_INF = [
"INF",
"INf",
"Inf",
"inF",
"iNF",
"InF",
"inf",
"iNf",
"NAN",
"nan",
"NaN",
"nAn",
"naN",
"NAn",
"nAN",
"Nan",
]
_NAN_INF.extend(["+" + x[:2] for x in _NAN_INF] + ["-" + x[:2] for x in _NAN_INF])
NAN_INF = frozenset(_NAN_INF)
ASCII_NUMS = "0123456789+-"
POTENTIAL_FIRST_CHAR = frozenset(decimal_chars + list(ASCII_NUMS + "."))
StrOrFloat = Union[str, float]
StrOrInt = Union[str, int]
def fast_float(
x: str,
key: Callable[[str], str] = lambda x: x,
nan: float = float("inf"),
_uni: Callable[[str, StrOrFloat], StrOrFloat] = unicodedata.numeric,
_nan_inf: FrozenSet[str] = NAN_INF,
_first_char: FrozenSet[str] = POTENTIAL_FIRST_CHAR,
) -> StrOrFloat:
"""
Convert a string to a float quickly, return input as-is if not possible.
We don't need to accept all input that the real fast_int accepts because
natsort is controlling what is passed to this function.
Parameters
----------
x : str
String to attempt to convert to a float.
key : callable
Single-argument function to apply to *x* if conversion fails.
nan : float
Value to return instead of NaN if NaN would be returned.
Returns
-------
*str* or *float*
"""
if x[0] in _first_char or x.lstrip()[:3] in _nan_inf:
try:
ret = float(x)
return nan if ret != ret else ret
except ValueError:
try:
return _uni(x, key(x)) if len(x) == 1 else key(x)
except TypeError: # pragma: no cover
return key(x)
else:
try:
return _uni(x, key(x)) if len(x) == 1 else key(x)
except TypeError: # pragma: no cover
return key(x)
def fast_int(
x: str,
key: Callable[[str], str] = lambda x: x,
_uni: Callable[[str, StrOrInt], StrOrInt] = unicodedata.digit,
_first_char: FrozenSet[str] = POTENTIAL_FIRST_CHAR,
) -> StrOrInt:
"""
Convert a string to a int quickly, return input as-is if not possible.
We don't need to accept all input that the real fast_int accepts because
natsort is controlling what is passed to this function.
Parameters
----------
x : str
String to attempt to convert to an int.
key : callable
Single-argument function to apply to *x* if conversion fails.
Returns
-------
*str* or *int*
"""
if x[0] in _first_char:
try:
return int(x)
except ValueError:
try:
return _uni(x, key(x)) if len(x) == 1 else key(x)
except TypeError: # pragma: no cover
return key(x)
else:
try:
return _uni(x, key(x)) if len(x) == 1 else key(x)
except TypeError: # pragma: no cover
return key(x)
@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""
Interface for natsort to access fastnumbers functions without
having to worry if it is actually installed.
"""
import re
from typing import Callable, Iterable, Iterator, Tuple, Union
StrOrFloat = Union[str, float]
StrOrInt = Union[str, int]
__all__ = ["try_float", "try_int"]
def is_supported_fastnumbers(
fastnumbers_version: str, minimum: Tuple[int, int, int] = (2, 0, 0)
) -> bool:
match = re.match(
r"^(\d+)\.(\d+)(\.(\d+))?([ab](\d+))?$",
fastnumbers_version,
flags=re.ASCII,
)
if not match:
raise ValueError(
"Invalid fastnumbers version number '{}'".format(fastnumbers_version)
)
(major, minor, patch) = match.group(1, 2, 4)
return (int(major), int(minor), int(patch)) >= minimum
# If the user has fastnumbers installed, they will get great speed
# benefits. If not, we use the simulated functions that come with natsort.
try:
# noinspection PyPackageRequirements
from fastnumbers import fast_float, fast_int, __version__ as fn_ver
# Require >= version 2.0.0.
if not is_supported_fastnumbers(fn_ver):
raise ImportError # pragma: no cover
# For versions of fastnumbers with mapping capability, use that
if is_supported_fastnumbers(fn_ver, (5, 0, 0)):
del fast_float, fast_int
from fastnumbers import try_float, try_int
except ImportError:
from natsort.compat.fake_fastnumbers import fast_float, fast_int # type: ignore
# Re-map the old-or-compatibility functions fast_float/fast_int to the
# newer API of try_float/try_int. If we already imported try_float/try_int
# then there is nothing to do.
if "try_float" not in globals():
def try_float( # type: ignore[no-redef] # noqa: F811
x: Iterable[str],
map: bool,
nan: float = float("inf"),
on_fail: Callable[[str], str] = lambda x: x,
) -> Iterator[StrOrFloat]:
assert map is True
return (fast_float(y, nan=nan, key=on_fail) for y in x)
if "try_int" not in globals():
def try_int( # type: ignore[no-redef] # noqa: F811
x: Iterable[str],
map: bool,
on_fail: Callable[[str], str] = lambda x: x,
) -> Iterator[StrOrInt]:
assert map is True
return (fast_int(y, key=on_fail) for y in x)
@@ -0,0 +1,122 @@
# -*- coding: utf-8 -*-
"""
Interface for natsort to access locale functionality without
having to worry about if it is using PyICU or the built-in locale.
"""
import sys
from typing import Callable, Union, cast
StrOrBytes = Union[str, bytes]
TrxfmFunc = Callable[[str], StrOrBytes]
# This string should be sorted after any other byte string because
# it contains the max unicode character repeated 20 times.
# You would need some odd data to come after that.
null_string = ""
null_string_max = chr(sys.maxunicode) * 20
# This variable could be str or bytes depending on the locale library
# being used, so give the type-checker this information.
null_string_locale: StrOrBytes
null_string_locale_max: StrOrBytes
# strxfrm can be buggy (especially on OSX and *possibly* some other
# BSD-based systems), so prefer icu if available.
try: # noqa: C901
import icu
from locale import getlocale
null_string_locale = b""
# This string should in theory be sorted after any other byte
# string because it contains the max byte char repeated many times.
# You would need some odd data to come after that.
null_string_locale_max = b"x7f" * 50
def dumb_sort() -> bool:
return False
# If using icu, get the locale from the current global locale,
def get_icu_locale() -> str:
language_code, encoding = getlocale()
if language_code is None or encoding is None: # pragma: no cover
return icu.Locale()
return icu.Locale(f"{language_code}.{encoding}")
def get_strxfrm() -> TrxfmFunc:
return icu.Collator.createInstance(get_icu_locale()).getSortKey
def get_thousands_sep() -> str:
sep = icu.DecimalFormatSymbols.kGroupingSeparatorSymbol
return icu.DecimalFormatSymbols(get_icu_locale()).getSymbol(sep)
def get_decimal_point() -> str:
sep = icu.DecimalFormatSymbols.kDecimalSeparatorSymbol
return icu.DecimalFormatSymbols(get_icu_locale()).getSymbol(sep)
except ImportError:
import locale
from locale import strxfrm
null_string_locale = null_string
null_string_locale_max = null_string_max
# On some systems, locale is broken and does not sort in the expected
# order. We will try to detect this and compensate.
def dumb_sort() -> bool:
return strxfrm("A") < strxfrm("a")
def get_strxfrm() -> TrxfmFunc:
return strxfrm
def get_thousands_sep() -> str:
sep = cast(str, locale.localeconv()["thousands_sep"])
# If this locale library is broken, some of the thousands separator
# characters are incorrectly blank. Here is a lookup table of the
# corrections I am aware of.
if dumb_sort():
language_code, encoding = locale.getlocale()
if language_code is None or encoding is None:
# No locale loaded, default to ','
return ","
loc = f"{language_code}.{encoding}"
return {
"de_DE.ISO8859-15": ".",
"es_ES.ISO8859-1": ".",
"de_AT.ISO8859-1": ".",
"de_at": "\xa0",
"nl_NL.UTF-8": ".",
"es_es": ".",
"fr_CH.ISO8859-15": "\xa0",
"fr_CA.ISO8859-1": "\xa0",
"de_CH.ISO8859-1": ".",
"fr_FR.ISO8859-15": "\xa0",
"nl_NL.ISO8859-1": ".",
"ca_ES.UTF-8": ".",
"nl_NL.ISO8859-15": ".",
"de_ch": "'",
"ca_es": ".",
"de_AT.ISO8859-15": ".",
"ca_ES.ISO8859-1": ".",
"de_AT.UTF-8": ".",
"es_ES.UTF-8": ".",
"fr_fr": "\xa0",
"es_ES.ISO8859-15": ".",
"de_DE.ISO8859-1": ".",
"nl_nl": ".",
"fr_ch": "\xa0",
"fr_ca": "\xa0",
"de_DE.UTF-8": ".",
"ca_ES.ISO8859-15": ".",
"de_CH.ISO8859-15": ".",
"fr_FR.ISO8859-1": "\xa0",
"fr_CH.ISO8859-1": "\xa0",
"de_de": ".",
"fr_FR.UTF-8": "\xa0",
"fr_CA.ISO8859-15": "\xa0",
}.get(loc, sep)
else:
return sep
def get_decimal_point() -> str:
return cast(str, locale.localeconv()["decimal_point"])
@@ -0,0 +1,839 @@
# -*- coding: utf-8 -*-
"""
Along with ns_enum.py, this module contains all of the
natsort public API.
The majority of the "work" is defined in utils.py.
"""
import platform
from functools import partial
from operator import itemgetter
from pathlib import PurePath
from typing import (
Any,
Callable,
Iterable,
Iterator,
List,
Optional,
Sequence,
Tuple,
TypeVar,
cast,
)
import natsort.compat.locale
from natsort import utils
from natsort.ns_enum import NSType, NS_DUMB, ns
from natsort.utils import NatsortInType, NatsortOutType
# Common input and output types
T = TypeVar("T")
NatsortInTypeT = TypeVar("NatsortInTypeT", bound=NatsortInType)
# The type that natsort_key returns
NatsortKeyType = Callable[[NatsortInType], NatsortOutType]
# Types for os_sorted
OSSortKeyType = Callable[[NatsortInType], NatsortOutType]
def decoder(encoding: str) -> Callable[[Any], Any]:
"""
Return a function that can be used to decode bytes to unicode.
Parameters
----------
encoding : str
The codec to use for decoding. This must be a valid unicode codec.
Returns
-------
decode_function
A function that takes a single argument and attempts to decode
it using the supplied codec. Any `UnicodeErrors` are raised.
If the argument was not of `bytes` type, it is simply returned
as-is.
See Also
--------
as_ascii
as_utf8
Examples
--------
>>> f = decoder('utf8')
>>> f(b'bytes') == 'bytes'
True
>>> f(12345) == 12345
True
>>> # On Python 3, without decoder this would return [b'a10', b'a2']
>>> natsorted([b'a10', b'a2'], key=decoder('utf8')) == [b'a2', b'a10']
True
>>> # On Python 3, without decoder this would raise a TypeError.
>>> natsorted([b'a10', 'a2'], key=decoder('utf8')) == ['a2', b'a10']
True
"""
return partial(utils.do_decoding, encoding=encoding)
def as_ascii(s: Any) -> Any:
"""
Function to decode an input with the ASCII codec, or return as-is.
Parameters
----------
s : object
Returns
-------
output
If the input was of type `bytes`, the return value is a `str` decoded
with the ASCII codec. Otherwise, the return value is identically the
input.
See Also
--------
decoder
"""
return utils.do_decoding(s, "ascii")
def as_utf8(s: Any) -> Any:
"""
Function to decode an input with the UTF-8 codec, or return as-is.
Parameters
----------
s : object
Returns
-------
output
If the input was of type `bytes`, the return value is a `str` decoded
with the UTF-8 codec. Otherwise, the return value is identically the
input.
See Also
--------
decoder
"""
return utils.do_decoding(s, "utf-8")
def natsort_keygen(
key: Optional[Callable[[Any], NatsortInType]] = None, alg: NSType = ns.DEFAULT
) -> Callable[[Any], NatsortOutType]:
"""
Generate a key to sort strings and numbers naturally.
This key is designed for use as the `key` argument to
functions such as the `sorted` builtin.
The user may customize the generated function with the
arguments to `natsort_keygen`, including an optional
`key` function.
Parameters
----------
key : callable, optional
A key used to manipulate the input value before parsing for
numbers. It is **not** applied recursively.
It should accept a single argument and return a single value.
alg : ns enum, optional
This option is used to control which algorithm `natsort`
uses when sorting. For details into these options, please see
the :class:`ns` class documentation. The default is `ns.INT`.
Returns
-------
out : function
A function that parses input for natural sorting that is
suitable for passing as the `key` argument to functions
such as `sorted`.
See Also
--------
natsorted
natsort_key
Examples
--------
`natsort_keygen` is a convenient way to create a custom key
to sort lists in-place (for example).::
>>> a = ['num5.10', 'num-3', 'num5.3', 'num2']
>>> a.sort(key=natsort_keygen(alg=ns.REAL))
>>> a
['num-3', 'num2', 'num5.10', 'num5.3']
"""
try:
ns.DEFAULT | alg
except TypeError:
msg = "natsort_keygen: 'alg' argument must be from the enum 'ns'"
raise ValueError(msg + ", got {}".format(str(alg)))
# Add the NS_DUMB option if the locale library is broken.
if alg & ns.LOCALEALPHA and natsort.compat.locale.dumb_sort():
alg |= NS_DUMB
# Set some variables that will be passed to the factory functions
if alg & ns.NUMAFTER:
if alg & ns.LOCALEALPHA:
sep = natsort.compat.locale.null_string_locale_max
else:
sep = natsort.compat.locale.null_string_max
pre_sep = natsort.compat.locale.null_string_max
else:
if alg & ns.LOCALEALPHA:
sep = natsort.compat.locale.null_string_locale
else:
sep = natsort.compat.locale.null_string
pre_sep = natsort.compat.locale.null_string
regex = utils.regex_chooser(alg)
# Create the functions that will be used to split strings.
input_transform = utils.input_string_transform_factory(alg)
component_transform = utils.string_component_transform_factory(alg)
final_transform = utils.final_data_transform_factory(alg, sep, pre_sep)
# Create the high-level parsing functions for strings, bytes, and numbers.
string_func = utils.parse_string_factory(
alg, sep, regex.split, input_transform, component_transform, final_transform
)
if alg & ns.PATH:
string_func = utils.parse_path_factory(string_func)
bytes_func = utils.parse_bytes_factory(alg)
num_func = utils.parse_number_or_none_factory(alg, sep, pre_sep)
# Return the natsort key with the parsing path pre-chosen.
return partial(
utils.natsort_key,
key=key,
string_func=string_func,
bytes_func=bytes_func,
num_func=num_func,
)
# Exposed for simplicity if one needs the default natsort key.
natsort_key = natsort_keygen()
natsort_key.__doc__ = """\
natsort_key(val)
The default natural sorting key.
This is the output of :func:`natsort_keygen` with default values.
See Also
--------
natsort_keygen
"""
def natsorted(
seq: Iterable[T],
key: Optional[Callable[[T], NatsortInType]] = None,
reverse: bool = False,
alg: NSType = ns.DEFAULT,
) -> List[T]:
"""
Sorts an iterable naturally.
Parameters
----------
seq : iterable
The input to sort.
key : callable, optional
A key used to determine how to sort each element of the iterable.
It is **not** applied recursively.
It should accept a single argument and return a single value.
reverse : {{True, False}}, optional
Return the list in reversed sorted order. The default is
`False`.
alg : ns enum, optional
This option is used to control which algorithm `natsort`
uses when sorting. For details into these options, please see
the :class:`ns` class documentation. The default is `ns.INT`.
Returns
-------
out: list
The sorted input.
See Also
--------
natsort_keygen : Generates the key that makes natural sorting possible.
realsorted : A wrapper for ``natsorted(seq, alg=ns.REAL)``.
humansorted : A wrapper for ``natsorted(seq, alg=ns.LOCALE)``.
index_natsorted : Returns the sorted indexes from `natsorted`.
os_sorted : Sort according to your operating system's rules.
Examples
--------
Use `natsorted` just like the builtin `sorted`::
>>> a = ['num3', 'num5', 'num2']
>>> natsorted(a)
['num2', 'num3', 'num5']
"""
if alg & ns.PRESORT:
seq = sorted(seq, reverse=reverse, key=str)
return sorted(seq, reverse=reverse, key=natsort_keygen(key, alg))
def humansorted(
seq: Iterable[T],
key: Optional[Callable[[T], NatsortInType]] = None,
reverse: bool = False,
alg: NSType = ns.DEFAULT,
) -> List[T]:
"""
Convenience function to properly sort non-numeric characters.
This is a wrapper around ``natsorted(seq, alg=ns.LOCALE)``.
Parameters
----------
seq : iterable
The input to sort.
key : callable, optional
A key used to determine how to sort each element of the sequence.
It is **not** applied recursively.
It should accept a single argument and return a single value.
reverse : {{True, False}}, optional
Return the list in reversed sorted order. The default is
`False`.
alg : ns enum, optional
This option is used to control which algorithm `natsort`
uses when sorting. For details into these options, please see
the :class:`ns` class documentation. The default is `ns.LOCALE`.
Returns
-------
out : list
The sorted input.
See Also
--------
index_humansorted : Returns the sorted indexes from `humansorted`.
Notes
-----
Please read :ref:`locale_issues` before using `humansorted`.
Examples
--------
Use `humansorted` just like the builtin `sorted`::
>>> a = ['Apple', 'Banana', 'apple', 'banana']
>>> natsorted(a)
['Apple', 'Banana', 'apple', 'banana']
>>> humansorted(a)
['apple', 'Apple', 'banana', 'Banana']
"""
return natsorted(seq, key, reverse, alg | ns.LOCALE)
def realsorted(
seq: Iterable[T],
key: Optional[Callable[[T], NatsortInType]] = None,
reverse: bool = False,
alg: NSType = ns.DEFAULT,
) -> List[T]:
"""
Convenience function to properly sort signed floats.
A signed float in a string could be "a-5.7". This is a wrapper around
``natsorted(seq, alg=ns.REAL)``.
The behavior of :func:`realsorted` for `natsort` version >= 4.0.0
was the default behavior of :func:`natsorted` for `natsort`
version < 4.0.0.
Parameters
----------
seq : iterable
The input to sort.
key : callable, optional
A key used to determine how to sort each element of the sequence.
It is **not** applied recursively.
It should accept a single argument and return a single value.
reverse : {{True, False}}, optional
Return the list in reversed sorted order. The default is
`False`.
alg : ns enum, optional
This option is used to control which algorithm `natsort`
uses when sorting. For details into these options, please see
the :class:`ns` class documentation. The default is `ns.REAL`.
Returns
-------
out : list
The sorted input.
See Also
--------
index_realsorted : Returns the sorted indexes from `realsorted`.
Examples
--------
Use `realsorted` just like the builtin `sorted`::
>>> a = ['num5.10', 'num-3', 'num5.3', 'num2']
>>> natsorted(a)
['num2', 'num5.3', 'num5.10', 'num-3']
>>> realsorted(a)
['num-3', 'num2', 'num5.10', 'num5.3']
"""
return natsorted(seq, key, reverse, alg | ns.REAL)
def index_natsorted(
seq: Iterable[T],
key: Optional[Callable[[T], NatsortInType]] = None,
reverse: bool = False,
alg: NSType = ns.DEFAULT,
) -> List[int]:
"""
Determine the list of the indexes used to sort the input sequence.
Sorts a sequence naturally, but returns a list of sorted the
indexes and not the sorted list itself. This list of indexes
can be used to sort multiple lists by the sorted order of the
given sequence.
Parameters
----------
seq : iterable
The input to sort.
key : callable, optional
A key used to determine how to sort each element of the sequence.
It is **not** applied recursively.
It should accept a single argument and return a single value.
reverse : {{True, False}}, optional
Return the list in reversed sorted order. The default is
`False`.
alg : ns enum, optional
This option is used to control which algorithm `natsort`
uses when sorting. For details into these options, please see
the :class:`ns` class documentation. The default is `ns.INT`.
Returns
-------
out : tuple
The ordered indexes of the input.
See Also
--------
natsorted
order_by_index
Examples
--------
Use index_natsorted if you want to sort multiple lists by the
sorted order of one list::
>>> a = ['num3', 'num5', 'num2']
>>> b = ['foo', 'bar', 'baz']
>>> index = index_natsorted(a)
>>> index
[2, 0, 1]
>>> # Sort both lists by the sort order of a
>>> order_by_index(a, index)
['num2', 'num3', 'num5']
>>> order_by_index(b, index)
['baz', 'foo', 'bar']
"""
newkey: Callable[[Tuple[int, T]], NatsortInType]
if key is None:
newkey = itemgetter(1)
else:
def newkey(x: Tuple[int, T]) -> NatsortInType:
return cast(Callable[[T], NatsortInType], key)(itemgetter(1)(x))
# Pair the index and sequence together, then sort by element
index_seq_pair = [(x, y) for x, y in enumerate(seq)]
if alg & ns.PRESORT:
index_seq_pair.sort(reverse=reverse, key=lambda x: str(itemgetter(1)(x)))
index_seq_pair.sort(reverse=reverse, key=natsort_keygen(newkey, alg))
return [x for x, _ in index_seq_pair]
def index_humansorted(
seq: Iterable[T],
key: Optional[Callable[[T], NatsortInType]] = None,
reverse: bool = False,
alg: NSType = ns.DEFAULT,
) -> List[int]:
"""
This is a wrapper around ``index_natsorted(seq, alg=ns.LOCALE)``.
Parameters
----------
seq: iterable
The input to sort.
key: callable, optional
A key used to determine how to sort each element of the sequence.
It is **not** applied recursively.
It should accept a single argument and return a single value.
reverse : {{True, False}}, optional
Return the list in reversed sorted order. The default is
`False`.
alg : ns enum, optional
This option is used to control which algorithm `natsort`
uses when sorting. For details into these options, please see
the :class:`ns` class documentation. The default is `ns.LOCALE`.
Returns
-------
out : tuple
The ordered indexes of the input.
See Also
--------
humansorted
order_by_index
Notes
-----
Please read :ref:`locale_issues` before using `humansorted`.
Examples
--------
Use `index_humansorted` just like the builtin `sorted`::
>>> a = ['Apple', 'Banana', 'apple', 'banana']
>>> index_humansorted(a)
[2, 0, 3, 1]
"""
return index_natsorted(seq, key, reverse, alg | ns.LOCALE)
def index_realsorted(
seq: Iterable[T],
key: Optional[Callable[[T], NatsortInType]] = None,
reverse: bool = False,
alg: NSType = ns.DEFAULT,
) -> List[int]:
"""
This is a wrapper around ``index_natsorted(seq, alg=ns.REAL)``.
Parameters
----------
seq: iterable
The input to sort.
key: callable, optional
A key used to determine how to sort each element of the sequence.
It is **not** applied recursively.
It should accept a single argument and return a single value.
reverse : {{True, False}}, optional
Return the list in reversed sorted order. The default is
`False`.
alg : ns enum, optional
This option is used to control which algorithm `natsort`
uses when sorting. For details into these options, please see
the :class:`ns` class documentation. The default is `ns.REAL`.
Returns
-------
out : tuple
The ordered indexes of the input.
See Also
--------
realsorted
order_by_index
Examples
--------
Use `index_realsorted` just like the builtin `sorted`::
>>> a = ['num5.10', 'num-3', 'num5.3', 'num2']
>>> index_realsorted(a)
[1, 3, 0, 2]
"""
return index_natsorted(seq, key, reverse, alg | ns.REAL)
def order_by_index(
seq: Sequence[Any], index: Iterable[int], iter: bool = False
) -> Iterable[Any]:
"""
Order a given sequence by an index sequence.
The output of `index_natsorted` is a
sequence of integers (index) that correspond to how its input
sequence **would** be sorted. The idea is that this index can
be used to reorder multiple sequences by the sorted order of the
first sequence. This function is a convenient wrapper to
apply this ordering to a sequence.
Parameters
----------
seq : sequence
The sequence to order.
index : iterable
The iterable that indicates how to order `seq`.
It should be the same length as `seq` and consist
of integers only.
iter : {{True, False}}, optional
If `True`, the ordered sequence is returned as a
iterator; otherwise it is returned as a
list. The default is `False`.
Returns
-------
out : {{list, iterator}}
The sequence ordered by `index`, as a `list` or as an
iterator (depending on the value of `iter`).
See Also
--------
index_natsorted
index_humansorted
index_realsorted
Examples
--------
`order_by_index` is a convenience function that helps you apply
the result of `index_natsorted`::
>>> a = ['num3', 'num5', 'num2']
>>> b = ['foo', 'bar', 'baz']
>>> index = index_natsorted(a)
>>> index
[2, 0, 1]
>>> # Sort both lists by the sort order of a
>>> order_by_index(a, index)
['num2', 'num3', 'num5']
>>> order_by_index(b, index)
['baz', 'foo', 'bar']
"""
return (seq[i] for i in index) if iter else [seq[i] for i in index]
def numeric_regex_chooser(alg: NSType) -> str:
"""
Select an appropriate regex for the type of number of interest.
Parameters
----------
alg : ns enum
Used to indicate the regular expression to select.
Returns
-------
regex : str
Regular expression string that matches the desired number type.
"""
# Remove the leading and trailing parens
return utils.regex_chooser(alg).pattern[1:-1]
def _split_apply(
v: Any, key: Optional[Callable[[T], NatsortInType]] = None, treat_base: bool = True
) -> Iterator[str]:
if key is not None:
v = key(v)
if not isinstance(v, (str, PurePath)):
v = str(v)
return utils.path_splitter(v, treat_base=treat_base)
# Choose the implementation based on the host OS
if platform.system() == "Windows":
from ctypes import wintypes, windll # type: ignore
from functools import cmp_to_key
_windows_sort_cmp = windll.Shlwapi.StrCmpLogicalW
_windows_sort_cmp.argtypes = [wintypes.LPWSTR, wintypes.LPWSTR]
_windows_sort_cmp.restype = wintypes.INT
_winsort_key = cmp_to_key(_windows_sort_cmp)
def os_sort_keygen(
key: Optional[Callable[[Any], NatsortInType]] = None
) -> Callable[[Any], NatsortOutType]:
return cast(
Callable[[Any], NatsortOutType],
lambda x: tuple(map(_winsort_key, _split_apply(x, key, treat_base=False))),
)
else:
# For UNIX-based platforms, ICU performs MUCH better than locale
# at replicating the file explorer's sort order. We will use
# ICU's ability to do basic natural sorting as it also better
# replicates than what natsort does by default.
#
# However, if the user does not have ICU installed then fall back
# on natsort's default handling for paths with locale turned on
# which will give good results in most cases (e.g. when there aren't
# a bunch of special characters).
try:
import icu
except ImportError:
# No ICU installed
def os_sort_keygen(
key: Optional[Callable[[Any], NatsortInType]] = None
) -> Callable[[Any], NatsortOutType]:
return natsort_keygen(key=key, alg=ns.LOCALE | ns.PATH | ns.IGNORECASE)
else:
# ICU installed
def os_sort_keygen(
key: Optional[Callable[[Any], NatsortInType]] = None
) -> Callable[[Any], NatsortOutType]:
loc = natsort.compat.locale.get_icu_locale()
collator = icu.Collator.createInstance(loc)
collator.setAttribute(
icu.UCollAttribute.NUMERIC_COLLATION, icu.UCollAttributeValue.ON
)
return lambda x: tuple(map(collator.getSortKey, _split_apply(x, key)))
os_sort_keygen.__doc__ = """
Generate a sorting key to replicate your file browser's sort order
See :func:`os_sorted` for description and caveats.
Returns
-------
out : function
A function that parses input for OS path sorting that is
suitable for passing as the `key` argument to functions
such as `sorted`.
See Also
--------
os_sort_key
os_sorted
Notes
-----
On Windows, this will implicitly coerce all inputs to str before
collating.
"""
os_sort_key = os_sort_keygen()
os_sort_key.__doc__ = """
os_sort_key(val)
The default key to replicate your file browser's sort order
This is the output of :func:`os_sort_keygen` with default values.
See Also
--------
os_sort_keygen
"""
def os_sorted(
seq: Iterable[T],
key: Optional[Callable[[T], NatsortInType]] = None,
reverse: bool = False,
presort: bool = False,
) -> List[T]:
"""
Sort elements in the same order as your operating system's file browser
.. warning::
The resulting function will generate results that will be
different depending on your platform. This is intentional.
On Windows, this will sort with the same order as Windows Explorer.
On MacOS/Linux, you will get different results depending on whether
or not you have :mod:`pyicu` installed.
- If you have :mod:`pyicu` installed, you will get results that are
the same as (or very close to) the same order as your operating
system's file browser.
- If you do not have :mod:`pyicu` installed, then this will give
the same results as if you used ``ns.LOCALE``, ``ns.PATH``,
and ``ns.IGNORECASE`` with :func:`natsorted`. If you do not have
special characters this will give correct results, but once
special characters are added you should lower your expectations.
It is *strongly* recommended to have :mod:`pyicu` installed on
MacOS/Linux if you want correct sort results.
It does *not* take into account if a path is a directory or a file
when sorting.
Parameters
----------
seq : iterable
The input to sort. Each element must be of type str.
key : callable, optional
A key used to determine how to sort each element of the sequence.
It should accept a single argument and return a single value.
reverse : {{True, False}}, optional
Return the list in reversed sorted order. The default is
`False`.
presort : {{True, False}}, optional
Equivalent to adding ``ns.PRESORT``, see :class:`ns` for
documentation. The default is `False`.
Returns
-------
out : list
The sorted input.
See Also
--------
natsorted
os_sort_keygen
Notes
-----
This will implicitly coerce all inputs to str before collating.
"""
if presort:
seq = sorted(seq, reverse=reverse, key=str)
return sorted(seq, reverse=reverse, key=os_sort_keygen(key))
@@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
"""
This module defines the "ns" enum for natsort is used to determine
what algorithm natsort uses.
"""
import enum
import itertools
import typing
_counter = itertools.count(0)
class ns(enum.IntEnum): # noqa: N801
"""
Enum to control the `natsort` algorithm.
This class acts like an enum to control the `natsort` algorithm. The
user may select several options simultaneously by or'ing the options
together. For example, to choose ``ns.INT``, ``ns.PATH``, and
``ns.LOCALE``, you could do ``ns.INT | ns.LOCALE | ns.PATH``. Each
function in the :mod:`natsort` package has an `alg` option that accepts
this enum to allow fine control over how your input is sorted.
Each option has a shortened 1- or 2-letter form.
.. note:: Please read :ref:`locale_issues` before using ``ns.LOCALE``.
Attributes
----------
INT, I (default)
The default - parse numbers as integers.
FLOAT, F
Tell `natsort` to parse numbers as floats.
UNSIGNED, U (default)
Tell `natsort` to ignore any sign (i.e. "-" or "+") to the immediate
left of a number. This is the default.
SIGNED, S
Tell `natsort` to take into account any sign (i.e. "-" or "+")
to the immediate left of a number.
REAL, R
This is a shortcut for ``ns.FLOAT | ns.SIGNED``, which is useful
when attempting to sort real numbers.
NOEXP, N
Tell `natsort` to not search for exponents as part of a float number.
For example, with `NOEXP` the number "5.6E5" would be interpreted
as `5.6`, `"E"`, and `5` instead of `560000`.
NUMAFTER, NA
Tell `natsort` to sort numbers after non-numbers. By default
numbers will be ordered before non-numbers.
PATH, P
Tell `natsort` to interpret strings as filesystem paths, so they
will be split according to the filesystem separator
(i.e. '/' on UNIX, '\\' on Windows), as well as splitting on the
file extension, if any. Without this, lists of file paths like
``['Folder/', 'Folder (1)/', 'Folder (10)/']`` will not be
sorted properly; 'Folder/' will be placed at the end, not at the
front. It is the same as setting the old `as_path` option to
`True`.
COMPATIBILITYNORMALIZE, CN
Use the "NFKD" unicode normalization form on input rather than the
default "NFD". This will transform characters such as '' into
'7'. Please see https://stackoverflow.com/a/7934397/1399279,
https://stackoverflow.com/a/7931547/1399279,
and https://unicode.org/reports/tr15/ for full details into unicode
normalization.
LOCALE, L
Tell `natsort` to be locale-aware when sorting. This includes both
proper sorting of alphabetical characters as well as proper
handling of locale-dependent decimal separators and thousands
separators. This is a shortcut for
``ns.LOCALEALPHA | ns.LOCALENUM``.
Your sorting results will vary depending on your current locale.
LOCALEALPHA, LA
Tell `natsort` to be locale-aware when sorting, but only for
alphabetical characters.
LOCALENUM, LN
Tell `natsort` to be locale-aware when sorting, but only for
decimal separators and thousands separators.
IGNORECASE, IC
Tell `natsort` to ignore case when sorting. For example,
``['Banana', 'apple', 'banana', 'Apple']`` would be sorted as
``['apple', 'Apple', 'Banana', 'banana']``.
LOWERCASEFIRST, LF
Tell `natsort` to put lowercase letters before uppercase letters
when sorting. For example,
``['Banana', 'apple', 'banana', 'Apple']`` would be sorted as
``['apple', 'banana', 'Apple', 'Banana']`` (the default order
would be ``['Apple', 'Banana', 'apple', 'banana']`` which is
the order from a purely ordinal sort).
Useless when used with `IGNORECASE`. Please note that if used
with ``LOCALE``, this actually has the reverse effect and will
put uppercase first (this is because ``LOCALE`` already puts
lowercase first); you may use this to your advantage if you
need to modify the order returned with ``LOCALE``.
GROUPLETTERS, G
Tell `natsort` to group lowercase and uppercase letters together
when sorting. For example,
``['Banana', 'apple', 'banana', 'Apple']`` would be sorted as
``['Apple', 'apple', 'Banana', 'banana']``.
Useless when used with `IGNORECASE`; use with `LOWERCASEFIRST`
to reverse the order of upper and lower case. Generally not
needed with `LOCALE`.
CAPITALFIRST, C
Only used when `LOCALE` is enabled. Tell `natsort` to put all
capitalized words before non-capitalized words. This is essentially
the inverse of `GROUPLETTERS`, and is the default Python sorting
behavior without `LOCALE`.
UNGROUPLETTERS, UG
An alias for `CAPITALFIRST`.
NANLAST, NL
If an NaN shows up in the input, this instructs `natsort` to
treat these as +Infinity and place them after all the other numbers.
By default, an NaN be treated as -Infinity and be placed first.
Note that this ``None`` is treated like NaN internally.
PRESORT, PS
Sort the input as strings before sorting with the `nasort`
algorithm. This can help eliminate inconsistent sorting in cases
where two different strings represent the same number. For example,
"a1" and "a01" both are internally represented as ("a", "1), so
without `PRESORT` the order of these two values would depend on
the order they appeared in the input (because Python's `sorted`
is a stable sorting algorithm).
Notes
-----
If you prefer to use `import natsort as ns` as opposed to
`from natsort import natsorted, ns`, the `ns` options are
available as top-level imports.
>>> import natsort as ns
>>> a = ['num5.10', 'num-3', 'num5.3', 'num2']
>>> ns.natsorted(a, alg=ns.REAL) == ns.natsorted(a, alg=ns.ns.REAL)
True
"""
# The below are the base ns options. The values will be stored as powers
# of two so bitmasks can be used to extract the user's requested options.
FLOAT = F = 1 << next(_counter)
SIGNED = S = 1 << next(_counter)
NOEXP = N = 1 << next(_counter)
PATH = P = 1 << next(_counter)
LOCALEALPHA = LA = 1 << next(_counter)
LOCALENUM = LN = 1 << next(_counter)
IGNORECASE = IC = 1 << next(_counter)
LOWERCASEFIRST = LF = 1 << next(_counter)
GROUPLETTERS = G = 1 << next(_counter)
UNGROUPLETTERS = CAPITALFIRST = C = UG = 1 << next(_counter)
NANLAST = NL = 1 << next(_counter)
COMPATIBILITYNORMALIZE = CN = 1 << next(_counter)
NUMAFTER = NA = 1 << next(_counter)
PRESORT = PS = 1 << next(_counter)
# Following were previously options but are now defaults.
DEFAULT = 0
INT = I = 0 # noqa: E741
UNSIGNED = U = 0
# The following are bitwise-OR combinations of other fields.
REAL = R = FLOAT | SIGNED
LOCALE = L = LOCALEALPHA | LOCALENUM
# The below is private for internal use only.
NS_DUMB = 1 << 31
# An integer can be used in place of the ns enum so make the
# type to use for this enum a union of it and an inteter.
NSType = typing.Union[ns, int]
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""
Pre-determine the collection of unicode decimals, digits, and numerals.
"""
import unicodedata
from natsort.unicode_numeric_hex import numeric_hex
# Convert each hex into the literal Unicode character.
# Stop if a ValueError is raised in case of a narrow Unicode build.
# The extra check with unicodedata is in case this Python version
# does not support some characters.
numeric_chars = []
for a in numeric_hex:
try:
character = chr(a)
except ValueError: # pragma: no cover
break
if unicodedata.numeric(character, None) is None:
continue # pragma: no cover
numeric_chars.append(character)
# The digit characters are a subset of the numerals.
digit_chars = [a for a in numeric_chars if unicodedata.digit(a, None) is not None]
# The decimal characters are a subset of the numerals
# (probably of the digits, but let's be safe).
decimal_chars = [a for a in numeric_chars if unicodedata.decimal(a, None) is not None]
# Create a single string with the above data.
decimals = "".join(decimal_chars)
digits = "".join(digit_chars)
numeric = "".join(numeric_chars)
digits_no_decimals = "".join([x for x in digits if x not in decimals])
numeric_no_decimals = "".join([x for x in numeric if x not in decimals])
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,934 @@
# -*- coding: utf-8 -*-
"""
Utilities and definitions for natsort, mostly all used to define
the natsort_key function.
SOME CONVENTIONS USED IN THIS FILE.
1 - Factory Functions
Most of the logic of natsort revolves around factory functions
that create branchless transformation functions. For example, rather
than making a string transformation function that has an if
statement to determine whether or not to perform .lowercase() at
runtime for each element to transform, there is a string transformation
factory function that will return a function that either calls
.lowercase() or does nothing. In this way, all the branches and
decisions are taken care of once, up front. In addition to a slight
speed improvement, this provides a more extensible infrastructure.
Each of these factory functions will end with the suffix "_factory"
to indicate that they themselves return a function.
2 - Keyword Parameters For Local Scope
Many of the closures that are created by the factory functions
have signatures similar to the following
>>> def factory(parameter):
... val = 'yes' if parameter else 'no'
... def closure(x, _val=val):
... return '{} {}'.format(_val, x)
... return closure
...
The variable value is passed as the default to a keyword argument.
This is a micro-optimization
that ensures "val" is a local variable instead of global variable
and thus has a slightly improved performance at runtime.
"""
import re
from functools import partial, reduce
from itertools import chain as ichain
from operator import methodcaller
from pathlib import PurePath
from typing import (
Any,
Callable,
Dict,
Iterable,
Iterator,
List,
Match,
Optional,
Pattern,
TYPE_CHECKING,
Tuple,
Union,
cast,
overload,
)
from unicodedata import normalize
from natsort.compat.fastnumbers import try_float, try_int
from natsort.compat.locale import (
StrOrBytes,
get_decimal_point,
get_strxfrm,
get_thousands_sep,
)
from natsort.ns_enum import NSType, NS_DUMB, ns
from natsort.unicode_numbers import digits_no_decimals, numeric_no_decimals
if TYPE_CHECKING:
from typing_extensions import Protocol
else:
Protocol = object
#
# Pre-define a slew of aggregate types which makes the type hinting below easier
#
class SupportsDunderLT(Protocol):
def __lt__(self, __other: Any) -> bool:
...
class SupportsDunderGT(Protocol):
def __gt__(self, __other: Any) -> bool:
...
Sortable = Union[SupportsDunderLT, SupportsDunderGT]
StrToStr = Callable[[str], str]
AnyCall = Callable[[Any], Any]
# For the bytes transform factory
BytesTuple = Tuple[bytes]
NestedBytesTuple = Tuple[Tuple[bytes]]
BytesTransform = Union[BytesTuple, NestedBytesTuple]
BytesTransformer = Callable[[bytes], BytesTransform]
# For the number transform factory
BasicTuple = Tuple[Any, ...]
NestedAnyTuple = Tuple[BasicTuple, ...]
AnyTuple = Union[BasicTuple, NestedAnyTuple]
NumTransform = AnyTuple
NumTransformer = Callable[[Any], NumTransform]
# For the string component transform factory
StrBytesNum = Union[str, bytes, float, int]
StrTransformer = Callable[[Iterable[str]], Iterator[StrBytesNum]]
# For the final data transform factory
FinalTransform = AnyTuple
FinalTransformer = Callable[[Iterable[Any], str], FinalTransform]
PathArg = Union[str, PurePath]
MatchFn = Callable[[str], Optional[Match]]
# For the string parsing factory
StrSplitter = Callable[[str], Iterable[str]]
StrParser = Callable[[PathArg], FinalTransform]
# For the path parsing factory
PathSplitter = Callable[[PathArg], Tuple[FinalTransform, ...]]
# For the natsort key
NatsortInType = Optional[Sortable]
NatsortOutType = Tuple[Sortable, ...]
KeyType = Callable[[Any], NatsortInType]
MaybeKeyType = Optional[KeyType]
class NumericalRegularExpressions:
"""
Container of regular expressions that match numbers.
The numbers also account for unicode non-decimal characters.
Not intended to be made an instance - use class methods only.
"""
# All unicode numeric characters (minus the decimal characters).
numeric: str = numeric_no_decimals
# All unicode digit characters (minus the decimal characters).
digits: str = digits_no_decimals
# Regular expression to match exponential component of a float.
exp: str = r"(?:[eE][-+]?\d+)?"
# Regular expression to match a floating point number.
float_num: str = r"(?:\d+\.?\d*|\.\d+)"
@classmethod
def _construct_regex(cls, fmt: str) -> Pattern[str]:
"""Given a format string, construct the regex with class attributes."""
return re.compile(fmt.format(**vars(cls)), flags=re.U)
@classmethod
def int_sign(cls) -> Pattern[str]:
"""Regular expression to match a signed int."""
return cls._construct_regex(r"([-+]?\d+|[{digits}])")
@classmethod
def int_nosign(cls) -> Pattern[str]:
"""Regular expression to match an unsigned int."""
return cls._construct_regex(r"(\d+|[{digits}])")
@classmethod
def float_sign_exp(cls) -> Pattern[str]:
"""Regular expression to match a signed float with exponent."""
return cls._construct_regex(r"([-+]?{float_num}{exp}|[{numeric}])")
@classmethod
def float_nosign_exp(cls) -> Pattern[str]:
"""Regular expression to match an unsigned float with exponent."""
return cls._construct_regex(r"({float_num}{exp}|[{numeric}])")
@classmethod
def float_sign_noexp(cls) -> Pattern[str]:
"""Regular expression to match a signed float without exponent."""
return cls._construct_regex(r"([-+]?{float_num}|[{numeric}])")
@classmethod
def float_nosign_noexp(cls) -> Pattern[str]:
"""Regular expression to match an unsigned float without exponent."""
return cls._construct_regex(r"({float_num}|[{numeric}])")
def regex_chooser(alg: NSType) -> Pattern[str]:
"""
Select an appropriate regex for the type of number of interest.
Parameters
----------
alg : ns enum
Used to indicate the regular expression to select.
Returns
-------
regex : compiled regex object
Regular expression object that matches the desired number type.
"""
if alg & ns.FLOAT:
alg &= ns.FLOAT | ns.SIGNED | ns.NOEXP
else:
alg &= ns.INT | ns.SIGNED
return {
ns.INT: NumericalRegularExpressions.int_nosign(),
ns.FLOAT: NumericalRegularExpressions.float_nosign_exp(),
ns.INT | ns.SIGNED: NumericalRegularExpressions.int_sign(),
ns.FLOAT | ns.SIGNED: NumericalRegularExpressions.float_sign_exp(),
ns.FLOAT | ns.NOEXP: NumericalRegularExpressions.float_nosign_noexp(),
ns.FLOAT | ns.SIGNED | ns.NOEXP: NumericalRegularExpressions.float_sign_noexp(),
}[alg]
def _no_op(x: Any) -> Any:
"""A function that does nothing and returns the input as-is."""
return x
def _normalize_input_factory(alg: NSType) -> StrToStr:
"""
Create a function that will normalize unicode input data.
Parameters
----------
alg : ns enum
Used to indicate how to normalize unicode.
Returns
-------
func : callable
A function that accepts string (unicode) input and returns the
the input normalized with the desired normalization scheme.
"""
normalization_form = "NFKD" if alg & ns.COMPATIBILITYNORMALIZE else "NFD"
return partial(normalize, normalization_form)
def _compose_input_factory(alg: NSType) -> StrToStr:
"""
Create a function that will compose unicode input data.
Parameters
----------
alg : ns enum
Used to indicate how to compose unicode.
Returns
-------
func : callable
A function that accepts string (unicode) input and returns the
the input normalized with the desired composition scheme.
"""
normalization_form = "NFKC" if alg & ns.COMPATIBILITYNORMALIZE else "NFC"
return partial(normalize, normalization_form)
@overload
def natsort_key(
val: NatsortInType,
key: None,
string_func: Union[StrParser, PathSplitter],
bytes_func: BytesTransformer,
num_func: NumTransformer,
) -> NatsortOutType:
...
@overload
def natsort_key(
val: Any,
key: KeyType,
string_func: Union[StrParser, PathSplitter],
bytes_func: BytesTransformer,
num_func: NumTransformer,
) -> NatsortOutType:
...
def natsort_key(
val: Union[NatsortInType, Any],
key: MaybeKeyType,
string_func: Union[StrParser, PathSplitter],
bytes_func: BytesTransformer,
num_func: NumTransformer,
) -> NatsortOutType:
"""
Key to sort strings and numbers naturally.
It works by splitting the string into components of strings and numbers,
and then converting the numbers into actual ints or floats.
Parameters
----------
val : str | bytes | int | float | iterable
key : callable | None
A key to apply to the *val* before any other operations are performed.
string_func : callable
If *val* (or the output of *key* if given) is of type *str*, this
function will be applied to it. The function must return
a tuple.
bytes_func : callable
If *val* (or the output of *key* if given) is of type *bytes*, this
function will be applied to it. The function must return
a tuple.
num_func : callable
If *val* (or the output of *key* if given) is not of type *bytes*,
*str*, nor is iterable, this function will be applied to it.
The function must return a tuple.
Returns
-------
out : tuple
The string split into its string and numeric components.
It *always* starts with a string, and then alternates
between numbers and strings (unless it was applied
recursively, in which case it will return tuples of tuples,
but the lowest-level tuples will then *always* start with
a string etc.).
See Also
--------
parse_string_factory
parse_bytes_factory
parse_number_or_none_factory
"""
# Apply key if needed
if key is not None:
val = key(val)
if isinstance(val, (str, PurePath)):
return string_func(val)
elif isinstance(val, bytes):
return bytes_func(val)
elif isinstance(val, Iterable):
# Must be parsed recursively, but do not apply the key recursively.
return tuple(
natsort_key(x, None, string_func, bytes_func, num_func) for x in val
)
else: # Anything else goes here
return num_func(val)
def parse_bytes_factory(alg: NSType) -> BytesTransformer:
"""
Create a function that will format a *bytes* object into a tuple.
Parameters
----------
alg : ns enum
Indicate how to format the *bytes*.
Returns
-------
func : callable
A function that accepts *bytes* input and returns a tuple
with the formatted *bytes*. Intended to be used as the
*bytes_func* argument to *natsort_key*.
See Also
--------
natsort_key
"""
# We don't worry about ns.UNGROUPLETTERS | ns.LOCALEALPHA because
# bytes cannot be compared to strings.
if alg & ns.PATH and alg & ns.IGNORECASE:
return lambda x: ((x.lower(),),)
elif alg & ns.PATH:
return lambda x: ((x,),)
elif alg & ns.IGNORECASE:
return lambda x: (x.lower(),)
else:
return lambda x: (x,)
def parse_number_or_none_factory(
alg: NSType, sep: StrOrBytes, pre_sep: str
) -> NumTransformer:
"""
Create a function that will format a number (or None) into a tuple.
Parameters
----------
alg : ns enum
Indicate how to format the *bytes*.
sep : str
The string character to be inserted before the number
in the returned tuple.
pre_sep : str
In the event that *alg* contains ``UNGROUPLETTERS``, this
string will be placed in a single-element tuple at the front
of the returned nested tuple.
Returns
-------
func : callable
A function that accepts numeric input (e.g. *int* or *float*)
and returns a tuple containing the number with the leading string
*sep*. Intended to be used as the *num_func* argument to
*natsort_key*.
See Also
--------
natsort_key
"""
nan_replace = float("+inf") if alg & ns.NANLAST else float("-inf")
def func(
val: Any,
_nan_replace: float = nan_replace,
_sep: StrOrBytes = sep,
reverse: bool = nan_replace == float("+inf"),
) -> BasicTuple:
"""Given a number, place it in a tuple with a leading null string."""
# Add a trailing string numbers equaling _nan_replace. This will make
# the ordering between None NaN, and the NaN replacement value...
# None comes first, then NaN, then the replacement value.
if val != val:
return _sep, _nan_replace, "3" if reverse else "1"
elif val is None:
return _sep, _nan_replace, "2"
elif val == _nan_replace:
return _sep, _nan_replace, "1" if reverse else "3"
else:
return _sep, val
# Return the function, possibly wrapping in tuple if PATH is selected.
if alg & ns.PATH and alg & ns.UNGROUPLETTERS and alg & ns.LOCALEALPHA:
return lambda x: (((pre_sep,), func(x)),)
elif alg & ns.UNGROUPLETTERS and alg & ns.LOCALEALPHA:
return lambda x: ((pre_sep,), func(x))
elif alg & ns.PATH:
return lambda x: (func(x),)
else:
return func
def parse_string_factory(
alg: NSType,
sep: StrOrBytes,
splitter: StrSplitter,
input_transform: StrToStr,
component_transform: StrTransformer,
final_transform: FinalTransformer,
) -> StrParser:
"""
Create a function that will split and format a *str* into a tuple.
Parameters
----------
alg : ns enum
Indicate how to format and split the *str*.
sep : str
The string character to be inserted between adjacent numeric
objects in the returned tuple.
splitter : callable
A function the will accept a string and returns an iterable
of strings where the numbers are separated from the non-numbers.
input_transform : callable
A function to apply to the string input *before* applying
the *splitter* function. Must return a string.
component_transform : callable
A function that is operated elementwise on the output of
*splitter*. It must accept a single string and return either
a string or a number.
final_transform : callable
A function to operate on the return value as a whole. It
must accept a tuple and a string argument - the tuple
should be the result of applying the above functions, and the
string is the original input value. It must return a tuple.
Returns
-------
func : callable
A function that accepts string input and returns a tuple
containing the string split into numeric and non-numeric
components, where the numeric components are converted into
numeric objects. The first element is *always* a string,
and then alternates number then string. Intended to be
used as the *string_func* argument to *natsort_key*.
See Also
--------
natsort_key
input_string_transform_factory
string_component_transform_factory
final_data_transform_factory
"""
# Sometimes we store the "original" input before transformation,
# sometimes after.
orig_after_xfrm = not (alg & NS_DUMB and alg & ns.LOCALEALPHA)
original_func = input_transform if orig_after_xfrm else _no_op
normalize_input = _normalize_input_factory(alg)
compose_input = _compose_input_factory(alg) if alg & ns.LOCALEALPHA else _no_op
def func(x: PathArg) -> FinalTransform:
if isinstance(x, PurePath):
# While paths are technically not strings, it is natural for them
# to be treated the same.
x = str(x)
# Apply string input transformation function and return to x.
# Original function is usually a no-op, but some algorithms require it
# to also be the transformation function.
a = normalize_input(x)
b, original = input_transform(a), original_func(a)
c = compose_input(b) # Decompose unicode if using LOCALE
d = splitter(c) # Split string into components.
e = filter(None, d) # Remove empty strings.
f = component_transform(e) # Apply transform on components.
g = sep_inserter(f, sep) # Insert '' between numbers.
return final_transform(g, original) # Apply the final transform.
return func
def parse_path_factory(str_split: StrParser) -> PathSplitter:
"""
Create a function that will properly split and format a path.
Parameters
----------
str_split : callable
The output of the *parse_string_factory* function.
Returns
-------
func : callable
A function that accepts a string or path-like object
and splits it into its path components, then passes
each component to *str_split* and returns the result
as a nested tuple. Can be used as the *string_func*
argument to *natsort_key*.
See Also
--------
natsort_key
parse_string_factory
"""
return lambda x: tuple(map(str_split, path_splitter(x)))
def sep_inserter(iterator: Iterator[Any], sep: StrOrBytes) -> Iterator[Any]:
"""
Insert '' between numbers in an iterator.
Parameters
----------
iterator
sep : str
The string character to be inserted between adjacent numeric objects.
Yields
------
The values of *iterator* in order, with *sep* inserted where adjacent
elements are numeric. If the first element in the input is numeric
then *sep* will be the first value yielded.
"""
try:
# Get the first element. A StopIteration indicates an empty iterator.
# Since we are controlling the types of the input, 'type' is used
# instead of 'isinstance' for the small speed advantage it offers.
types = (int, float)
first = next(iterator)
if type(first) in types:
yield sep
yield first
# Now, check if pair of elements are both numbers. If so, add ''.
second = next(iterator)
if type(first) in types and type(second) in types:
yield sep
yield second
# Now repeat in a loop.
for x in iterator:
first, second = second, x
if type(first) in types and type(second) in types:
yield sep
yield second
except StopIteration:
# Catch StopIteration per deprecation in PEP 479:
# "Change StopIteration handling inside generators"
return
def input_string_transform_factory(alg: NSType) -> StrToStr:
"""
Create a function to transform a string.
Parameters
----------
alg : ns enum
Indicate how to format the *str*.
Returns
-------
func : callable
A function to be used as the *input_transform* argument to
*parse_string_factory*.
See Also
--------
parse_string_factory
"""
# Shortcuts.
lowfirst = alg & ns.LOWERCASEFIRST
dumb = alg & NS_DUMB
# Build the chain of functions to execute in order.
function_chain: List[StrToStr] = []
if (dumb and not lowfirst) or (lowfirst and not dumb):
function_chain.append(methodcaller("swapcase"))
if alg & ns.IGNORECASE:
function_chain.append(methodcaller("casefold"))
if alg & ns.LOCALENUM:
# Create a regular expression that will remove thousands separators.
strip_thousands = r"""
(?<=[0-9]{{1}}) # At least 1 number
(?<![0-9]{{4}}) # No more than 3 numbers
{nodecimal} # Cannot follow decimal
{thou} # The thousands separator
(?=[0-9]{{3}} # Three numbers must follow
([^0-9]|$) # But a non-number after that
)
"""
nodecimal = r""
if alg & ns.FLOAT:
# Make a regular expression component that will ensure no
# separators are removed after a decimal point.
d = re.escape(get_decimal_point())
nodecimal += r"(?<!" + d + r"[0-9])"
nodecimal += r"(?<!" + d + r"[0-9]{2})"
nodecimal += r"(?<!" + d + r"[0-9]{3})"
strip_thousands = strip_thousands.format(
thou=re.escape(get_thousands_sep()), nodecimal=nodecimal
)
strip_thousands_re = re.compile(strip_thousands, flags=re.VERBOSE)
function_chain.append(partial(strip_thousands_re.sub, ""))
# Create a regular expression that will change the decimal point to
# a period if not already a period.
decimal = get_decimal_point()
if alg & ns.FLOAT and decimal != ".":
switch_decimal = r"(?<=[0-9]){decimal}|{decimal}(?=[0-9])"
switch_decimal = switch_decimal.format(decimal=re.escape(decimal))
switch_decimal_re = re.compile(switch_decimal)
function_chain.append(partial(switch_decimal_re.sub, "."))
# Return the chained functions.
return chain_functions(function_chain)
def string_component_transform_factory(alg: NSType) -> StrTransformer:
"""
Create a function to either transform a string or convert to a number.
Parameters
----------
alg : ns enum
Indicate how to format the *str*.
Returns
-------
func : callable
A function to be used as the *component_transform* argument to
*parse_string_factory*.
See Also
--------
parse_string_factory
"""
# Shortcuts.
use_locale = alg & ns.LOCALEALPHA
dumb = alg & NS_DUMB
group_letters = (alg & ns.GROUPLETTERS) or (use_locale and dumb)
nan_val = float("+inf") if alg & ns.NANLAST else float("-inf")
# Build the chain of functions to execute in order.
func_chain: List[Callable[[str], StrOrBytes]] = []
if group_letters:
func_chain.append(groupletters)
if use_locale:
func_chain.append(get_strxfrm())
# Return the correct chained functions.
kwargs: Dict[str, Union[float, Callable[[str], StrOrBytes], bool]]
kwargs = {"on_fail": chain_functions(func_chain)} if func_chain else {}
kwargs["map"] = True
if alg & ns.FLOAT:
kwargs["nan"] = nan_val
return cast(StrTransformer, partial(try_float, **kwargs))
else:
return cast(StrTransformer, partial(try_int, **kwargs))
def final_data_transform_factory(
alg: NSType, sep: StrOrBytes, pre_sep: str
) -> FinalTransformer:
"""
Create a function to transform a tuple.
Parameters
----------
alg : ns enum
Indicate how to format the *str*.
sep : str
Separator that was passed to *parse_string_factory*.
pre_sep : str
String separator to insert at the at the front
of the return tuple in the case that the first element
is *sep*.
Returns
-------
func : callable
A function to be used as the *final_transform* argument to
*parse_string_factory*.
See Also
--------
parse_string_factory
"""
if alg & ns.UNGROUPLETTERS and alg & ns.LOCALEALPHA:
swap = alg & NS_DUMB and alg & ns.LOWERCASEFIRST
transform = cast(StrToStr, methodcaller("swapcase") if swap else _no_op)
def func(
split_val: Iterable[NatsortInType],
val: str,
_transform: StrToStr = transform,
_sep: StrOrBytes = sep,
_pre_sep: str = pre_sep,
) -> FinalTransform:
"""
Return a tuple with the first character of the first element
of the return value as the first element, and the return value
as the second element. This will be used to perform gross sorting
by the first letter.
"""
split_val = tuple(split_val)
if not split_val:
return (), ()
elif split_val[0] == _sep:
return (_pre_sep,), split_val
else:
return (_transform(val[0]),), split_val
else:
def func(
split_val: Iterable[NatsortInType],
val: str,
_transform: StrToStr = _no_op,
_sep: StrOrBytes = sep,
_pre_sep: str = pre_sep,
) -> FinalTransform:
return tuple(split_val)
return func
lower_function: StrToStr = cast(StrToStr, methodcaller("casefold"))
# noinspection PyIncorrectDocstring
def groupletters(x: str, _low: StrToStr = lower_function) -> str:
"""
Double all characters, making doubled letters lowercase.
Parameters
----------
x : str
Returns
-------
str
Examples
--------
>>> groupletters("Apple")
'aAppppllee'
"""
return "".join(ichain.from_iterable((_low(y), y) for y in x))
def chain_functions(functions: Iterable[AnyCall]) -> AnyCall:
"""
Chain a list of single-argument functions together and return.
The functions are applied in list order, and the output of the
previous functions is passed to the next function.
Parameters
----------
functions : list
A list of single-argument functions to chain together.
Returns
-------
func : callable
A single argument function.
Examples
--------
Chain several functions together!
>>> funcs = [lambda x: x * 4, len, lambda x: x + 5]
>>> func = chain_functions(funcs)
>>> func('hey')
17
"""
functions = list(functions)
if not functions:
return _no_op
elif len(functions) == 1:
return functions[0]
else:
# See https://stackoverflow.com/a/39123400/1399279
return partial(reduce, lambda res, f: f(res), functions)
@overload
def do_decoding(s: bytes, encoding: str) -> str:
...
@overload
def do_decoding(s: Any, encoding: str) -> Any:
...
def do_decoding(s: Any, encoding: str) -> Any:
"""
Helper to decode a *bytes* object, or return the object as-is.
Parameters
----------
s : bytes | object
encoding : str
The encoding to use to decode *s*.
Returns
-------
decoded
*str* if *s* was *bytes* and the decoding was successful.
*s* if *s* was not *bytes*.
"""
if isinstance(s, bytes):
return s.decode(encoding)
else:
return s
# noinspection PyIncorrectDocstring
def path_splitter(
s: PathArg, treat_base: bool = True, _d_match: MatchFn = re.compile(r"\.\d").match
) -> Iterator[str]:
"""
Split a string into its path components.
Assumes a string is a path or is path-like.
Parameters
----------
s : str | pathlib.Path
treat_base: bool, optional
If True, treat the base of component of the file path as
special and split off extensions. If False, do not do this.
The default is True.
Returns
-------
split : tuple
The path split by directory components and extensions.
Examples
--------
>>> tuple(path_splitter("this/thing.ext"))
('this', 'thing', '.ext')
"""
if not isinstance(s, PurePath):
s = PurePath(s)
# Split the path into parts.
try:
*path_parts, base = s.parts
except ValueError:
path_parts = []
base = str(s)
suffixes = []
if treat_base:
# Now, split off the file extensions until
# - we reach a decimal number at the beginning of the suffix
# - more than two suffixes have been seen
# - a suffix is more than five characters (including leading ".")
# - there are no more extensions
for i, suffix in enumerate(reversed(PurePath(base).suffixes)):
if _d_match(suffix) or i > 1 or len(suffix) > 5:
break
suffixes.append(suffix)
suffixes.reverse()
# Remove the suffixes from the base component
base = base.replace("".join(suffixes), "")
base_component = [base] if base else []
# Join all path comonents in an iterator
return filter(None, ichain(path_parts, base_component, suffixes))