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.
Files changed (99) hide show
  1. morphir/__init__.py +14 -0
  2. morphir/ir/__init__.py +110 -0
  3. morphir/ir/access_controlled.py +15 -0
  4. morphir/ir/decorations.py +19 -0
  5. morphir/ir/diagnostics.py +105 -0
  6. morphir/ir/distribution.py +21 -0
  7. morphir/ir/document.py +37 -0
  8. morphir/ir/documented.py +11 -0
  9. morphir/ir/format_version.py +483 -0
  10. morphir/ir/fqname.py +9 -0
  11. morphir/ir/json.py +11 -0
  12. morphir/ir/literal.py +25 -0
  13. morphir/ir/meta.py +11 -0
  14. morphir/ir/migrate.py +1267 -0
  15. morphir/ir/module.py +17 -0
  16. morphir/ir/name.py +27 -0
  17. morphir/ir/package.py +15 -0
  18. morphir/ir/path.py +25 -0
  19. morphir/ir/qname.py +9 -0
  20. morphir/ir/ref.py +17 -0
  21. morphir/ir/type.py +37 -0
  22. morphir/ir/type_constraints.py +33 -0
  23. morphir/ir/type_def.py +19 -0
  24. morphir/ir/type_spec.py +25 -0
  25. morphir/ir/v3/__init__.py +64 -0
  26. morphir/ir/v3/_legacy_value.py +201 -0
  27. morphir/ir/v3/access_controlled.py +31 -0
  28. morphir/ir/v3/codec.py +1275 -0
  29. morphir/ir/v3/codec_v1.py +491 -0
  30. morphir/ir/v3/codec_v2.py +187 -0
  31. morphir/ir/v3/distribution.py +42 -0
  32. morphir/ir/v3/documented.py +13 -0
  33. morphir/ir/v3/fqname.py +30 -0
  34. morphir/ir/v3/literal.py +46 -0
  35. morphir/ir/v3/module.py +32 -0
  36. morphir/ir/v3/name.py +42 -0
  37. morphir/ir/v3/package.py +23 -0
  38. morphir/ir/v3/path.py +29 -0
  39. morphir/ir/v3/qname.py +16 -0
  40. morphir/ir/v3/type.py +126 -0
  41. morphir/ir/v3/value.py +235 -0
  42. morphir/ir/v4/__init__.py +160 -0
  43. morphir/ir/v4/access_controlled.py +17 -0
  44. morphir/ir/v4/annotation.py +75 -0
  45. morphir/ir/v4/codec.py +2161 -0
  46. morphir/ir/v4/decorations.py +88 -0
  47. morphir/ir/v4/distribution.py +56 -0
  48. morphir/ir/v4/document.py +69 -0
  49. morphir/ir/v4/documented.py +10 -0
  50. morphir/ir/v4/fqname.py +69 -0
  51. morphir/ir/v4/incompleteness.py +78 -0
  52. morphir/ir/v4/json.py +103 -0
  53. morphir/ir/v4/literal.py +51 -0
  54. morphir/ir/v4/meta.py +27 -0
  55. morphir/ir/v4/module.py +30 -0
  56. morphir/ir/v4/name.py +69 -0
  57. morphir/ir/v4/package.py +19 -0
  58. morphir/ir/v4/path.py +50 -0
  59. morphir/ir/v4/qname.py +34 -0
  60. morphir/ir/v4/ref.py +35 -0
  61. morphir/ir/v4/type.py +140 -0
  62. morphir/ir/v4/type_constraints.py +91 -0
  63. morphir/ir/v4/type_def.py +38 -0
  64. morphir/ir/v4/type_spec.py +64 -0
  65. morphir/ir/v4/value.py +480 -0
  66. morphir/ir/value.py +81 -0
  67. morphir/py.typed +0 -0
  68. morphir/sdk/__init__.py +110 -0
  69. morphir/sdk/_compare.py +187 -0
  70. morphir/sdk/aggregate.py +421 -0
  71. morphir/sdk/basics.py +504 -0
  72. morphir/sdk/char.py +110 -0
  73. morphir/sdk/decimal.py +311 -0
  74. morphir/sdk/dict.py +336 -0
  75. morphir/sdk/instant.py +118 -0
  76. morphir/sdk/int.py +86 -0
  77. morphir/sdk/key.py +522 -0
  78. morphir/sdk/list.py +356 -0
  79. morphir/sdk/local_date.py +436 -0
  80. morphir/sdk/local_time.py +267 -0
  81. morphir/sdk/maybe.py +135 -0
  82. morphir/sdk/number.py +237 -0
  83. morphir/sdk/regex.py +287 -0
  84. morphir/sdk/result.py +162 -0
  85. morphir/sdk/result_list.py +169 -0
  86. morphir/sdk/rule.py +103 -0
  87. morphir/sdk/set.py +214 -0
  88. morphir/sdk/stateful_app.py +49 -0
  89. morphir/sdk/string.py +427 -0
  90. morphir/sdk/tuple.py +43 -0
  91. morphir/sdk/uuid.py +209 -0
  92. morphir_python-0.0.1.dist-info/METADATA +269 -0
  93. morphir_python-0.0.1.dist-info/RECORD +99 -0
  94. morphir_python-0.0.1.dist-info/WHEEL +4 -0
  95. morphir_python-0.0.1.dist-info/entry_points.txt +2 -0
  96. morphir_python-0.0.1.dist-info/licenses/LICENSE +201 -0
  97. morphir_tools/__init__.py +13 -0
  98. morphir_tools/cli.py +223 -0
  99. 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)