xulbux 1.9.5__cp311-cp311-macosx_11_0_arm64.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.
- 455848faf89d8974b22a__mypyc.cpython-311-darwin.so +0 -0
- xulbux/__init__.cpython-311-darwin.so +0 -0
- xulbux/__init__.py +46 -0
- xulbux/base/consts.cpython-311-darwin.so +0 -0
- xulbux/base/consts.py +172 -0
- xulbux/base/decorators.cpython-311-darwin.so +0 -0
- xulbux/base/decorators.py +28 -0
- xulbux/base/exceptions.cpython-311-darwin.so +0 -0
- xulbux/base/exceptions.py +23 -0
- xulbux/base/types.cpython-311-darwin.so +0 -0
- xulbux/base/types.py +118 -0
- xulbux/cli/help.cpython-311-darwin.so +0 -0
- xulbux/cli/help.py +77 -0
- xulbux/code.cpython-311-darwin.so +0 -0
- xulbux/code.py +137 -0
- xulbux/color.cpython-311-darwin.so +0 -0
- xulbux/color.py +1331 -0
- xulbux/console.cpython-311-darwin.so +0 -0
- xulbux/console.py +2069 -0
- xulbux/data.cpython-311-darwin.so +0 -0
- xulbux/data.py +798 -0
- xulbux/env_path.cpython-311-darwin.so +0 -0
- xulbux/env_path.py +123 -0
- xulbux/file.cpython-311-darwin.so +0 -0
- xulbux/file.py +74 -0
- xulbux/file_sys.cpython-311-darwin.so +0 -0
- xulbux/file_sys.py +266 -0
- xulbux/format_codes.cpython-311-darwin.so +0 -0
- xulbux/format_codes.py +722 -0
- xulbux/json.cpython-311-darwin.so +0 -0
- xulbux/json.py +200 -0
- xulbux/regex.cpython-311-darwin.so +0 -0
- xulbux/regex.py +247 -0
- xulbux/string.cpython-311-darwin.so +0 -0
- xulbux/string.py +161 -0
- xulbux/system.cpython-311-darwin.so +0 -0
- xulbux/system.py +313 -0
- xulbux-1.9.5.dist-info/METADATA +271 -0
- xulbux-1.9.5.dist-info/RECORD +43 -0
- xulbux-1.9.5.dist-info/WHEEL +6 -0
- xulbux-1.9.5.dist-info/entry_points.txt +2 -0
- xulbux-1.9.5.dist-info/licenses/LICENSE +21 -0
- xulbux-1.9.5.dist-info/top_level.txt +2 -0
|
Binary file
|
xulbux/json.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides the `Json` class, which includes methods to read,
|
|
3
|
+
create and update JSON files, with support for comments inside the JSON data.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .file_sys import FileSys
|
|
7
|
+
from .data import Data
|
|
8
|
+
from .file import File
|
|
9
|
+
|
|
10
|
+
from typing import Literal, Any, cast
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import json as _json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Json:
|
|
16
|
+
"""This class provides methods to read, create and update JSON files,
|
|
17
|
+
with support for comments inside the JSON data."""
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def read(
|
|
21
|
+
cls,
|
|
22
|
+
json_file: Path | str,
|
|
23
|
+
comment_start: str = ">>",
|
|
24
|
+
comment_end: str = "<<",
|
|
25
|
+
return_original: bool = False,
|
|
26
|
+
) -> dict | tuple[dict, dict]:
|
|
27
|
+
"""Read JSON files, ignoring comments.\n
|
|
28
|
+
------------------------------------------------------------------------------------
|
|
29
|
+
- `json_file` -⠀the path (relative or absolute) to the JSON file to read
|
|
30
|
+
- `comment_start` -⠀the string that indicates the start of a comment
|
|
31
|
+
- `comment_end` -⠀the string that indicates the end of a comment
|
|
32
|
+
- `return_original` -⠀if true, the original JSON data is returned additionally:<br>
|
|
33
|
+
```python
|
|
34
|
+
(processed_json, original_json)
|
|
35
|
+
```\n
|
|
36
|
+
------------------------------------------------------------------------------------
|
|
37
|
+
For more detailed information about the comment handling,
|
|
38
|
+
see the `Data.remove_comments()` method documentation."""
|
|
39
|
+
if (json_path := Path(json_file) if isinstance(json_file, str) else json_file).suffix != ".json":
|
|
40
|
+
json_path = json_path.with_suffix(".json")
|
|
41
|
+
file_path = FileSys.extend_or_make_path(json_path, prefer_script_dir=True)
|
|
42
|
+
|
|
43
|
+
with open(file_path, "r") as file:
|
|
44
|
+
content = file.read()
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
data = _json.loads(content)
|
|
48
|
+
except _json.JSONDecodeError as e:
|
|
49
|
+
fmt_error = "\n ".join(str(e).splitlines())
|
|
50
|
+
raise ValueError(f"Error parsing JSON in {file_path!r}:\n {fmt_error}") from e
|
|
51
|
+
|
|
52
|
+
if not (processed_data := dict(Data.remove_comments(data, comment_start, comment_end))):
|
|
53
|
+
raise ValueError(f"The JSON file {file_path!r} is empty or contains only comments.")
|
|
54
|
+
|
|
55
|
+
return (processed_data, data) if return_original else processed_data
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def create(
|
|
59
|
+
cls,
|
|
60
|
+
json_file: Path | str,
|
|
61
|
+
data: dict,
|
|
62
|
+
indent: int = 2,
|
|
63
|
+
compactness: Literal[0, 1, 2] = 1,
|
|
64
|
+
force: bool = False,
|
|
65
|
+
) -> Path:
|
|
66
|
+
"""Create a nicely formatted JSON file from a dictionary.\n
|
|
67
|
+
---------------------------------------------------------------------------
|
|
68
|
+
- `json_file` -⠀the path (relative or absolute) to the JSON file to create
|
|
69
|
+
- `data` -⠀the dictionary data to write to the JSON file
|
|
70
|
+
- `indent` -⠀the amount of spaces to use for indentation
|
|
71
|
+
- `compactness` -⠀can be `0`, `1` or `2` and indicates how compact
|
|
72
|
+
the data should be formatted (see `Data.render()` for more info)
|
|
73
|
+
- `force` -⠀if true, will overwrite existing files
|
|
74
|
+
without throwing an error (errors explained below)\n
|
|
75
|
+
---------------------------------------------------------------------------
|
|
76
|
+
The method will throw a `FileExistsError` if a file with the same
|
|
77
|
+
name already exists and a `SameContentFileExistsError` if a file
|
|
78
|
+
with the same name and same content already exists."""
|
|
79
|
+
if (json_path := Path(json_file) if isinstance(json_file, str) else json_file).suffix != ".json":
|
|
80
|
+
json_path = json_path.with_suffix(".json")
|
|
81
|
+
|
|
82
|
+
file_path = FileSys.extend_or_make_path(json_path, prefer_script_dir=True)
|
|
83
|
+
File.create(
|
|
84
|
+
file_path=file_path,
|
|
85
|
+
content=Data.render(
|
|
86
|
+
data=data,
|
|
87
|
+
indent=indent,
|
|
88
|
+
compactness=compactness,
|
|
89
|
+
as_json=True,
|
|
90
|
+
syntax_highlighting=False,
|
|
91
|
+
),
|
|
92
|
+
force=force,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return file_path
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def update(
|
|
99
|
+
cls,
|
|
100
|
+
json_file: Path | str,
|
|
101
|
+
update_values: dict[str, Any],
|
|
102
|
+
comment_start: str = ">>",
|
|
103
|
+
comment_end: str = "<<",
|
|
104
|
+
path_sep: str = "->",
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Update single/multiple values inside JSON files,
|
|
107
|
+
without needing to know the rest of the data.\n
|
|
108
|
+
-----------------------------------------------------------------------------------
|
|
109
|
+
- `json_file` -⠀the path (relative or absolute) to the JSON file to update
|
|
110
|
+
- `update_values` -⠀a dictionary with the paths to the values to update
|
|
111
|
+
and the new values to set (see explanation below – section 2)
|
|
112
|
+
- `comment_start` -⠀the string that indicates the start of a comment
|
|
113
|
+
- `comment_end` -⠀the string that indicates the end of a comment
|
|
114
|
+
- `path_sep` -⠀the separator used inside the value-paths in `update_values`\n
|
|
115
|
+
-----------------------------------------------------------------------------------
|
|
116
|
+
For more detailed information about the comment handling,
|
|
117
|
+
see the `Data.remove_comments()` method documentation.\n
|
|
118
|
+
-----------------------------------------------------------------------------------
|
|
119
|
+
The `update_values` is a dictionary, where the keys are the paths
|
|
120
|
+
to the data to update, and the values are the new values to set.\n
|
|
121
|
+
For example for this JSON data:
|
|
122
|
+
```python
|
|
123
|
+
{
|
|
124
|
+
"healthy": {
|
|
125
|
+
"fruits": ["apples", "bananas", "oranges"],
|
|
126
|
+
"vegetables": ["carrots", "broccoli", "celery"]
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
… the `update_values` dictionary could look like this:
|
|
131
|
+
```python
|
|
132
|
+
{
|
|
133
|
+
# CHANGE FIRST LIST-VALUE UNDER 'fruits' TO "strawberries"
|
|
134
|
+
"healthy->fruits->0": "strawberries",
|
|
135
|
+
# CHANGE VALUE OF KEY 'vegetables' TO [1, 2, 3]
|
|
136
|
+
"healthy->vegetables": [1, 2, 3]
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
In this example, if you want to change the value of `"apples"`,
|
|
140
|
+
you can use `healthy->fruits->apples` as the value-path.<br>
|
|
141
|
+
If you don't know that the first list item is `"apples"`,
|
|
142
|
+
you can use the items list index inside the value-path, so `healthy->fruits->0`.\n
|
|
143
|
+
⇾ If the given value-path doesn't exist, it will be created."""
|
|
144
|
+
processed_data, data = cls.read(
|
|
145
|
+
json_file=json_file,
|
|
146
|
+
comment_start=comment_start,
|
|
147
|
+
comment_end=comment_end,
|
|
148
|
+
return_original=True,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
update: dict[str, Any] = {}
|
|
152
|
+
for val_path, new_val in update_values.items():
|
|
153
|
+
try:
|
|
154
|
+
if (path_id := Data.get_path_id(data=processed_data, value_paths=val_path, path_sep=path_sep)) is not None:
|
|
155
|
+
update[cast(str, path_id)] = new_val
|
|
156
|
+
else:
|
|
157
|
+
data = cls._create_nested_path(data, val_path.split(path_sep), new_val)
|
|
158
|
+
except Exception:
|
|
159
|
+
data = cls._create_nested_path(data, val_path.split(path_sep), new_val)
|
|
160
|
+
|
|
161
|
+
if update:
|
|
162
|
+
data = Data.set_value_by_path_id(data, update)
|
|
163
|
+
|
|
164
|
+
cls.create(json_file=json_file, data=dict(data), force=True)
|
|
165
|
+
|
|
166
|
+
@staticmethod
|
|
167
|
+
def _create_nested_path(data_obj: dict, path_keys: list[str], value: Any) -> dict:
|
|
168
|
+
"""Internal method that creates nested dictionaries/lists based on the
|
|
169
|
+
given path keys and sets the specified value at the end of the path."""
|
|
170
|
+
last_idx, current = len(path_keys) - 1, data_obj
|
|
171
|
+
|
|
172
|
+
for i, key in enumerate(path_keys):
|
|
173
|
+
if i == last_idx:
|
|
174
|
+
if isinstance(current, dict):
|
|
175
|
+
current[key] = value
|
|
176
|
+
elif isinstance(current, list) and key.isdigit():
|
|
177
|
+
idx = int(key)
|
|
178
|
+
while len(current) <= idx:
|
|
179
|
+
current.append(None)
|
|
180
|
+
current[idx] = value
|
|
181
|
+
else:
|
|
182
|
+
raise TypeError(f"Cannot set key '{key}' on {type(current)}")
|
|
183
|
+
|
|
184
|
+
else:
|
|
185
|
+
next_key = path_keys[i + 1]
|
|
186
|
+
if isinstance(current, dict):
|
|
187
|
+
if key not in current:
|
|
188
|
+
current[key] = [] if next_key.isdigit() else {}
|
|
189
|
+
current = current[key]
|
|
190
|
+
elif isinstance(current, list) and key.isdigit():
|
|
191
|
+
idx = int(key)
|
|
192
|
+
while len(current) <= idx:
|
|
193
|
+
current.append(None)
|
|
194
|
+
if current[idx] is None:
|
|
195
|
+
current[idx] = [] if next_key.isdigit() else {}
|
|
196
|
+
current = current[idx]
|
|
197
|
+
else:
|
|
198
|
+
raise TypeError(f"Cannot navigate through {type(current)}")
|
|
199
|
+
|
|
200
|
+
return data_obj
|
|
Binary file
|
xulbux/regex.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides the `Regex` class, which includes methods
|
|
3
|
+
to dynamically generate complex regex patterns for common use cases.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .base.decorators import mypyc_attr
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
import regex as _rx
|
|
10
|
+
import re as _re
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Regex:
|
|
14
|
+
"""This class provides methods to dynamically generate complex regex patterns for common use cases."""
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def quotes(cls) -> str:
|
|
18
|
+
"""Matches pairs of quotes. (strings)\n
|
|
19
|
+
--------------------------------------------------------------------------------
|
|
20
|
+
Will create two named groups:
|
|
21
|
+
- `quote` the quote type (single or double)
|
|
22
|
+
- `string` everything inside the found quote pair\n
|
|
23
|
+
---------------------------------------------------------------------------------
|
|
24
|
+
Attention: Requires non-standard library `regex`, not standard library `re`!"""
|
|
25
|
+
return r"""(?P<quote>["'])(?P<string>(?:\\.|(?!\g<quote>).)*?)\g<quote>"""
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def brackets(
|
|
29
|
+
cls,
|
|
30
|
+
bracket1: str = "(",
|
|
31
|
+
bracket2: str = ")",
|
|
32
|
+
is_group: bool = False,
|
|
33
|
+
strip_spaces: bool = False,
|
|
34
|
+
ignore_in_strings: bool = True,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Matches everything inside pairs of brackets, including other nested brackets.\n
|
|
37
|
+
---------------------------------------------------------------------------------------
|
|
38
|
+
- `bracket1` -⠀the opening bracket (e.g. `(`, `{`, `[`, …)
|
|
39
|
+
- `bracket2` -⠀the closing bracket (e.g. `)`, `}`, `]`, …)
|
|
40
|
+
- `is_group` -⠀whether to create a capturing group for the content inside the brackets
|
|
41
|
+
- `strip_spaces` -⠀whether to strip spaces from the bracket content or not
|
|
42
|
+
- `ignore_in_strings` -⠀whether to ignore closing brackets that are inside
|
|
43
|
+
strings/quotes (e.g. `'…)…'` or `"…)…"`)\n
|
|
44
|
+
---------------------------------------------------------------------------------------
|
|
45
|
+
Attention: Requires non-standard library `regex`, not standard library `re`!"""
|
|
46
|
+
g = "" if is_group else "?:"
|
|
47
|
+
b1 = _rx.escape(bracket1) if len(bracket1) == 1 else bracket1
|
|
48
|
+
b2 = _rx.escape(bracket2) if len(bracket2) == 1 else bracket2
|
|
49
|
+
s1 = r"\s*" if strip_spaces else ""
|
|
50
|
+
s2 = "" if strip_spaces else r"\s*"
|
|
51
|
+
|
|
52
|
+
if ignore_in_strings:
|
|
53
|
+
return cls._clean( \
|
|
54
|
+
rf"""{b1}{s1}({g}{s2}(?:
|
|
55
|
+
[^{b1}{b2}"']
|
|
56
|
+
|"(?:\\.|[^"\\])*"
|
|
57
|
+
|'(?:\\.|[^'\\])*'
|
|
58
|
+
|{b1}(?:
|
|
59
|
+
[^{b1}{b2}"']
|
|
60
|
+
|"(?:\\.|[^"\\])*"
|
|
61
|
+
|'(?:\\.|[^'\\])*'
|
|
62
|
+
|(?R)
|
|
63
|
+
)*{b2}
|
|
64
|
+
)*{s2}){s1}{b2}"""
|
|
65
|
+
)
|
|
66
|
+
else:
|
|
67
|
+
return cls._clean( \
|
|
68
|
+
rf"""{b1}{s1}({g}{s2}(?:
|
|
69
|
+
[^{b1}{b2}]
|
|
70
|
+
|{b1}(?:
|
|
71
|
+
[^{b1}{b2}]
|
|
72
|
+
|(?R)
|
|
73
|
+
)*{b2}
|
|
74
|
+
)*{s2}){s1}{b2}"""
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def outside_strings(cls, pattern: str = r".*") -> str:
|
|
79
|
+
"""Matches the `pattern` only when it is not found inside a string (`'…'` or `"…"`)."""
|
|
80
|
+
return rf"""(?<!["'])(?:{pattern})(?!["'])"""
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def all_except(cls, disallowed_pattern: str, ignore_pattern: str = "", is_group: bool = False) -> str:
|
|
84
|
+
"""Matches everything up to the `disallowed_pattern`, unless the
|
|
85
|
+
`disallowed_pattern` is found inside a string/quotes (`'…'` or `"…"`).\n
|
|
86
|
+
-------------------------------------------------------------------------------------
|
|
87
|
+
- `disallowed_pattern` -⠀the pattern that is not allowed to be matched
|
|
88
|
+
- `ignore_pattern` -⠀a pattern that, if found, will make the regex ignore the
|
|
89
|
+
`disallowed_pattern` (even if it contains the `disallowed_pattern` inside it):<br>
|
|
90
|
+
For example if `disallowed_pattern` is `>` and `ignore_pattern` is `->`,
|
|
91
|
+
the `->`-arrows will be allowed, even though they have `>` in them.
|
|
92
|
+
- `is_group` -⠀whether to create a capturing group for the matched content"""
|
|
93
|
+
g = "" if is_group else "?:"
|
|
94
|
+
|
|
95
|
+
return cls._clean( \
|
|
96
|
+
rf"""({g}
|
|
97
|
+
(?:(?!{ignore_pattern}).)*
|
|
98
|
+
(?:(?!{cls.outside_strings(disallowed_pattern)}).)*
|
|
99
|
+
)"""
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def func_call(cls, func_name: Optional[str] = None) -> str:
|
|
104
|
+
"""Match a function call, and get back two groups:
|
|
105
|
+
1. function name
|
|
106
|
+
2. the function's arguments\n
|
|
107
|
+
If no `func_name` is given, it will match any function call.\n
|
|
108
|
+
---------------------------------------------------------------------------------
|
|
109
|
+
Attention: Requires non-standard library `regex`, not standard library `re`!"""
|
|
110
|
+
if func_name in {"", None}:
|
|
111
|
+
func_name = r"[\w_]+"
|
|
112
|
+
|
|
113
|
+
return rf"""(?<=\b)({func_name})\s*{cls.brackets("(", ")", is_group=True)}"""
|
|
114
|
+
|
|
115
|
+
@classmethod
|
|
116
|
+
def rgba_str(cls, fix_sep: Optional[str] = ",", allow_alpha: bool = True) -> str:
|
|
117
|
+
"""Matches an RGBA color inside a string.\n
|
|
118
|
+
----------------------------------------------------------------------------------
|
|
119
|
+
- `fix_sep` -⠀the fixed separator between the RGBA values (e.g. `,`, `;` …)<br>
|
|
120
|
+
If set to nothing or `None`, any char that is not a letter or number
|
|
121
|
+
can be used to separate the RGBA values, including just a space.
|
|
122
|
+
- `allow_alpha` -⠀whether to include the alpha channel in the match\n
|
|
123
|
+
----------------------------------------------------------------------------------
|
|
124
|
+
The RGBA color can be in the formats (for `fix_sep = ','`):
|
|
125
|
+
- `rgba(r, g, b)`
|
|
126
|
+
- `rgba(r, g, b, a)` (if `allow_alpha=True`)
|
|
127
|
+
- `(r, g, b)`
|
|
128
|
+
- `(r, g, b, a)` (if `allow_alpha=True`)
|
|
129
|
+
- `r, g, b`
|
|
130
|
+
- `r, g, b, a` (if `allow_alpha=True`)\n
|
|
131
|
+
#### Valid ranges:
|
|
132
|
+
- `r` 0-255 (int: red)
|
|
133
|
+
- `g` 0-255 (int: green)
|
|
134
|
+
- `b` 0-255 (int: blue)
|
|
135
|
+
- `a` 0.0-1.0 (float: opacity)"""
|
|
136
|
+
fix_sep = _re.escape(fix_sep) if isinstance(fix_sep, str) else r"[^0-9A-Z]"
|
|
137
|
+
|
|
138
|
+
rgb_part = rf"""((?:0*(?:25[0-5]|2[0-4][0-9]|1?[0-9]{{1,2}})))
|
|
139
|
+
(?:\s*{fix_sep}\s*)((?:0*(?:25[0-5]|2[0-4][0-9]|1?[0-9]{{1,2}})))
|
|
140
|
+
(?:\s*{fix_sep}\s*)((?:0*(?:25[0-5]|2[0-4][0-9]|1?[0-9]{{1,2}})))"""
|
|
141
|
+
|
|
142
|
+
if allow_alpha:
|
|
143
|
+
return cls._clean( \
|
|
144
|
+
rf"""(?ix)(?:rgb|rgba)?\s*(?:
|
|
145
|
+
\(?\s*{rgb_part}
|
|
146
|
+
(?:(?:\s*{fix_sep}\s*)((?:0*(?:0?\.[0-9]+|1\.0+|[0-9]+\.[0-9]+|[0-9]+))))?
|
|
147
|
+
\s*\)?
|
|
148
|
+
)"""
|
|
149
|
+
)
|
|
150
|
+
else:
|
|
151
|
+
return cls._clean( \
|
|
152
|
+
rf"""(?ix)(?:rgb|rgba)?\s*(?:
|
|
153
|
+
\(?\s*{rgb_part}\s*\)?
|
|
154
|
+
)"""
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
@classmethod
|
|
158
|
+
def hsla_str(cls, fix_sep: Optional[str] = ",", allow_alpha: bool = True) -> str:
|
|
159
|
+
"""Matches a HSLA color inside a string.\n
|
|
160
|
+
----------------------------------------------------------------------------------
|
|
161
|
+
- `fix_sep` -⠀the fixed separator between the HSLA values (e.g. `,`, `;` …)<br>
|
|
162
|
+
If set to nothing or `None`, any char that is not a letter or number
|
|
163
|
+
can be used to separate the HSLA values, including just a space.
|
|
164
|
+
- `allow_alpha` -⠀whether to include the alpha channel in the match\n
|
|
165
|
+
----------------------------------------------------------------------------------
|
|
166
|
+
The HSLA color can be in the formats (for `fix_sep = ','`):
|
|
167
|
+
- `hsla(h, s, l)`
|
|
168
|
+
- `hsla(h, s, l, a)` (if `allow_alpha=True`)
|
|
169
|
+
- `(h, s, l)`
|
|
170
|
+
- `(h, s, l, a)` (if `allow_alpha=True`)
|
|
171
|
+
- `h, s, l`
|
|
172
|
+
- `h, s, l, a` (if `allow_alpha=True`)\n
|
|
173
|
+
#### Valid ranges:
|
|
174
|
+
- `h` 0-360 (int: hue)
|
|
175
|
+
- `s` 0-100 (int: saturation)
|
|
176
|
+
- `l` 0-100 (int: lightness)
|
|
177
|
+
- `a` 0.0-1.0 (float: opacity)"""
|
|
178
|
+
fix_sep = _re.escape(fix_sep) if isinstance(fix_sep, str) else r"[^0-9A-Z]"
|
|
179
|
+
|
|
180
|
+
hsl_part = rf"""((?:0*(?:360|3[0-5][0-9]|[12][0-9][0-9]|[1-9]?[0-9])))(?:\s*°)?
|
|
181
|
+
(?:\s*{fix_sep}\s*)((?:0*(?:100|[1-9][0-9]|[0-9])))(?:\s*%)?
|
|
182
|
+
(?:\s*{fix_sep}\s*)((?:0*(?:100|[1-9][0-9]|[0-9])))(?:\s*%)?"""
|
|
183
|
+
|
|
184
|
+
if allow_alpha:
|
|
185
|
+
return cls._clean( \
|
|
186
|
+
rf"""(?ix)(?:hsl|hsla)?\s*(?:
|
|
187
|
+
\(?\s*{hsl_part}
|
|
188
|
+
(?:(?:\s*{fix_sep}\s*)((?:0*(?:0?\.[0-9]+|1\.0+|[0-9]+\.[0-9]+|[0-9]+))))?
|
|
189
|
+
\s*\)?
|
|
190
|
+
)"""
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
return cls._clean( \
|
|
194
|
+
rf"""(?ix)(?:hsl|hsla)?\s*(?:
|
|
195
|
+
\(?\s*{hsl_part}\s*\)?
|
|
196
|
+
)"""
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def hexa_str(cls, allow_alpha: bool = True) -> str:
|
|
201
|
+
"""Matches a HEXA color inside a string.\n
|
|
202
|
+
----------------------------------------------------------------------
|
|
203
|
+
- `allow_alpha` -⠀whether to include the alpha channel in the match\n
|
|
204
|
+
----------------------------------------------------------------------
|
|
205
|
+
The HEXA color can be in the formats (prefix `#`, `0x` or no prefix):
|
|
206
|
+
- `RGB`
|
|
207
|
+
- `RGBA` (if `allow_alpha=True`)
|
|
208
|
+
- `RRGGBB`
|
|
209
|
+
- `RRGGBBAA` (if `allow_alpha=True`)\n
|
|
210
|
+
#### Valid ranges:
|
|
211
|
+
every channel from 0-9 and A-F (case insensitive)"""
|
|
212
|
+
return r"(?i)(?:#|0x)?([0-9A-F]{8}|[0-9A-F]{6}|[0-9A-F]{4}|[0-9A-F]{3})" \
|
|
213
|
+
if allow_alpha else r"(?i)(?:#|0x)?([0-9A-F]{6}|[0-9A-F]{3})"
|
|
214
|
+
|
|
215
|
+
@classmethod
|
|
216
|
+
def _clean(cls, pattern: str) -> str:
|
|
217
|
+
"""Internal method to make a multiline-string regex pattern into a single-line pattern."""
|
|
218
|
+
return "".join(line.strip() for line in pattern.splitlines()).strip()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@mypyc_attr(native_class=False)
|
|
222
|
+
class LazyRegex:
|
|
223
|
+
"""A class that lazily compiles and caches regex patterns on first access.\n
|
|
224
|
+
--------------------------------------------------------------------------------
|
|
225
|
+
- `**patterns` -⠀keyword arguments where the key is the name of the pattern and
|
|
226
|
+
the value is the regex pattern string to compile\n
|
|
227
|
+
--------------------------------------------------------------------------------
|
|
228
|
+
#### Example usage:
|
|
229
|
+
```python
|
|
230
|
+
PATTERNS = LazyRegex(
|
|
231
|
+
email=r"(?i)[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,}",
|
|
232
|
+
phone=r"\\+?\\d{1,3}[-.\\s]?\\(?\\d{1,4}\\)?[-.\\s]?\\d{1,4}[-.\\s]?\\d{1,9}",
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
email_pattern = PATTERNS.email # Compiles and caches the EMAIL pattern
|
|
236
|
+
phone_pattern = PATTERNS.phone # Compiles and caches the PHONE pattern
|
|
237
|
+
```"""
|
|
238
|
+
|
|
239
|
+
def __init__(self, **patterns: str):
|
|
240
|
+
self._patterns = patterns
|
|
241
|
+
|
|
242
|
+
def __getattr__(self, name: str) -> _rx.Pattern:
|
|
243
|
+
if name in self._patterns:
|
|
244
|
+
setattr(self, name, compiled := _rx.compile(self._patterns[name]))
|
|
245
|
+
return compiled
|
|
246
|
+
|
|
247
|
+
raise AttributeError(f"'{type(self).__name__}' object has no attribute '{name}'")
|
|
Binary file
|
xulbux/string.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
This module provides the `String` class, which includes
|
|
3
|
+
various utility methods for string manipulation and conversion.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Optional, Literal, Any
|
|
7
|
+
import json as _json
|
|
8
|
+
import ast as _ast
|
|
9
|
+
import re as _re
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class String:
|
|
13
|
+
"""This class provides various utility methods for string manipulation and conversion."""
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def to_type(cls, string: str) -> Any:
|
|
17
|
+
"""Will convert a string to the found type, including complex nested structures.\n
|
|
18
|
+
-----------------------------------------------------------------------------------
|
|
19
|
+
- `string` -⠀the string to convert"""
|
|
20
|
+
try:
|
|
21
|
+
return _ast.literal_eval(string := string.strip())
|
|
22
|
+
except (ValueError, SyntaxError):
|
|
23
|
+
try:
|
|
24
|
+
return _json.loads(string)
|
|
25
|
+
except _json.JSONDecodeError:
|
|
26
|
+
return string
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def normalize_spaces(cls, string: str, tab_spaces: int = 4) -> str:
|
|
30
|
+
"""Replaces all special space characters with normal spaces.\n
|
|
31
|
+
---------------------------------------------------------------
|
|
32
|
+
- `tab_spaces` -⠀number of spaces to replace tab chars with"""
|
|
33
|
+
if tab_spaces < 0:
|
|
34
|
+
raise ValueError(f"The 'tab_spaces' parameter must be non-negative, got {tab_spaces!r}")
|
|
35
|
+
|
|
36
|
+
return string.replace("\t", " " * tab_spaces).replace("\u2000", " ").replace("\u2001", " ").replace("\u2002", " ") \
|
|
37
|
+
.replace("\u2003", " ").replace("\u2004", " ").replace("\u2005", " ").replace("\u2006", " ") \
|
|
38
|
+
.replace("\u2007", " ").replace("\u2008", " ").replace("\u2009", " ").replace("\u200A", " ")
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def escape(cls, string: str, str_quotes: Optional[Literal["'", '"']] = None) -> str:
|
|
42
|
+
"""Escapes Python's special characters (e.g. `\\n`, `\\t`, …) and quotes inside the string.\n
|
|
43
|
+
--------------------------------------------------------------------------------------------------------
|
|
44
|
+
- `string` -⠀the string to escape
|
|
45
|
+
- `str_quotes` -⠀the type of quotes the string will be put inside of (or None to not escape quotes)<br>
|
|
46
|
+
Can be either `"` or `'` and should match the quotes, the string will be put inside of.<br>
|
|
47
|
+
So if your string will be `"string"`, `str_quotes` should be `"`.<br>
|
|
48
|
+
That way, if the string includes the same quotes, they will be escaped."""
|
|
49
|
+
string = string.replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") \
|
|
50
|
+
.replace("\b", "\\b").replace("\f", "\\f").replace("\a", "\\a")
|
|
51
|
+
|
|
52
|
+
if str_quotes == '"':
|
|
53
|
+
return string.replace("\\'", "'").replace('"', '\\"')
|
|
54
|
+
elif str_quotes == "'":
|
|
55
|
+
return string.replace('\\"', '"').replace("'", "\\'")
|
|
56
|
+
else:
|
|
57
|
+
return string
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def is_empty(cls, string: Optional[str], spaces_are_empty: bool = False) -> bool:
|
|
61
|
+
"""Returns `True` if the string is considered empty and `False` otherwise.\n
|
|
62
|
+
-----------------------------------------------------------------------------------------------
|
|
63
|
+
- `string` -⠀the string to check (or `None`, which is considered empty)
|
|
64
|
+
- `spaces_are_empty` -⠀if true, strings consisting only of spaces are also considered empty"""
|
|
65
|
+
return bool(
|
|
66
|
+
(string in {"", None}) or \
|
|
67
|
+
(spaces_are_empty and isinstance(string, str) and not string.strip())
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def single_char_repeats(cls, string: str, char: str) -> int | bool:
|
|
72
|
+
"""- If the string consists of only the same `char`, it returns the number of times it is present.
|
|
73
|
+
- If the string doesn't consist of only the same character, it returns `False`.\n
|
|
74
|
+
---------------------------------------------------------------------------------------------------
|
|
75
|
+
- `string` -⠀the string to check
|
|
76
|
+
- `char` -⠀the character to check for repetition"""
|
|
77
|
+
if len(char) != 1:
|
|
78
|
+
raise ValueError(f"The 'char' parameter must be a single character, got {char!r}")
|
|
79
|
+
|
|
80
|
+
if len(string) == (len(char) * string.count(char)):
|
|
81
|
+
return string.count(char)
|
|
82
|
+
else:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def decompose(cls, case_string: str, seps: str = "-_", lower_all: bool = True) -> list[str]:
|
|
87
|
+
"""Will decompose the string (any type of casing, also mixed) into parts.\n
|
|
88
|
+
----------------------------------------------------------------------------
|
|
89
|
+
- `case_string` -⠀the string to decompose
|
|
90
|
+
- `seps` -⠀additional separators to split the string at
|
|
91
|
+
- `lower_all` -⠀if true, all parts will be converted to lowercase"""
|
|
92
|
+
return [
|
|
93
|
+
(part.lower() if lower_all else part) \
|
|
94
|
+
for part in _re.split(rf"(?<=[a-z])(?=[A-Z])|[{_re.escape(seps)}]", case_string)
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def to_camel_case(cls, string: str, upper: bool = True) -> str:
|
|
99
|
+
"""Will convert the string of any type of casing to CamelCase.\n
|
|
100
|
+
-----------------------------------------------------------------
|
|
101
|
+
- `string` -⠀the string to convert
|
|
102
|
+
- `upper` -⠀if true, it will convert to UpperCamelCase,
|
|
103
|
+
otherwise to lowerCamelCase"""
|
|
104
|
+
parts = cls.decompose(string)
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
("" if upper else parts[0].lower()) + \
|
|
108
|
+
"".join(part.capitalize() for part in (parts if upper else parts[1:]))
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def to_delimited_case(cls, string: str, delimiter: str = "_", screaming: bool = False) -> str:
|
|
113
|
+
"""Will convert the string of any type of casing to delimited case.\n
|
|
114
|
+
-----------------------------------------------------------------------
|
|
115
|
+
- `string` -⠀the string to convert
|
|
116
|
+
- `delimiter` -⠀the delimiter to use between parts
|
|
117
|
+
- `screaming` -⠀whether to convert all parts to uppercase"""
|
|
118
|
+
return delimiter.join(
|
|
119
|
+
part.upper() if screaming else part \
|
|
120
|
+
for part in cls.decompose(string)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def get_lines(cls, string: str, remove_empty_lines: bool = False) -> list[str]:
|
|
125
|
+
"""Will split the string into lines.\n
|
|
126
|
+
------------------------------------------------------------------------------------
|
|
127
|
+
- `string` -⠀the string to split
|
|
128
|
+
- `remove_empty_lines` -⠀if true, it will remove all empty lines from the result"""
|
|
129
|
+
if not remove_empty_lines:
|
|
130
|
+
return string.splitlines()
|
|
131
|
+
elif not (lines := string.splitlines()):
|
|
132
|
+
return []
|
|
133
|
+
elif not (non_empty_lines := [line for line in lines if line.strip()]):
|
|
134
|
+
return []
|
|
135
|
+
else:
|
|
136
|
+
return non_empty_lines
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def remove_consecutive_empty_lines(cls, string: str, max_consecutive: int = 0) -> str:
|
|
140
|
+
"""Will remove consecutive empty lines from the string.\n
|
|
141
|
+
-------------------------------------------------------------------------------------
|
|
142
|
+
- `string` -⠀the string to process
|
|
143
|
+
- `max_consecutive` -⠀the maximum number of allowed consecutive empty lines.<br>
|
|
144
|
+
* If `0`, it will remove all consecutive empty lines.
|
|
145
|
+
* If bigger than `0`, it will only allow `max_consecutive` consecutive empty lines
|
|
146
|
+
and everything above it will be cut down to `max_consecutive` empty lines."""
|
|
147
|
+
if max_consecutive < 0:
|
|
148
|
+
raise ValueError(f"The 'max_consecutive' parameter must be non-negative, got {max_consecutive!r}")
|
|
149
|
+
|
|
150
|
+
return _re.sub(r"(\n\s*){2,}", r"\1" * (max_consecutive + 1), string)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def split_count(cls, string: str, count: int) -> list[str]:
|
|
154
|
+
"""Will split the string every `count` characters.\n
|
|
155
|
+
-----------------------------------------------------
|
|
156
|
+
- `string` -⠀the string to split
|
|
157
|
+
- `count` -⠀the number of characters per part"""
|
|
158
|
+
if count <= 0:
|
|
159
|
+
raise ValueError(f"The 'count' parameter must be a positive integer, got {count!r}")
|
|
160
|
+
|
|
161
|
+
return [string[i:i + count] for i in range(0, len(string), count)]
|
|
Binary file
|