Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/meta/extensible-types' into meta…
Browse files Browse the repository at this point in the history
…/new-internal-repr
  • Loading branch information
arthur-debert committed Dec 6, 2024
2 parents 8e4f5e6 + ac13a24 commit 66417e3
Show file tree
Hide file tree
Showing 30 changed files with 885 additions and 131 deletions.
120 changes: 102 additions & 18 deletions docs/ranges.rst
Original file line number Diff line number Diff line change
@@ -1,46 +1,130 @@
.. _ranges:

Ranges
======
######

The `Rangy` class provides a flexible way to represent counts, including both closed ranges and open-ended ranges. It also allows specifying an exact count as a special case of a closed range where the minimum and maximum values are the same.

Ranges are a tuple of boundaries: the min and max. The min and max can be any integer, including negative values. The min and max are always inclusive. As we'l see bellow, the min and the max can be the, in which case we have a singular range.


The special classes reprent unbounded boundaries:
**Any** : "*"
Represent from zero to infinity

**At least one**: "+"

If a range has one of these two values, it is considered an open range.


Singular Ranges
****************

Singular ranges do sound like a joke, or they should. What we call singular ranges are ranges that represent a single value.
They only exist to provide a consistent interface with the rest of the library.

In some cases, it's really useful to do stuff on ranges and numbers, and doing so with one interface is really useful.

Singular ranges are an abstraction that allows you to treat a single value as a range.
Some key facts about singular ranges:
- They are always inclusive.
- Internally, they are represented as a tuple with the same value twice, i.e. min = max.
- They may be open or closed ranges.

Meaning
=======
If open singular ranges, the meaning is:
* "+" anything greater than one.
* "*" anything greater or equal to one.

Representations:
================
* **Integer representation:** Simply provide an integer. `Rangy(4)` represents an exact count of 4.

* **String representation:** Provide a string representation of the integer. `Rangy("4")` is equivalent to `Rangy(4)`.

* **Tuple representation:** Provide a tuple with identical integer values. `Rangy((4, 4))` is equivalent to `Rangy(4)`. Mixed type tuples like `("4", 4)` are also supported. This form is primarily for consistency, as using the integer or string form directly is generally simpler for exact counts.

Validation
==========
Heres a list of valid and invalid values for a singular range. What we mean if Rangy("x").validates(y) should return:

* `Rangy(4)```:
* **True**: 4
* **False** : 3, 5, anything else
* `Rangy("+")`:
* **True**: 1,3,4, 3043424324
* **False**: 0, -1, -30

Closed Ranges
-------------
*************

Closed ranges specify a minimum and maximum inclusive count. They can be created using several formats:

* **String representation:** `"min-max"` where `min` and `max` are integers. Hyphens, commas, colons, and semicolons can be used interchangeably as separators. For example, `"2-4"`, `"2,4"`, `"2:4"`, and `"2;4"` are all equivalent and represent a count that must be between 2 and 4 (inclusive).

* **Tuple representation:** `(min, max)` where `min` and `max` are integers. For example, `(2, 4)` also represents a count between 2 and 4 (inclusive). Mixed type tuples like `("2", 4)` are also supported.
Meaning
=======
Closed ranges means a well defined min and max, and for int, a finite number of values.
The values are always inclusive.

Representations:
================

Open Ranges
-----------
* **String representation:** `"min-max"` where `min` and `max` are integers. Hyphens, commas, colons, and semicolons can be used interchangeably as separators. For example, `"2-4"`, `"2,4"`, `"2:4"`, and `"2;4"` are all equivalent and represent a count that must be between 2 and 4 (inclusive).

Open ranges allow for unbounded maximums. These are represented using the following formats:
* **Tuple representation:** `(min, max)` where `min` and `max` are integers. For example, `(2, 4)` also represents a count between 2 and 4 (inclusive). Mixed type tuples like `("2", 4)` are also supported.

* **`*`**: Represents *any* count (0 or greater). Equivalent to "0-\*".
Validation
==========
Heres a list of valid and invalid values for a closed ranges. What we mean if Rangy("x").validates(y) should return:

* **`+`**: Represents a count of at least one (1 or greater). Equivalent to "1-\*".
* `Rangy(3, 5)```:
* **True**: 3, 4, 5
* **False** : 1,2,6,7, anything else
* `Rangy("3-4")`:
* **True**: 3, 4
* **False**: 1,2,5,6, anything else

* **String representation with open maximum:** `"min-*"` or `"min+"` where `min` represents the minimum allowed count. For example, `"2-*"` specifies a count of 2 or more. `"2+"` is equivalent. Likewise `"*-3"` is equivalent to "0-3", and `"+-3"` is equivalent to "1-3".
Open Ranges
************

* **Tuple representation with open maximum:** `(min, "*")` or `(min, "+")` where `min` represents the minimum allowed count. For example `(2, "*")` specifies a count of 2 or more. `(2, "+")` is equivalent. Mixed type tuples like `("2", "*")` are also supported.
Either the min or the max can be open-ended.

Meaning
=======

As the min boundary:

Exact Counts (Identity Ranges)
------------------------------
* "+" means more than one up to smaller and equal to the end.
* "*" means any number smaller or equal to the end.

You can use `Rangy` to represent an exact count by specifying a closed range where the minimum and maximum values are the same.
As the max boundary:
* "+" means one more than the min up to infinity.
* "*" means any number greater or equal to the min.

* **Integer representation:** Simply provide an integer. `Rangy(4)` represents an exact count of 4.
Representations:
================
* **String representation with open maximum:** `"min-*"` or `"min+"` where `min` represents the minimum allowed count. For example, `"2-*"` specifies a count of 2 or more. `"2+"` means is equivalent. Likewise `"*-3"` is equivalent to "0-3", and `"+-3"` is equivalent to "1-3".

* **String representation:** Provide a string representation of the integer. `Rangy("4")` is equivalent to `Rangy(4)`.
* **Tuple representation with open maximum:** `(min, "*")` or `(min, "+")` where `min` represents the minimum allowed count. For example `(2, "*")` specifies a count of 2 or more. `(2, "+")` is equivalent. Mixed type tuples like `("2", "*")` are also supported.

* **Tuple representation:** Provide a tuple with identical integer values. `Rangy((4, 4))` is equivalent to `Rangy(4)`. Mixed type tuples like `("4", 4)` are also supported. This form is primarily for consistency, as using the integer or string form directly is generally simpler for exact counts.
Validation
==========
Heres a list of valid and invalid values for a closed ranges. What we mean if Rangy("x").validates(y) should return:
We'll show min any, max any, min at least one, max at least one


* `Rangy("*", 10)`:
* **True**: 0 through 10.
* **False** : > 11
* `Rangy("3-*")`:
* **True**: 3, 4 anything greater
* **False**: 1,2
* `Rangy("+", 10)`:
* **True**: 1 through 10.
* **False** : 0 and > 11
* `Rangy("3-+")`:
* **True**: 4 anything greater
* **False**: 1,2


Examples
Expand Down
13 changes: 10 additions & 3 deletions rangy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
from .const import ANY, AT_LEAST_ONE, EXACT, RANGE, COUNT_TYPES, INFINITY, ANY_CHAR, ONE_PLUS_CHAR
from .rangy import Rangy, _parse
from .distribute import distribute
from .const import ANY, AT_LEAST_ONE, EXACT, RANGE, COUNT_TYPES, INFINITY, ANY_CHAR, AT_LEAST_ONE_CHAR, SPECIAL_CHARS
from .converters import Converter
from .registry import ConverterRegistry
from .builtins import register_builtins
from .rangy import Rangy
from .distribute import distribute
from .parse import parse_range, _normalize_to_sequence, _convert_part

register_builtins()
__all__ = ["ANY", "AT_LEAST_ONE", "EXACT", "RANGE", "Rangy", "parse_range", "distribute", "Converter", "ConverterRegistry", "COUNT_TYPES", "INFINITY", "ANY_CHAR", "AT_LEAST_ONE_CHAR", "SPECIAL_CHARS", "_normalize_to_sequence", "_convert_part"]
15 changes: 15 additions & 0 deletions rangy/builtins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
from rangy import Converter, ConverterRegistry

# Create converters for built-in types

def register_builtins():
int_converter = Converter(int)
float_converter = Converter(float)
converters = [int_converter, float_converter]
for converter in converters:
try:
ConverterRegistry.get(converter._type)
except KeyError:
ConverterRegistry.register(converter)

register_builtins()
6 changes: 5 additions & 1 deletion rangy/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@
COUNT_TYPES = ( EXACT, RANGE, ANY, AT_LEAST_ONE, )
INFINITY = 1000000
ANY_CHAR = "*"
ONE_PLUS_CHAR = "+"
AT_LEAST_ONE_CHAR = "+"
SPECIAL_CHARS = {
ANY: ANY_CHAR,
AT_LEAST_ONE: AT_LEAST_ONE_CHAR,
}
98 changes: 98 additions & 0 deletions rangy/converters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
class Converter():
"""
Facilitates conversion of custom data types to numeric and string representations for use within the `Rangy` system.
The `Rangy` system works primarily with numeric ranges. This `Converter` class allows you to seamlessly integrate custom data types that might not inherently have numeric or string representations suitable for range comparisons. It bridges the gap by providing conversion logic to and from numeric and string forms.
Args:
_type (type): The custom data type that this converter handles. This is used for type lookup in the `ConverterRegistry`.
to_numeric (callable, optional): A function that takes an instance of your custom type and returns a numeric value (int or float). If not provided, the converter attempts to cast the value to float, then int. Defaults to None.
to_string (callable, optional): A function that takes an instance of your custom type and returns its string representation. If not provided, the built-in `str()` is used. Defaults to None.
Methods:
to_number(value): Converts a value of the custom type to a numeric representation. Uses the `to_numeric` function if provided, otherwise attempts direct casting.
to_str(value): Converts a value of the custom type to a string representation. Uses the `to_string` function if provided, otherwise uses the built-in `str()`.
__call__(value): Makes instances of the `Converter` callable. Equivalent to calling `to_number(value)`. This allows convenient use within other parts of the `Rangy` system.
__float__(value): Similar to `to_number`, but specifically attempts to return a float representation. Primarily used internally.
__int__(value): Similar to `to_number`, but specifically attempts to return an integer representation. Primarily used internally.
__str__(value): Synonymous with `to_str(value)`. Used for string conversions, mainly internally.
Example:
```python
from rangy import Converter, ConverterRegistry, Rangy, parse_range
class CustomType:
def __init__(self, value):
self.value = value
# Define conversion functions
def custom_to_numeric(custom_obj):
return custom_obj.value
def custom_to_string(custom_obj):
return f"CustomValue({custom_obj.value})"
# Create and register the converter
custom_converter = Converter(CustomType, custom_to_numeric, custom_to_string)
ConverterRegistry.register(custom_converter)
# Now you can use CustomType in ranges:
range_tuple = parse_range(("1", CustomType(5)))
print(range_tuple) # output: (1, 5)
range_tuple = parse_range((CustomType(2), "4"))
print(range_tuple) # output (2, 4)
rangy_obj = Rangy((CustomType(1), CustomType(3)))
print(2 in rangy_obj) # output: True
```
Raises:
ValueError: If the input value cannot be converted to a number (when `to_numeric` fails or direct casting is unsuccessful).
"""

def __init__(self, _type, to_numeric=None, to_string=None):
self._type = _type
self.to_numeric = to_numeric
self.to_string = to_string

def to_number(self, value):
if self.to_numeric:
return self.to_numeric(value)
try:
return float(value)
except ValueError:
try:
return int(value)
except ValueError:
raise ValueError("Could not convert value to number")

def to_str(self, value):
if self.to_string:
return self.to_string(value)
return str(value)

def __float__(self, value): # pragma: no cover
return self.to_number(value)

def __int__(self, value):# pragma: no cover
return self.to_number(value)

def __str__(self, value): # pragma: no cover
return self.to_str(value)

def __call__(self, value): # pragma: no cover
return self.to_number(value)

4 changes: 2 additions & 2 deletions rangy/distribute.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ def distribute(items: List[Any], rangys: List[Rangy], separator: str = SEPERATOR
item_index = 0

for i, take in enumerate(takes):
result[i].extend(items[item_index:item_index + take])
item_index += take
result[i].extend(items[item_index:int(item_index + take)])
item_index += int(take)
# Validate AND check if we've ignored some arguments.
if item_index != len(items):
raise ValueError(f"Too many arguments provided. {len(items) - item_index} extra argument(s) found.")
Expand Down
4 changes: 4 additions & 0 deletions rangy/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

class ParseRangeError(ValueError):
"""Raised when a range string cannot be parsed."""
pass
Loading

0 comments on commit 66417e3

Please sign in to comment.