morphir-python 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- morphir/__init__.py +14 -0
- morphir/ir/__init__.py +110 -0
- morphir/ir/access_controlled.py +15 -0
- morphir/ir/decorations.py +19 -0
- morphir/ir/diagnostics.py +105 -0
- morphir/ir/distribution.py +21 -0
- morphir/ir/document.py +37 -0
- morphir/ir/documented.py +11 -0
- morphir/ir/format_version.py +483 -0
- morphir/ir/fqname.py +9 -0
- morphir/ir/json.py +11 -0
- morphir/ir/literal.py +25 -0
- morphir/ir/meta.py +11 -0
- morphir/ir/migrate.py +1267 -0
- morphir/ir/module.py +17 -0
- morphir/ir/name.py +27 -0
- morphir/ir/package.py +15 -0
- morphir/ir/path.py +25 -0
- morphir/ir/qname.py +9 -0
- morphir/ir/ref.py +17 -0
- morphir/ir/type.py +37 -0
- morphir/ir/type_constraints.py +33 -0
- morphir/ir/type_def.py +19 -0
- morphir/ir/type_spec.py +25 -0
- morphir/ir/v3/__init__.py +64 -0
- morphir/ir/v3/_legacy_value.py +201 -0
- morphir/ir/v3/access_controlled.py +31 -0
- morphir/ir/v3/codec.py +1275 -0
- morphir/ir/v3/codec_v1.py +491 -0
- morphir/ir/v3/codec_v2.py +187 -0
- morphir/ir/v3/distribution.py +42 -0
- morphir/ir/v3/documented.py +13 -0
- morphir/ir/v3/fqname.py +30 -0
- morphir/ir/v3/literal.py +46 -0
- morphir/ir/v3/module.py +32 -0
- morphir/ir/v3/name.py +42 -0
- morphir/ir/v3/package.py +23 -0
- morphir/ir/v3/path.py +29 -0
- morphir/ir/v3/qname.py +16 -0
- morphir/ir/v3/type.py +126 -0
- morphir/ir/v3/value.py +235 -0
- morphir/ir/v4/__init__.py +160 -0
- morphir/ir/v4/access_controlled.py +17 -0
- morphir/ir/v4/annotation.py +75 -0
- morphir/ir/v4/codec.py +2161 -0
- morphir/ir/v4/decorations.py +88 -0
- morphir/ir/v4/distribution.py +56 -0
- morphir/ir/v4/document.py +69 -0
- morphir/ir/v4/documented.py +10 -0
- morphir/ir/v4/fqname.py +69 -0
- morphir/ir/v4/incompleteness.py +78 -0
- morphir/ir/v4/json.py +103 -0
- morphir/ir/v4/literal.py +51 -0
- morphir/ir/v4/meta.py +27 -0
- morphir/ir/v4/module.py +30 -0
- morphir/ir/v4/name.py +69 -0
- morphir/ir/v4/package.py +19 -0
- morphir/ir/v4/path.py +50 -0
- morphir/ir/v4/qname.py +34 -0
- morphir/ir/v4/ref.py +35 -0
- morphir/ir/v4/type.py +140 -0
- morphir/ir/v4/type_constraints.py +91 -0
- morphir/ir/v4/type_def.py +38 -0
- morphir/ir/v4/type_spec.py +64 -0
- morphir/ir/v4/value.py +480 -0
- morphir/ir/value.py +81 -0
- morphir/py.typed +0 -0
- morphir/sdk/__init__.py +110 -0
- morphir/sdk/_compare.py +187 -0
- morphir/sdk/aggregate.py +421 -0
- morphir/sdk/basics.py +504 -0
- morphir/sdk/char.py +110 -0
- morphir/sdk/decimal.py +311 -0
- morphir/sdk/dict.py +336 -0
- morphir/sdk/instant.py +118 -0
- morphir/sdk/int.py +86 -0
- morphir/sdk/key.py +522 -0
- morphir/sdk/list.py +356 -0
- morphir/sdk/local_date.py +436 -0
- morphir/sdk/local_time.py +267 -0
- morphir/sdk/maybe.py +135 -0
- morphir/sdk/number.py +237 -0
- morphir/sdk/regex.py +287 -0
- morphir/sdk/result.py +162 -0
- morphir/sdk/result_list.py +169 -0
- morphir/sdk/rule.py +103 -0
- morphir/sdk/set.py +214 -0
- morphir/sdk/stateful_app.py +49 -0
- morphir/sdk/string.py +427 -0
- morphir/sdk/tuple.py +43 -0
- morphir/sdk/uuid.py +209 -0
- morphir_python-0.0.1.dist-info/METADATA +269 -0
- morphir_python-0.0.1.dist-info/RECORD +99 -0
- morphir_python-0.0.1.dist-info/WHEEL +4 -0
- morphir_python-0.0.1.dist-info/entry_points.txt +2 -0
- morphir_python-0.0.1.dist-info/licenses/LICENSE +201 -0
- morphir_tools/__init__.py +13 -0
- morphir_tools/cli.py +223 -0
- morphir_tools/py.typed +0 -0
morphir/sdk/maybe.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Morphir.SDK.Maybe: a value that may or may not exist.
|
|
2
|
+
|
|
3
|
+
`Maybe[A]` is the union of two frozen dataclasses, `Just[A]` and `Nothing`. Use
|
|
4
|
+
structural pattern matching to take a `Maybe` apart:
|
|
5
|
+
|
|
6
|
+
>>> from morphir.sdk.maybe import Just, Maybe, Nothing
|
|
7
|
+
>>> def describe(m: Maybe[int]) -> str:
|
|
8
|
+
... match m:
|
|
9
|
+
... case Just(value):
|
|
10
|
+
... return f"just {value}"
|
|
11
|
+
... case Nothing():
|
|
12
|
+
... return "nothing"
|
|
13
|
+
>>> describe(Just(1))
|
|
14
|
+
'just 1'
|
|
15
|
+
|
|
16
|
+
All functions take their arguments in Elm order, with the `Maybe` last.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Just",
|
|
27
|
+
"Maybe",
|
|
28
|
+
"Nothing",
|
|
29
|
+
"and_then",
|
|
30
|
+
"has_value",
|
|
31
|
+
"map",
|
|
32
|
+
"map2",
|
|
33
|
+
"map3",
|
|
34
|
+
"map4",
|
|
35
|
+
"map5",
|
|
36
|
+
"with_default",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class Just[A]:
|
|
42
|
+
"""A `Maybe` that holds a value."""
|
|
43
|
+
|
|
44
|
+
value: A
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class Nothing:
|
|
49
|
+
"""A `Maybe` that holds no value. All instances are equal."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
type Maybe[A] = Just[A] | Nothing
|
|
53
|
+
"""Either `Just[A]` or `Nothing`."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def with_default[A](default: A, maybe: Maybe[A]) -> A:
|
|
57
|
+
"""Return the value of a `Just`, or the default for `Nothing`."""
|
|
58
|
+
match maybe:
|
|
59
|
+
case Just(value):
|
|
60
|
+
return value
|
|
61
|
+
case Nothing():
|
|
62
|
+
return default
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def has_value[A](maybe: Maybe[A]) -> bool:
|
|
66
|
+
"""Check that a `Maybe` is a `Just`. This is a Morphir addition to elm/core."""
|
|
67
|
+
return isinstance(maybe, Just)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def map[A, B](f: Callable[[A], B], maybe: Maybe[A]) -> Maybe[B]:
|
|
71
|
+
"""Apply a function to the value of a `Just`."""
|
|
72
|
+
if isinstance(maybe, Just):
|
|
73
|
+
return Just(f(maybe.value))
|
|
74
|
+
return Nothing()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def map2[A, B, R](f: Callable[[A, B], R], ma: Maybe[A], mb: Maybe[B]) -> Maybe[R]:
|
|
78
|
+
"""Apply a function when both arguments are `Just`."""
|
|
79
|
+
if isinstance(ma, Just) and isinstance(mb, Just):
|
|
80
|
+
return Just(f(ma.value, mb.value))
|
|
81
|
+
return Nothing()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def map3[A, B, C, R](
|
|
85
|
+
f: Callable[[A, B, C], R], ma: Maybe[A], mb: Maybe[B], mc: Maybe[C]
|
|
86
|
+
) -> Maybe[R]:
|
|
87
|
+
"""Apply a function when all three arguments are `Just`."""
|
|
88
|
+
if isinstance(ma, Just) and isinstance(mb, Just) and isinstance(mc, Just):
|
|
89
|
+
return Just(f(ma.value, mb.value, mc.value))
|
|
90
|
+
return Nothing()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def map4[A, B, C, D, R](
|
|
94
|
+
f: Callable[[A, B, C, D], R],
|
|
95
|
+
ma: Maybe[A],
|
|
96
|
+
mb: Maybe[B],
|
|
97
|
+
mc: Maybe[C],
|
|
98
|
+
md: Maybe[D],
|
|
99
|
+
) -> Maybe[R]:
|
|
100
|
+
"""Apply a function when all four arguments are `Just`."""
|
|
101
|
+
if (
|
|
102
|
+
isinstance(ma, Just)
|
|
103
|
+
and isinstance(mb, Just)
|
|
104
|
+
and isinstance(mc, Just)
|
|
105
|
+
and isinstance(md, Just)
|
|
106
|
+
):
|
|
107
|
+
return Just(f(ma.value, mb.value, mc.value, md.value))
|
|
108
|
+
return Nothing()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def map5[A, B, C, D, E, R](
|
|
112
|
+
f: Callable[[A, B, C, D, E], R],
|
|
113
|
+
ma: Maybe[A],
|
|
114
|
+
mb: Maybe[B],
|
|
115
|
+
mc: Maybe[C],
|
|
116
|
+
md: Maybe[D],
|
|
117
|
+
me: Maybe[E],
|
|
118
|
+
) -> Maybe[R]:
|
|
119
|
+
"""Apply a function when all five arguments are `Just`."""
|
|
120
|
+
if (
|
|
121
|
+
isinstance(ma, Just)
|
|
122
|
+
and isinstance(mb, Just)
|
|
123
|
+
and isinstance(mc, Just)
|
|
124
|
+
and isinstance(md, Just)
|
|
125
|
+
and isinstance(me, Just)
|
|
126
|
+
):
|
|
127
|
+
return Just(f(ma.value, mb.value, mc.value, md.value, me.value))
|
|
128
|
+
return Nothing()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def and_then[A, B](f: Callable[[A], Maybe[B]], maybe: Maybe[A]) -> Maybe[B]:
|
|
132
|
+
"""Chain a computation that can fail onto a `Maybe`."""
|
|
133
|
+
if isinstance(maybe, Just):
|
|
134
|
+
return f(maybe.value)
|
|
135
|
+
return Nothing()
|
morphir/sdk/number.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Morphir.SDK.Number: exact rational numbers.
|
|
2
|
+
|
|
3
|
+
A `Number` is a frozen dataclass with an integer `numerator` and `denominator`.
|
|
4
|
+
Python integers have no size limit, so all arithmetic is exact.
|
|
5
|
+
|
|
6
|
+
As in the Elm runtime, arithmetic does not reduce its result:
|
|
7
|
+
`add(1/2, 1/2)` is `4/4`. Use `simplify` to reduce a number. Because of this,
|
|
8
|
+
`==` on two `Number` values is structural (`Number(1, 2) != Number(2, 4)`); use
|
|
9
|
+
`equal` to compare by value.
|
|
10
|
+
|
|
11
|
+
The name `abs` in this module shadows a Python built-in. Import the module with
|
|
12
|
+
an alias, not the names:
|
|
13
|
+
|
|
14
|
+
>>> from morphir.sdk import number as Number
|
|
15
|
+
>>> Number.to_fractional_string(Number.add(Number.from_int(1), Number.one))
|
|
16
|
+
'2/1'
|
|
17
|
+
|
|
18
|
+
Departures from the Elm runtime:
|
|
19
|
+
|
|
20
|
+
* Comparisons move a negative sign out of the denominator before they
|
|
21
|
+
cross-multiply. The Elm runtime does not, so it orders `3/-4` and `1/2`
|
|
22
|
+
wrongly.
|
|
23
|
+
* `simplify` yields a positive denominator: `3/-6` becomes `-1/2`. The Elm
|
|
24
|
+
runtime yields `1/-2`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import builtins
|
|
28
|
+
import math
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
from morphir.sdk import decimal as _sdk_decimal
|
|
32
|
+
from morphir.sdk import maybe
|
|
33
|
+
from morphir.sdk.maybe import Just, Maybe, Nothing
|
|
34
|
+
from morphir.sdk.result import Err, Ok, Result
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"DivisionByZero",
|
|
38
|
+
"Number",
|
|
39
|
+
"abs",
|
|
40
|
+
"add",
|
|
41
|
+
"coerce_to_decimal",
|
|
42
|
+
"divide",
|
|
43
|
+
"equal",
|
|
44
|
+
"from_int",
|
|
45
|
+
"greater_than",
|
|
46
|
+
"greater_than_or_equal",
|
|
47
|
+
"is_simplified",
|
|
48
|
+
"less_than",
|
|
49
|
+
"less_than_or_equal",
|
|
50
|
+
"multiply",
|
|
51
|
+
"negate",
|
|
52
|
+
"not_equal",
|
|
53
|
+
"one",
|
|
54
|
+
"reciprocal",
|
|
55
|
+
"simplify",
|
|
56
|
+
"subtract",
|
|
57
|
+
"to_decimal",
|
|
58
|
+
"to_fractional_string",
|
|
59
|
+
"zero",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class Number:
|
|
65
|
+
"""A rational number. The fraction is not reduced."""
|
|
66
|
+
|
|
67
|
+
numerator: int
|
|
68
|
+
denominator: int
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class DivisionByZero:
|
|
73
|
+
"""The error that `divide` yields for a zero divisor."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
zero: Number = Number(0, 1)
|
|
77
|
+
"""The number 0, as 0/1."""
|
|
78
|
+
|
|
79
|
+
one: Number = Number(1, 1)
|
|
80
|
+
"""The number 1, as 1/1."""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def from_int(n: int) -> Number:
|
|
84
|
+
"""Convert an integer to a `Number` with a denominator of 1."""
|
|
85
|
+
return Number(n, 1)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Convert to
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def to_decimal(n: Number) -> Maybe[_sdk_decimal.Decimal]:
|
|
92
|
+
"""Convert to a `Decimal`, or `Nothing` when the denominator is zero.
|
|
93
|
+
|
|
94
|
+
A quotient that is not exact has 50 significant digits.
|
|
95
|
+
"""
|
|
96
|
+
return _sdk_decimal.div(
|
|
97
|
+
_sdk_decimal.from_int(n.numerator), _sdk_decimal.from_int(n.denominator)
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def coerce_to_decimal(default: _sdk_decimal.Decimal, n: Number) -> _sdk_decimal.Decimal:
|
|
102
|
+
"""Convert to a `Decimal`, or yield the default when the denominator is zero."""
|
|
103
|
+
return maybe.with_default(default, to_decimal(n))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def to_fractional_string(n: Number) -> str:
|
|
107
|
+
"""Convert to a string of the form "numerator/denominator"."""
|
|
108
|
+
return f"{n.numerator}/{n.denominator}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Comparison
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _cross_products(a: Number, b: Number) -> tuple[int, int]:
|
|
115
|
+
"""Return the two integers whose order is the order of `a` and `b`.
|
|
116
|
+
|
|
117
|
+
A negative denominator changes the direction of the comparison, so the sign
|
|
118
|
+
moves to the numerator first.
|
|
119
|
+
"""
|
|
120
|
+
sign = (-1 if a.denominator < 0 else 1) * (-1 if b.denominator < 0 else 1)
|
|
121
|
+
return (
|
|
122
|
+
sign * a.numerator * b.denominator,
|
|
123
|
+
sign * b.numerator * a.denominator,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def equal(a: Number, b: Number) -> bool:
|
|
128
|
+
"""Check `a == b` by value, so 1/2 equals 2/4."""
|
|
129
|
+
x, y = _cross_products(a, b)
|
|
130
|
+
return x == y
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def not_equal(a: Number, b: Number) -> bool:
|
|
134
|
+
"""Check `a /= b` by value."""
|
|
135
|
+
return not equal(a, b)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def less_than(a: Number, b: Number) -> bool:
|
|
139
|
+
"""Check `a < b`."""
|
|
140
|
+
x, y = _cross_products(a, b)
|
|
141
|
+
return x < y
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def less_than_or_equal(a: Number, b: Number) -> bool:
|
|
145
|
+
"""Check `a <= b`."""
|
|
146
|
+
x, y = _cross_products(a, b)
|
|
147
|
+
return x <= y
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def greater_than(a: Number, b: Number) -> bool:
|
|
151
|
+
"""Check `a > b`."""
|
|
152
|
+
x, y = _cross_products(a, b)
|
|
153
|
+
return x > y
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def greater_than_or_equal(a: Number, b: Number) -> bool:
|
|
157
|
+
"""Check `a >= b`."""
|
|
158
|
+
x, y = _cross_products(a, b)
|
|
159
|
+
return x >= y
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# Arithmetic
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _is_zero(n: Number) -> bool:
|
|
166
|
+
return n.numerator == 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def negate(n: Number) -> Number:
|
|
170
|
+
"""Change the sign of a number."""
|
|
171
|
+
return Number(-n.numerator, n.denominator)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def abs(n: Number) -> Number:
|
|
175
|
+
"""Return the absolute value of a number."""
|
|
176
|
+
return Number(builtins.abs(n.numerator), builtins.abs(n.denominator))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def reciprocal(n: Number) -> Number:
|
|
180
|
+
"""Swap the numerator and the denominator. Zero stays as it is, as in Elm."""
|
|
181
|
+
return n if _is_zero(n) else Number(n.denominator, n.numerator)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def add(a: Number, b: Number) -> Number:
|
|
185
|
+
"""Add two numbers. The result is not reduced."""
|
|
186
|
+
return Number(
|
|
187
|
+
a.numerator * b.denominator + a.denominator * b.numerator,
|
|
188
|
+
a.denominator * b.denominator,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def subtract(a: Number, b: Number) -> Number:
|
|
193
|
+
"""Subtract the second number from the first. The result is not reduced."""
|
|
194
|
+
return Number(
|
|
195
|
+
a.numerator * b.denominator - a.denominator * b.numerator,
|
|
196
|
+
a.denominator * b.denominator,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def multiply(a: Number, b: Number) -> Number:
|
|
201
|
+
"""Multiply two numbers. The result is not reduced."""
|
|
202
|
+
return Number(a.numerator * b.numerator, a.denominator * b.denominator)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def divide(a: Number, b: Number) -> Result[DivisionByZero, Number]:
|
|
206
|
+
"""Divide two numbers, or yield `Err(DivisionByZero())` for a zero divisor.
|
|
207
|
+
|
|
208
|
+
The result is not reduced.
|
|
209
|
+
"""
|
|
210
|
+
if _is_zero(b):
|
|
211
|
+
return Err(DivisionByZero())
|
|
212
|
+
return Ok(Number(a.numerator * b.denominator, a.denominator * b.numerator))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Simplify
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def simplify(n: Number) -> Maybe[Number]:
|
|
219
|
+
"""Reduce a number to lowest terms, with a positive denominator.
|
|
220
|
+
|
|
221
|
+
Yields `Nothing` when the denominator is zero.
|
|
222
|
+
"""
|
|
223
|
+
if n.denominator == 0:
|
|
224
|
+
return Nothing()
|
|
225
|
+
factor = math.gcd(n.numerator, n.denominator)
|
|
226
|
+
if n.denominator < 0:
|
|
227
|
+
factor = -factor
|
|
228
|
+
return Just(Number(n.numerator // factor, n.denominator // factor))
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def is_simplified(n: Number) -> bool:
|
|
232
|
+
"""Check that `simplify` does not change a number.
|
|
233
|
+
|
|
234
|
+
A number with a zero denominator counts as simplified, as in the Elm
|
|
235
|
+
runtime.
|
|
236
|
+
"""
|
|
237
|
+
return maybe.with_default(n, simplify(n)) == n
|
morphir/sdk/regex.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
r"""Morphir.SDK.Regex: regular expressions.
|
|
2
|
+
|
|
3
|
+
The reference is the API of elm/regex 1.0.0. A `Regex` holds a compiled pattern
|
|
4
|
+
of the standard library `re` module. Lists are tuples, as in `morphir.sdk.list`.
|
|
5
|
+
|
|
6
|
+
>>> from morphir.sdk import regex as Regex
|
|
7
|
+
>>> from morphir.sdk import maybe as Maybe
|
|
8
|
+
>>> comma = Maybe.with_default(Regex.never, Regex.from_string(" *, *"))
|
|
9
|
+
>>> Regex.split(comma, "tom , 99, 90")
|
|
10
|
+
('tom', '99', '90')
|
|
11
|
+
>>> Regex.replace(comma, lambda match: ";", "tom , 99, 90")
|
|
12
|
+
'tom;99;90'
|
|
13
|
+
|
|
14
|
+
Departures from elm/regex:
|
|
15
|
+
|
|
16
|
+
* **Pattern syntax.** elm/regex compiles a pattern with the JavaScript `RegExp`
|
|
17
|
+
(with no `u` flag). This module compiles it with Python `re`. Most patterns
|
|
18
|
+
mean the same in the two, but not all. The known differences are:
|
|
19
|
+
|
|
20
|
+
* A named group is `(?P<name>...)` and a reference to it is `(?P=name)`. The
|
|
21
|
+
JavaScript forms `(?<name>...)` and `\k<name>` are not valid.
|
|
22
|
+
* `\d`, `\w`, `\s` and `\b` use the Unicode character properties. In
|
|
23
|
+
JavaScript `\d` and `\w` match ASCII characters only. Write `[0-9]` for the
|
|
24
|
+
ASCII digits.
|
|
25
|
+
* `case_insensitive` uses the Unicode case rules.
|
|
26
|
+
* `$` with no `multiline` option also matches before a line feed at the end
|
|
27
|
+
of the text. Use `\Z` for the end of the text only.
|
|
28
|
+
* Python rejects an escape of an ASCII letter that it does not know, such as
|
|
29
|
+
the JavaScript control escape `\cJ`. The JavaScript classes `[^]` and `[]`
|
|
30
|
+
are not valid.
|
|
31
|
+
* A lookbehind must have a fixed width.
|
|
32
|
+
* A pattern that JavaScript accepts can therefore give `Nothing` here, and the
|
|
33
|
+
reverse is also possible.
|
|
34
|
+
|
|
35
|
+
* **Index.** `Match.index` counts Unicode code points, as all string functions
|
|
36
|
+
in this SDK do. In Elm it counts UTF-16 code units.
|
|
37
|
+
* **Empty matches in `split`.** In Elm, `split` does not stop when the pattern
|
|
38
|
+
matches the empty string. Here the search continues after an empty match one
|
|
39
|
+
character later, as `re.finditer` does: `split` with the pattern `""` gives
|
|
40
|
+
the characters with an empty string at the two ends.
|
|
41
|
+
* **A negative count.** `split_at_most` with a negative count does no split. In
|
|
42
|
+
Elm a negative count means no limit.
|
|
43
|
+
|
|
44
|
+
Behaviour of elm/regex that this module keeps:
|
|
45
|
+
|
|
46
|
+
* A group that matched the empty string is `Nothing` in `Match.submatches`, the
|
|
47
|
+
same as a group that did not take part in the match.
|
|
48
|
+
* `find` and `find_at_most` stop at an empty match that ends where the search
|
|
49
|
+
for it started: `find` with `a*` on `"baaa"` gives one empty match.
|
|
50
|
+
* `replace` replaces empty matches in the same way as JavaScript and Python.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
import re
|
|
54
|
+
from dataclasses import dataclass
|
|
55
|
+
from typing import TYPE_CHECKING
|
|
56
|
+
|
|
57
|
+
from morphir.sdk.maybe import Just, Maybe, Nothing
|
|
58
|
+
|
|
59
|
+
if TYPE_CHECKING:
|
|
60
|
+
from collections.abc import Callable
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"Match",
|
|
64
|
+
"Options",
|
|
65
|
+
"Regex",
|
|
66
|
+
"contains",
|
|
67
|
+
"find",
|
|
68
|
+
"find_at_most",
|
|
69
|
+
"from_string",
|
|
70
|
+
"from_string_with",
|
|
71
|
+
"never",
|
|
72
|
+
"replace",
|
|
73
|
+
"replace_at_most",
|
|
74
|
+
"split",
|
|
75
|
+
"split_at_most",
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Regex:
|
|
81
|
+
"""A compiled regular expression.
|
|
82
|
+
|
|
83
|
+
Make one with `from_string` or `from_string_with`. Two values are equal when
|
|
84
|
+
they have the same pattern text and the same options.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
pattern: re.Pattern[str]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True, slots=True)
|
|
91
|
+
class Options:
|
|
92
|
+
"""The options of `from_string_with`."""
|
|
93
|
+
|
|
94
|
+
case_insensitive: bool
|
|
95
|
+
"""Ignore the case of letters."""
|
|
96
|
+
|
|
97
|
+
multiline: bool
|
|
98
|
+
"""Let `^` and `$` match at the start and the end of each line."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True, slots=True)
|
|
102
|
+
class Match:
|
|
103
|
+
"""The data of one match."""
|
|
104
|
+
|
|
105
|
+
match: str
|
|
106
|
+
"""The text that matched."""
|
|
107
|
+
|
|
108
|
+
index: int
|
|
109
|
+
"""The position of the match in the text, in code points from 0."""
|
|
110
|
+
|
|
111
|
+
number: int
|
|
112
|
+
"""The number of the match: 1 for the first match, 2 for the second."""
|
|
113
|
+
|
|
114
|
+
submatches: tuple[Maybe[str], ...]
|
|
115
|
+
"""The text of each group in the pattern.
|
|
116
|
+
|
|
117
|
+
A group that took no part in the match, or that matched the empty string, is
|
|
118
|
+
`Nothing`.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def from_string_with(options: Options, string: str) -> Maybe[Regex]:
|
|
123
|
+
"""Compile a pattern with options.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
options: The options.
|
|
127
|
+
string: The pattern, in the syntax of the Python `re` module.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
`Just` the regular expression, or `Nothing` when the pattern is not
|
|
131
|
+
valid.
|
|
132
|
+
"""
|
|
133
|
+
flags = re.NOFLAG
|
|
134
|
+
if options.case_insensitive:
|
|
135
|
+
flags |= re.IGNORECASE
|
|
136
|
+
if options.multiline:
|
|
137
|
+
flags |= re.MULTILINE
|
|
138
|
+
try:
|
|
139
|
+
return Just(Regex(re.compile(string, flags)))
|
|
140
|
+
except (re.PatternError, OverflowError, RecursionError):
|
|
141
|
+
return Nothing()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def from_string(string: str) -> Maybe[Regex]:
|
|
145
|
+
"""Compile a pattern that is case sensitive and not multiline.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
string: The pattern, in the syntax of the Python `re` module.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
`Just` the regular expression, or `Nothing` when the pattern is not
|
|
152
|
+
valid.
|
|
153
|
+
"""
|
|
154
|
+
return from_string_with(Options(case_insensitive=False, multiline=False), string)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
never: Regex = Regex(re.compile(r".^"))
|
|
158
|
+
"""A regular expression that never matches."""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def contains(regex: Regex, string: str) -> bool:
|
|
162
|
+
"""Check that a regular expression matches somewhere in a string."""
|
|
163
|
+
return regex.pattern.search(string) is not None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _to_match(found: re.Match[str], number: int) -> Match:
|
|
167
|
+
return Match(
|
|
168
|
+
match=found.group(0),
|
|
169
|
+
index=found.start(),
|
|
170
|
+
number=number,
|
|
171
|
+
submatches=tuple(
|
|
172
|
+
Just(group) if group else Nothing() for group in found.groups()
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def split_at_most(number: int, regex: Regex, string: str) -> tuple[str, ...]:
|
|
178
|
+
"""Split a string at the first matches of a regular expression.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
number: The maximum number of splits. Zero or less gives no split.
|
|
182
|
+
regex: The regular expression that matches the separators.
|
|
183
|
+
string: The string to split.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
The parts between the separators. The text of the groups in the pattern
|
|
187
|
+
is not in the result.
|
|
188
|
+
"""
|
|
189
|
+
parts: list[str] = []
|
|
190
|
+
start = 0
|
|
191
|
+
for count, found in enumerate(regex.pattern.finditer(string)):
|
|
192
|
+
if count >= number:
|
|
193
|
+
break
|
|
194
|
+
parts.append(string[start : found.start()])
|
|
195
|
+
start = found.end()
|
|
196
|
+
parts.append(string[start:])
|
|
197
|
+
return tuple(parts)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def split(regex: Regex, string: str) -> tuple[str, ...]:
|
|
201
|
+
"""Split a string at all matches of a regular expression.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
regex: The regular expression that matches the separators.
|
|
205
|
+
string: The string to split.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
The parts between the separators.
|
|
209
|
+
"""
|
|
210
|
+
return split_at_most(len(string) + 1, regex, string)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def find_at_most(number: int, regex: Regex, string: str) -> tuple[Match, ...]:
|
|
214
|
+
"""Find the first matches of a regular expression in a string.
|
|
215
|
+
|
|
216
|
+
Args:
|
|
217
|
+
number: The maximum number of matches.
|
|
218
|
+
regex: The regular expression.
|
|
219
|
+
string: The string to search.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The matches, in the sequence in which they occur.
|
|
223
|
+
"""
|
|
224
|
+
matches: list[Match] = []
|
|
225
|
+
position = 0
|
|
226
|
+
previous_end = -1
|
|
227
|
+
while len(matches) < number:
|
|
228
|
+
found = regex.pattern.search(string, position)
|
|
229
|
+
if found is None or found.end() == previous_end:
|
|
230
|
+
break
|
|
231
|
+
matches.append(_to_match(found, len(matches) + 1))
|
|
232
|
+
previous_end = position = found.end()
|
|
233
|
+
return tuple(matches)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def find(regex: Regex, string: str) -> tuple[Match, ...]:
|
|
237
|
+
"""Find all matches of a regular expression in a string.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
regex: The regular expression.
|
|
241
|
+
string: The string to search.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
The matches, in the sequence in which they occur.
|
|
245
|
+
"""
|
|
246
|
+
return find_at_most(len(string) + 1, regex, string)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def replace_at_most(
|
|
250
|
+
number: int, regex: Regex, with_: Callable[[Match], str], string: str
|
|
251
|
+
) -> str:
|
|
252
|
+
r"""Replace the first matches of a regular expression in a string.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
number: The maximum number of replacements.
|
|
256
|
+
regex: The regular expression.
|
|
257
|
+
with_: A function that gives the replacement text for a match. The text
|
|
258
|
+
is used as it is: `\1` and `$1` have no special meaning.
|
|
259
|
+
string: The string to change.
|
|
260
|
+
|
|
261
|
+
Returns:
|
|
262
|
+
The string with the replacements.
|
|
263
|
+
"""
|
|
264
|
+
if number <= 0:
|
|
265
|
+
return string
|
|
266
|
+
count = 0
|
|
267
|
+
|
|
268
|
+
def replacement(found: re.Match[str]) -> str:
|
|
269
|
+
nonlocal count
|
|
270
|
+
count += 1
|
|
271
|
+
return with_(_to_match(found, count))
|
|
272
|
+
|
|
273
|
+
return regex.pattern.sub(replacement, string, count=number)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def replace(regex: Regex, with_: Callable[[Match], str], string: str) -> str:
|
|
277
|
+
"""Replace all matches of a regular expression in a string.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
regex: The regular expression.
|
|
281
|
+
with_: A function that gives the replacement text for a match.
|
|
282
|
+
string: The string to change.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
The string with the replacements.
|
|
286
|
+
"""
|
|
287
|
+
return replace_at_most(len(string) + 1, regex, with_, string)
|