agapsys-utils 0.0.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leandro José Britto de Oliveira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: agapsys-utils
3
+ Version: 0.0.0
4
+ Summary: Common utilities
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: dev
10
+ Requires-Dist: build; extra == "dev"
11
+ Requires-Dist: twine; extra == "dev"
12
+ Requires-Dist: pytest>=7.0; extra == "dev"
13
+ Requires-Dist: pytest-cov; extra == "dev"
14
+ Dynamic: license-file
15
+
16
+ # agapsys-utils
17
+
18
+ This project provides general purpose utiltities that individually thet do not fit in a dedicated library.
19
+
20
+ ## License
21
+
22
+ m-conf is distributed under MIT License. Please see the [LICENSE](LICENSE) file for details on copying and distribution.
23
+
24
+ ## Basic usage
25
+
26
+ ```py
27
+ import agapsys.utils as utils
28
+
29
+ # That's it... use the available modules
30
+ ```
@@ -0,0 +1,15 @@
1
+ # agapsys-utils
2
+
3
+ This project provides general purpose utiltities that individually thet do not fit in a dedicated library.
4
+
5
+ ## License
6
+
7
+ m-conf is distributed under MIT License. Please see the [LICENSE](LICENSE) file for details on copying and distribution.
8
+
9
+ ## Basic usage
10
+
11
+ ```py
12
+ import agapsys.utils as utils
13
+
14
+ # That's it... use the available modules
15
+ ```
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "agapsys-utils"
7
+ version = "0.0.0"
8
+ description = "Common utilities"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.11"
12
+ dependencies = []
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "build",
17
+ "twine",
18
+ "pytest>=7.0",
19
+ "pytest-cov"
20
+ ]
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+
25
+ [tool.pytest]
26
+ addopts = []
27
+ testpaths = ["tests"]
28
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ # Copyright (c) 2026 Leandro José Britto de Oliveira
2
+ # Licensed under the MIT License.
@@ -0,0 +1,104 @@
1
+ # Copyright (c) 2026 Leandro José Britto de Oliveira
2
+ # Licensed under the MIT License.
3
+
4
+ from typing import Any, Type
5
+
6
+ import builtins
7
+
8
+ def check(condition: bool, exc_type: Type[BaseException], err_msg: str | None = None) -> bool:
9
+ """
10
+ Asserts a given condition is `True`.
11
+
12
+ Args:
13
+ condition (bool): Condition which must evaluate to `True`.
14
+ exc_type (Type[BaseException]): Type of the exception to be raised if condition is `False`.
15
+ err_msg (str | None, optional): Custom error message used if assertion fails.
16
+
17
+ Raises:
18
+ exc_type: If condition is `False`.
19
+
20
+ Returns:
21
+ bool: If function returns, it will always return `True`.
22
+ """
23
+ assert builtins.isinstance(condition, bool)
24
+ assert builtins.issubclass(exc_type, BaseException)
25
+ assert builtins.isinstance(err_msg, (str, type(None)))
26
+
27
+ if not condition:
28
+ if err_msg is None:
29
+ err_msg = "Condition is False"
30
+
31
+ raise exc_type(err_msg)
32
+
33
+ return True
34
+
35
+ def isinstance(obj: Any, class_or_tuple: Type | tuple[Type, ...], err_msg: str | None = None) -> bool:
36
+ """
37
+ Standardized assert for isinstance checks.
38
+
39
+ Args:
40
+ obj (Any):
41
+ Inspected object.
42
+
43
+ class_or_tuple (type | tuple[type, ...]):
44
+ A type or a list of types.
45
+
46
+ err_msg (str | None, optional):
47
+ Custom error message used if assertion fails.
48
+
49
+ Raises:
50
+ TypeError: If `obj` is not an instance of expected types.
51
+
52
+ Returns:
53
+ bool: If function returns, it will always return `True`.
54
+ """
55
+ return check(
56
+ builtins.isinstance(obj, class_or_tuple),
57
+ TypeError,
58
+ f"Invalid type: {'None' if type(obj) == type(None) else type(obj).__name__}" if err_msg is None else err_msg
59
+ )
60
+
61
+ def issubclass(cls: Type, class_or_tuple: Type | tuple[Type, ...], err_msg: str | None = None) -> bool:
62
+ """
63
+ Standardized assert for issubclass checks.
64
+
65
+ Args:
66
+ cls (Type):
67
+ Inspected given type.
68
+
69
+ class_or_tuple (type | tuple[type, ...]):
70
+ Accepted parent classes.
71
+
72
+ err_msg (str | None, optional):
73
+ Custom error message used if assertion fails.
74
+
75
+ Raises:
76
+ TypeError: If `cls` is not a subclass of any of provided types.
77
+
78
+ Returns:
79
+ bool: If function returns, it will always return `True`.
80
+ """
81
+ return check(
82
+ builtins.issubclass(cls, class_or_tuple),
83
+ TypeError,
84
+ f"{cls.__name__} is not a valid type" if err_msg is None else err_msg
85
+ )
86
+
87
+ def check_value(condition: bool, err_msg: str | None = None) -> bool:
88
+ """
89
+ Standardized way of check if a value meets a certain criteria.
90
+
91
+ Args:
92
+ condition (bool):
93
+ Tested condition.
94
+
95
+ var_name (str, optional):
96
+ Variable name (used upon error).
97
+
98
+ err_msg (str | None, optional):
99
+ Custom error message used if assertion fails.
100
+
101
+ Raises:
102
+ ValueError: If `condition` is `False`.
103
+ """
104
+ return check(condition, ValueError, err_msg)
@@ -0,0 +1,420 @@
1
+ # Copyright (c) 2026 Leandro José Britto de Oliveira
2
+ # Licensed under the MIT License.
3
+
4
+ from . import assertions
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from io import IOBase, BytesIO, TextIOWrapper
8
+ from typing import Any, IO
9
+
10
+ import locale
11
+ import os
12
+ import selectors
13
+ import shlex
14
+ import subprocess
15
+ import sys
16
+ import time
17
+
18
+ @dataclass(frozen=True)
19
+ class Result:
20
+ """
21
+ Process execution result.
22
+
23
+ Args:
24
+ exit_code (int): Process exit code.
25
+
26
+ stdout (str | bytes | None):
27
+ Data captured from process stdout. It process was open in text mode,
28
+ it is a string, otherwise it is composed by raw bytes. If capturing
29
+ was not set during process creation, it is `None`.
30
+
31
+ stderr (str | bytes | None):
32
+ Data captured from process stderr. It process was open in text mode,
33
+ it is a string, otherwise it is composed by raw bytes. If capturing
34
+ was not set during process creation, it is `None`.
35
+ """
36
+ exit_code: int
37
+ stdout: str | bytes | None
38
+ stderr: str | bytes | None
39
+
40
+ class Error(Exception):
41
+ """
42
+ Process execution error.
43
+ """
44
+
45
+ def __init__(self, result: Result) -> None:
46
+ """
47
+ Process execution error.
48
+
49
+ Args:
50
+ result (Result): Execution result.
51
+ """
52
+ assertions.isinstance(result, Result)
53
+ assertions.check_value(result.exit_code != 0)
54
+
55
+ super().__init__(f"Process exited with code {result.exit_code}")
56
+ self.__result = result
57
+
58
+ @property
59
+ def result(self) -> Result:
60
+ """
61
+ Execution result.
62
+
63
+ Returns:
64
+ Result: Execution result.
65
+ """
66
+ return self.__result
67
+
68
+ class Listener(ABC):
69
+ """
70
+ Listener for a process.
71
+ """
72
+ @classmethod
73
+ def decode(cls, data: bytes) -> str:
74
+ """
75
+ Decodes raw bytes into a string using system defaults.
76
+
77
+ Args:
78
+ data (bytes): raw bytes.
79
+
80
+ Returns:
81
+ str: Decoded string.
82
+ """
83
+
84
+ if not hasattr(Listener.decode, "encoding"):
85
+ encoding = locale.getpreferredencoding(False)
86
+
87
+ return data.decode(encoding, errors="replace")
88
+
89
+ def __init__(self, interactive: bool = True):
90
+ """
91
+ Listener for a process.
92
+
93
+ Args:
94
+ interactive (bool, optional):
95
+ Defines if this listener interacts with the process.
96
+ Defaults to True.
97
+ """
98
+ assertions.isinstance(interactive, bool)
99
+ self.__interactive = interactive
100
+ super().__init__()
101
+
102
+ @property
103
+ def interactive(self) -> bool:
104
+ """_summary_
105
+
106
+ Returns:
107
+ bool: _description_
108
+ """
109
+ return self.__interactive
110
+
111
+ def on_open(self, stdin: IOBase | None):
112
+ """
113
+ Called upon process start.
114
+
115
+ Default implementation does nothing.
116
+
117
+ Args:
118
+ stdin (IOBase | None):
119
+ Process stdin. Use it to send data to the process. If listener
120
+ is interactive, this will be a valide pipe to communicate
121
+ with the process. Otherwise, it is `None`.
122
+ """
123
+ pass
124
+
125
+ @abstractmethod
126
+ def on_read(
127
+ self,
128
+ stdin: IOBase | None,
129
+ stdout_data: str | bytes | None,
130
+ stderr_data: str | bytes | None
131
+ ):
132
+ """Called upon data is read from the process.
133
+
134
+ This is an abstract method.
135
+
136
+ Args:
137
+ stdin (IOBase):
138
+ Process stdin. Use it to send data to the process. If listener
139
+ is interactive, this will be a valide pipe to communicate
140
+ with the process. Otherwise, it is `None`.
141
+
142
+ stdout_data (str | bytes | None):
143
+ Read data. `None` if no data was read from stdout.
144
+ If process was open in text mode, this argument will be a
145
+ string. Otherwise it will be raw data.
146
+
147
+ stderr_data (str | bytes | None):
148
+ Read data. `None` if no data was read from stderr.
149
+ If process was open in text mode, this argument will be a
150
+ string. Otherwise it will be raw data.
151
+ """
152
+ pass
153
+
154
+ def on_close(self, exit_code: int):
155
+ """
156
+ Called upon process finish.
157
+
158
+ Default implementation does nothing.
159
+
160
+ Args:
161
+ exit_code (int):
162
+ Process exit code.
163
+ """
164
+ pass
165
+
166
+ def open(
167
+ cmd: str|list[str],
168
+ text_mode: bool = True,
169
+ env: dict[str, str] | None = None,
170
+ stdin: IO[Any] | None = None,
171
+ stdout: IO[Any] | None = None,
172
+ stderr: IO[Any] | None = None,
173
+ capture_output: bool = False,
174
+ listener: Listener | None = None,
175
+ check_exit: bool = False,
176
+ timeout: float | None = None
177
+ ) -> Result:
178
+ """
179
+ Executes a process and wait for its completion.
180
+
181
+ Args:
182
+ cmd (str | list[str]):
183
+ Command to be executed.
184
+
185
+ text_mode (bool, Optional):
186
+ Interpret output as text. Defaults to `True`.
187
+
188
+ env (dict[str, str] | None, Optional):
189
+ Custom environment for the process. Passsing `None` means inheriting
190
+ parent environment. Defaults to `None`.
191
+
192
+ stdin (IOBase, Optional):
193
+ IOBase instance to attach to process stdin. Defaults to `None`.
194
+
195
+ stdout (IOBase | None, Optional):
196
+ IOBase instance to attach to process stdout. Defaults to `None`.
197
+
198
+ stderr (IOBase | None, Optional):
199
+ IOBase instance to attach to process stderr. Defaults to `None`.
200
+
201
+ capture_output (bool, Optional):
202
+ Defines if output shall be captured to be read after process
203
+ finishes.
204
+
205
+ listener (Listener | None, Optional):
206
+ Listener instance used to monitor/interact with process. Defaults
207
+ to `None`. NOTE: It is not possible to pass an interactive
208
+ listener along with `stdin`.
209
+
210
+ check_exit (bool, Optional):
211
+ If `True`, raises an _Error_ if exit code is not zero.
212
+
213
+ timeout (int | None):
214
+ Timeout (in seconds) waiting for process to terminate. Passing
215
+ `None` means waiting until ti finishes. Defaults to `None`
216
+
217
+ Raises:
218
+ Error:
219
+ If process exited with a non-zero code and `check_exit` is `True`.
220
+
221
+ TimeoutError:
222
+ If a timeout was reached an process did not terminate (it will
223
+ be killed).
224
+
225
+ ValueError:
226
+ If `stdin` was given along with an interactive `listener`.
227
+
228
+ Returns:
229
+ Result: Result of process execution (note that function does not return
230
+ if process exited with a non-zero value and `check_exit` was `True`. For
231
+ this case use raised exception to get details about execution result).
232
+ """
233
+ assertions.check_value(assertions.isinstance(cmd, (str, list)) and bool(cmd))
234
+ assertions.isinstance(text_mode, bool)
235
+ assertions.isinstance(env, (dict, type(None)))
236
+ assertions.isinstance(stdin, (type(None), IOBase))
237
+ assertions.isinstance(stdout, (type(None), IOBase))
238
+ assertions.isinstance(stderr, (type(None), IOBase))
239
+ assertions.isinstance(capture_output, bool)
240
+ assertions.isinstance(check_exit, bool)
241
+ assertions.isinstance(listener, (type(None), Listener))
242
+ assertions.isinstance(timeout, (float, int, type(None)))
243
+
244
+ if timeout is not None:
245
+ if isinstance(timeout, int):
246
+ timeout = float(timeout)
247
+
248
+ assert timeout > 0.0
249
+
250
+ if isinstance(cmd, str):
251
+ cmd = shlex.split(cmd)
252
+
253
+ if stdin is None:
254
+ proc_stdin = subprocess.DEVNULL # proc.stdin is None
255
+ else:
256
+ if listener is not None and listener.interactive:
257
+ raise ValueError("Cannot pass an interactive listener along with stdin")
258
+
259
+ if stdin is sys.stdin:
260
+ proc_stdin = None # proc.stdin is None (process inherits parent)
261
+ else:
262
+ proc_stdin = stdin # proc.stdin is NOT None and it will be provided one
263
+
264
+ if listener is not None and listener.interactive:
265
+ proc_stdin = subprocess.PIPE # Listener will communicate with the process
266
+
267
+ proc = subprocess.Popen(
268
+ cmd,
269
+ stdin=proc_stdin,
270
+ stdout=subprocess.PIPE,
271
+ stderr=subprocess.PIPE,
272
+ text=text_mode,
273
+ env=env
274
+ )
275
+
276
+ assert proc.stdout is not None
277
+ assert proc.stderr is not None
278
+
279
+ sel = selectors.DefaultSelector()
280
+
281
+ sel.register(proc.stdout, selectors.EVENT_READ)
282
+ sel.register(proc.stderr, selectors.EVENT_READ)
283
+
284
+ stdout_buffer = None if not capture_output else BytesIO()
285
+ stderr_buffer = None if not capture_output else BytesIO()
286
+
287
+ def decode(data: bytes) -> bytes | str:
288
+ """
289
+ Returns either given data or decoded version if `text_mode` is `True`.
290
+
291
+ Args:
292
+ data (bytes): raw bytes.
293
+
294
+ Returns:
295
+ bytes | str: `data` if `text_mode` is `False`. Otherwise, returns
296
+ string resulting from decoding given data.
297
+ """
298
+ if not text_mode:
299
+ return data
300
+ else:
301
+ return Listener.decode(data)
302
+
303
+ def read(stream: IOBase) -> bytes:
304
+ """
305
+ Read all data (raw bytes) currently available from an IO
306
+
307
+ Args:
308
+ stream (IOBase): IO object to read from.
309
+
310
+ Returns:
311
+ bytes: Read data (raw bytes).
312
+ """
313
+ chunks = []
314
+ while True:
315
+ try:
316
+ if isinstance(stream, TextIOWrapper):
317
+ chunk = stream.buffer.read()
318
+ else:
319
+ chunk = stream.read()
320
+
321
+ if not chunk:
322
+ break
323
+
324
+ chunks.append(chunk)
325
+ except BlockingIOError:
326
+ # No more data available right now
327
+ break
328
+
329
+ return b"".join(chunks)
330
+
331
+ #if isinstance(stream, TextIOWrapper):
332
+ # return stream.buffer.read1(chunk_size)
333
+ #else:
334
+ # return stream.read1(chunk_size)
335
+
336
+ def write(data: bytes, stream: IO[Any]):
337
+ """
338
+ Writes raw bytes into an IO object.
339
+
340
+ Args:
341
+ data (bytes): Raw data to be written.
342
+ stream (IO[Any]): IO object.
343
+ """
344
+ if isinstance(stream, TextIOWrapper):
345
+ stream.buffer.write(data)
346
+ stream.buffer.flush()
347
+ else:
348
+ stream.write(data)
349
+ stream.flush()
350
+
351
+ # Make pipes non-block so it is possible to read all data available
352
+ # upon selector events (Works on all platforms, including Windows)
353
+ os.set_blocking(proc.stdout.fileno(), False)
354
+ os.set_blocking(proc.stderr.fileno(), False)
355
+
356
+ listener_stdin = None
357
+ if stdin is None and listener is not None:
358
+ # Pass the pipe to the listener so it can communicate with the process.
359
+ listener_stdin = proc.stdin
360
+
361
+ if proc.poll() is None and listener is not None:
362
+ listener.on_open(listener_stdin)
363
+
364
+ mark = time.perf_counter()
365
+ remaining: float | None = timeout
366
+
367
+ while remaining is None or remaining >= 0:
368
+ events = sel.select(remaining)
369
+ for key, _ in events:
370
+ while True:
371
+ data = read(key.fileobj)
372
+ decoded_data = decode(data)
373
+
374
+ if not data:
375
+ break
376
+
377
+ if key.fileobj is proc.stdout:
378
+ # process stdout
379
+ if capture_output:
380
+ stdout_buffer.write(data)
381
+
382
+ if stdout is not None:
383
+ write(data, stdout)
384
+
385
+ if listener is not None:
386
+ listener.on_read(listener_stdin, decoded_data, None)
387
+
388
+ elif key.fileobj is proc.stderr:
389
+ # process stderr
390
+ if capture_output:
391
+ stderr_buffer.write(data)
392
+
393
+ if stderr:
394
+ write(data, stderr)
395
+
396
+ if listener is not None:
397
+ listener.on_read(listener_stdin, None, decoded_data)
398
+
399
+ # If process exited, break
400
+ if proc.poll() is not None:
401
+ exit_code = proc.wait()
402
+
403
+ if listener is not None:
404
+ listener.on_close(exit_code)
405
+
406
+ stdout_data = None if not capture_output else decode(stdout_buffer.getvalue())
407
+ stderr_data = None if not capture_output else decode(stderr_buffer.getvalue())
408
+
409
+ result = Result(exit_code, stdout_data, stderr_data)
410
+
411
+ if exit_code != 0 and check_exit:
412
+ raise Error(result)
413
+
414
+ return result
415
+
416
+ elapsed = time.perf_counter() - mark
417
+ remaining = None if remaining is None else remaining - elapsed
418
+
419
+ proc.kill()
420
+ raise TimeoutError()
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: agapsys-utils
3
+ Version: 0.0.0
4
+ Summary: Common utilities
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: dev
10
+ Requires-Dist: build; extra == "dev"
11
+ Requires-Dist: twine; extra == "dev"
12
+ Requires-Dist: pytest>=7.0; extra == "dev"
13
+ Requires-Dist: pytest-cov; extra == "dev"
14
+ Dynamic: license-file
15
+
16
+ # agapsys-utils
17
+
18
+ This project provides general purpose utiltities that individually thet do not fit in a dedicated library.
19
+
20
+ ## License
21
+
22
+ m-conf is distributed under MIT License. Please see the [LICENSE](LICENSE) file for details on copying and distribution.
23
+
24
+ ## Basic usage
25
+
26
+ ```py
27
+ import agapsys.utils as utils
28
+
29
+ # That's it... use the available modules
30
+ ```
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/agapsys/utils/__init__.py
5
+ src/agapsys/utils/assertions.py
6
+ src/agapsys/utils/proc.py
7
+ src/agapsys_utils.egg-info/PKG-INFO
8
+ src/agapsys_utils.egg-info/SOURCES.txt
9
+ src/agapsys_utils.egg-info/dependency_links.txt
10
+ src/agapsys_utils.egg-info/requires.txt
11
+ src/agapsys_utils.egg-info/top_level.txt
@@ -0,0 +1,6 @@
1
+
2
+ [dev]
3
+ build
4
+ twine
5
+ pytest>=7.0
6
+ pytest-cov