processit 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.
- processit/__init__.py +7 -0
- processit/_version.py +2 -0
- processit/progress.py +360 -0
- processit/py.typed +0 -0
- processit-0.0.1.dist-info/METADATA +35 -0
- processit-0.0.1.dist-info/RECORD +9 -0
- processit-0.0.1.dist-info/WHEEL +5 -0
- processit-0.0.1.dist-info/licenses/LICENSE +21 -0
- processit-0.0.1.dist-info/top_level.txt +1 -0
processit/__init__.py
ADDED
processit/_version.py
ADDED
processit/progress.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING, TextIO, cast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import (
|
|
13
|
+
AsyncIterable,
|
|
14
|
+
AsyncIterator,
|
|
15
|
+
Awaitable,
|
|
16
|
+
Iterable,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Progress[T]:
|
|
21
|
+
__slots__ = (
|
|
22
|
+
'_is_tty',
|
|
23
|
+
'_last_line_len',
|
|
24
|
+
'_last_refresh',
|
|
25
|
+
'_next_render_at',
|
|
26
|
+
'_prefix',
|
|
27
|
+
'_refresh_task',
|
|
28
|
+
'_stopped',
|
|
29
|
+
'_summary_printed',
|
|
30
|
+
'count',
|
|
31
|
+
'desc',
|
|
32
|
+
'iterable',
|
|
33
|
+
'refresh_interval',
|
|
34
|
+
'show_summary',
|
|
35
|
+
'start_time',
|
|
36
|
+
'stream',
|
|
37
|
+
'total',
|
|
38
|
+
'width',
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def __init__( # noqa: PLR0913
|
|
42
|
+
self,
|
|
43
|
+
iterable: Iterable[T] | AsyncIterable[T],
|
|
44
|
+
total: int | None = None,
|
|
45
|
+
*,
|
|
46
|
+
desc: str = 'Processing',
|
|
47
|
+
width: int = 30,
|
|
48
|
+
stream: TextIO | None = None,
|
|
49
|
+
refresh_interval: float = 0.1,
|
|
50
|
+
show_summary: bool = True,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.iterable = iterable
|
|
53
|
+
self.total = total
|
|
54
|
+
self.desc = desc
|
|
55
|
+
self.width = width
|
|
56
|
+
self.stream = stream or sys.stderr
|
|
57
|
+
self.refresh_interval = refresh_interval
|
|
58
|
+
self.show_summary = show_summary
|
|
59
|
+
|
|
60
|
+
self.count = 0
|
|
61
|
+
self.start_time = time.perf_counter()
|
|
62
|
+
self._last_refresh = 0.0
|
|
63
|
+
self._next_render_at = 0.0 # render inmediato al inicio
|
|
64
|
+
self._refresh_task: asyncio.Task[None] | None = None
|
|
65
|
+
self._summary_printed = False
|
|
66
|
+
self._last_line_len = 0
|
|
67
|
+
self._stopped = False
|
|
68
|
+
self._is_tty = bool(getattr(self.stream, 'isatty', lambda: False)())
|
|
69
|
+
self._prefix = f'{self.desc} '
|
|
70
|
+
|
|
71
|
+
def write(self, msg: str = '') -> None:
|
|
72
|
+
"""Print a message below the bar and re-render it (TTY-safe)."""
|
|
73
|
+
if self._stopped:
|
|
74
|
+
return
|
|
75
|
+
self._clear_line()
|
|
76
|
+
if msg:
|
|
77
|
+
if not msg.endswith('\n'):
|
|
78
|
+
msg += '\n'
|
|
79
|
+
self.stream.write(msg)
|
|
80
|
+
self.stream.flush()
|
|
81
|
+
self._render(force=True)
|
|
82
|
+
|
|
83
|
+
def _format_elapsed(self, seconds: float) -> str:
|
|
84
|
+
"""Return hh:mm:ss, mm:ss or ss.s depending on duration."""
|
|
85
|
+
if seconds < 60: # noqa: PLR2004
|
|
86
|
+
return f'{seconds:04.1f}s'
|
|
87
|
+
minutes, secs = divmod(int(seconds), 60)
|
|
88
|
+
if minutes < 60: # noqa: PLR2004
|
|
89
|
+
return f'{minutes:02d}:{secs:02d}'
|
|
90
|
+
hours, minutes = divmod(minutes, 60)
|
|
91
|
+
return f'{hours:02d}:{minutes:02d}:{secs:02d}'
|
|
92
|
+
|
|
93
|
+
def _write_line(self, text: str) -> None:
|
|
94
|
+
if self._is_tty:
|
|
95
|
+
# \r = return, \x1b[2K = clear whole line (ANSI)
|
|
96
|
+
self.stream.write('\r\x1b[2K' + text)
|
|
97
|
+
self.stream.flush()
|
|
98
|
+
self._last_line_len = len(text)
|
|
99
|
+
else:
|
|
100
|
+
# non-TTY (StringIO/logs): one line per render for deterministic
|
|
101
|
+
# tests/logs
|
|
102
|
+
self.stream.write(text + '\n')
|
|
103
|
+
self.stream.flush()
|
|
104
|
+
self._last_line_len = 0
|
|
105
|
+
|
|
106
|
+
def _clear_line(self) -> None:
|
|
107
|
+
if self._is_tty:
|
|
108
|
+
self.stream.write('\r\x1b[2K')
|
|
109
|
+
self.stream.flush()
|
|
110
|
+
self._last_line_len = 0
|
|
111
|
+
else:
|
|
112
|
+
# non-TTY: renders are on separate lines; nothing to clear
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
def _should_render(self, now: float) -> bool:
|
|
116
|
+
# Permite render al inicio (_next_render_at=0) o si ha
|
|
117
|
+
# pasado refresh_interval
|
|
118
|
+
return now >= self._next_render_at or self._last_line_len == 0
|
|
119
|
+
|
|
120
|
+
def _render(self, *, force: bool = False) -> None:
|
|
121
|
+
if self._stopped:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
now = time.perf_counter()
|
|
125
|
+
if not force and not self._should_render(now):
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
elapsed = now - self.start_time
|
|
129
|
+
rate = self.count / elapsed if elapsed > 0 else 0.0
|
|
130
|
+
elapsed_str = self._format_elapsed(elapsed)
|
|
131
|
+
|
|
132
|
+
eta_str = ''
|
|
133
|
+
if self.total is not None and self.count > 0 and rate > 0:
|
|
134
|
+
remaining = max(self.total - self.count, 0)
|
|
135
|
+
eta = remaining / rate
|
|
136
|
+
eta_str = f' ETA {self._format_elapsed(eta)}'
|
|
137
|
+
|
|
138
|
+
if self.total:
|
|
139
|
+
frac = min(self.count / self.total, 1.0)
|
|
140
|
+
filled = int(self.width * frac)
|
|
141
|
+
bar = f'[{"#" * filled}{"." * (self.width - filled)}]'
|
|
142
|
+
percent = f'{frac * 100:6.2f}%'
|
|
143
|
+
line = (
|
|
144
|
+
f'{self._prefix}{bar} {percent} '
|
|
145
|
+
f'({self.count}/{self.total}) {rate:.2f} it/s '
|
|
146
|
+
f'{elapsed_str}{eta_str}'
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
line = (
|
|
150
|
+
f'{self._prefix}{self.count} it '
|
|
151
|
+
f'({rate:.2f} it/s {elapsed_str})'
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
self._write_line(line)
|
|
155
|
+
self._last_refresh = now
|
|
156
|
+
self._next_render_at = now + self.refresh_interval
|
|
157
|
+
|
|
158
|
+
def _print_summary_if_needed(self) -> None:
|
|
159
|
+
if self.show_summary and not self._summary_printed:
|
|
160
|
+
self._stopped = True
|
|
161
|
+
self._clear_line()
|
|
162
|
+
|
|
163
|
+
elapsed = time.perf_counter() - self.start_time
|
|
164
|
+
rate = self.count / elapsed if elapsed > 0 else 0.0
|
|
165
|
+
elapsed_str = self._format_elapsed(elapsed)
|
|
166
|
+
|
|
167
|
+
self.stream.write(
|
|
168
|
+
f'{self.desc}: {self.count} it in {elapsed_str} '
|
|
169
|
+
f'({rate:.2f} it/s)\n',
|
|
170
|
+
)
|
|
171
|
+
self.stream.flush()
|
|
172
|
+
self._summary_printed = True
|
|
173
|
+
|
|
174
|
+
async def _refresh_periodically(self) -> None:
|
|
175
|
+
while not self._stopped:
|
|
176
|
+
await asyncio.sleep(self.refresh_interval)
|
|
177
|
+
if self._stopped:
|
|
178
|
+
break
|
|
179
|
+
self._render()
|
|
180
|
+
|
|
181
|
+
async def __aenter__(self) -> Progress[T]:
|
|
182
|
+
self._stopped = False
|
|
183
|
+
self._render(force=True) # feedback inmediato
|
|
184
|
+
self._refresh_task = asyncio.create_task(self._refresh_periodically())
|
|
185
|
+
return self
|
|
186
|
+
|
|
187
|
+
async def __aexit__(self, *_: object) -> None:
|
|
188
|
+
if self._refresh_task is not None:
|
|
189
|
+
self._refresh_task.cancel()
|
|
190
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
191
|
+
await self._refresh_task
|
|
192
|
+
self._refresh_task = None
|
|
193
|
+
self._print_summary_if_needed()
|
|
194
|
+
|
|
195
|
+
async def __aiter__(self) -> AsyncIterator[T]:
|
|
196
|
+
started_here = False
|
|
197
|
+
|
|
198
|
+
if self._refresh_task is None:
|
|
199
|
+
self._stopped = False
|
|
200
|
+
self._render(
|
|
201
|
+
force=True,
|
|
202
|
+
) # feedback inmediato fuera de context manager
|
|
203
|
+
self._refresh_task = asyncio.create_task(
|
|
204
|
+
self._refresh_periodically(),
|
|
205
|
+
)
|
|
206
|
+
started_here = True
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
if hasattr(self.iterable, '__aiter__'):
|
|
210
|
+
async for item in cast('AsyncIterable[T]', self.iterable):
|
|
211
|
+
self.count += 1
|
|
212
|
+
self._render()
|
|
213
|
+
yield item
|
|
214
|
+
else:
|
|
215
|
+
for item in cast('Iterable[T]', self.iterable):
|
|
216
|
+
self.count += 1
|
|
217
|
+
self._render()
|
|
218
|
+
yield item
|
|
219
|
+
# cede el loop para no bloquear el refresco
|
|
220
|
+
await asyncio.sleep(0)
|
|
221
|
+
finally:
|
|
222
|
+
if started_here and self._refresh_task is not None: # type: ignore
|
|
223
|
+
self._refresh_task.cancel()
|
|
224
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
225
|
+
await self._refresh_task
|
|
226
|
+
self._refresh_task = None
|
|
227
|
+
self._print_summary_if_needed()
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def progress[T]( # noqa: PLR0913
|
|
231
|
+
iterable: Iterable[T] | AsyncIterable[T],
|
|
232
|
+
total: int | None = None,
|
|
233
|
+
*,
|
|
234
|
+
desc: str = 'Processing',
|
|
235
|
+
width: int = 30,
|
|
236
|
+
refresh_interval: float = 0.1,
|
|
237
|
+
show_summary: bool = True,
|
|
238
|
+
stream: TextIO | None = None,
|
|
239
|
+
) -> Progress[T]:
|
|
240
|
+
"""Wrap an iterable or async iterable with a progress bar.
|
|
241
|
+
|
|
242
|
+
Parameters
|
|
243
|
+
----------
|
|
244
|
+
iterable: Iterable[T] | AsyncIterable[T]
|
|
245
|
+
The iterable or async iterable to iterate over.
|
|
246
|
+
total: int | None, optional
|
|
247
|
+
Total number of elements. If not provided and `iterable` has
|
|
248
|
+
`__len__`, it will be inferred automatically. Otherwise, ETA
|
|
249
|
+
will not be displayed.
|
|
250
|
+
desc: str, optional
|
|
251
|
+
A short description displayed before the progress bar.
|
|
252
|
+
width: int, optional
|
|
253
|
+
The width (in characters) of the progress bar (default: 30).
|
|
254
|
+
refresh_interval: float, optional
|
|
255
|
+
Minimum time interval in seconds between display refreshes.
|
|
256
|
+
show_summary: bool, optional
|
|
257
|
+
Whether to print a final summary line showing total iterations,
|
|
258
|
+
total time, and iteration rate (default: True).
|
|
259
|
+
stream: TextIO | None, optional
|
|
260
|
+
Output stream to render the bar (default: sys.stderr).
|
|
261
|
+
|
|
262
|
+
Yields:
|
|
263
|
+
------
|
|
264
|
+
T
|
|
265
|
+
Each element from the iterable or async iterable, in order.
|
|
266
|
+
"""
|
|
267
|
+
# Infer total if possible
|
|
268
|
+
if total is None and hasattr(iterable, '__len__'):
|
|
269
|
+
try:
|
|
270
|
+
total = len(iterable) # type: ignore[arg-type]
|
|
271
|
+
except Exception:
|
|
272
|
+
total = None
|
|
273
|
+
|
|
274
|
+
return Progress(
|
|
275
|
+
iterable,
|
|
276
|
+
total,
|
|
277
|
+
desc=desc,
|
|
278
|
+
width=width,
|
|
279
|
+
refresh_interval=refresh_interval,
|
|
280
|
+
show_summary=show_summary,
|
|
281
|
+
stream=stream,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def track_as_completed[T]( # noqa: PLR0913
|
|
286
|
+
tasks: Iterable[Awaitable[T]],
|
|
287
|
+
*,
|
|
288
|
+
total: int | None = None,
|
|
289
|
+
desc: str = 'Processing',
|
|
290
|
+
width: int = 30,
|
|
291
|
+
refresh_interval: float = 0.1,
|
|
292
|
+
show_summary: bool = True,
|
|
293
|
+
stream: TextIO | None = None,
|
|
294
|
+
) -> Progress[asyncio.Future[T]]:
|
|
295
|
+
"""Iterate results as tasks complete, with a progress bar.
|
|
296
|
+
|
|
297
|
+
Parameters
|
|
298
|
+
----------
|
|
299
|
+
tasks: Iterable[Awaitable[T]]
|
|
300
|
+
Awaitables or tasks to run/track.
|
|
301
|
+
total: int | None, optional
|
|
302
|
+
Total number of tasks. If not provided and `tasks` has `__len__`,
|
|
303
|
+
it will be inferred. Otherwise, ETA will not be shown.
|
|
304
|
+
desc: str
|
|
305
|
+
Short description prefix for the bar.
|
|
306
|
+
width: int
|
|
307
|
+
Progress bar width (characters).
|
|
308
|
+
refresh_interval: float
|
|
309
|
+
Seconds between refreshes.
|
|
310
|
+
show_summary: bool
|
|
311
|
+
Whether to print a final summary line.
|
|
312
|
+
stream: TextIO | None, optional
|
|
313
|
+
Output stream to render the bar (default: sys.stderr).
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
-------
|
|
317
|
+
Progress[Future[T]]
|
|
318
|
+
An async-iterable of **futures** (await them in the loop).
|
|
319
|
+
|
|
320
|
+
Notes:
|
|
321
|
+
-----
|
|
322
|
+
We *avoid* passing the synchronous iterator from
|
|
323
|
+
`asyncio.as_completed(...)` directly to `Progress`, since its `next()` can
|
|
324
|
+
block the event loop. Instead, we drive completion asynchronously using
|
|
325
|
+
`asyncio.wait(FIRST_COMPLETED)`.
|
|
326
|
+
"""
|
|
327
|
+
# Infer total if possible
|
|
328
|
+
if total is None and hasattr(tasks, '__len__'):
|
|
329
|
+
try:
|
|
330
|
+
total = len(tasks) # type: ignore[arg-type]
|
|
331
|
+
except Exception:
|
|
332
|
+
total = None
|
|
333
|
+
|
|
334
|
+
async def _as_completed_async() -> AsyncIterable[asyncio.Future[T]]:
|
|
335
|
+
pending: set[asyncio.Future[T]] = {
|
|
336
|
+
asyncio.ensure_future(t)
|
|
337
|
+
for t in tasks # type: ignore[arg-type]
|
|
338
|
+
}
|
|
339
|
+
try:
|
|
340
|
+
while pending:
|
|
341
|
+
done, pending = await asyncio.wait(
|
|
342
|
+
pending,
|
|
343
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
344
|
+
)
|
|
345
|
+
for fut in done:
|
|
346
|
+
yield fut
|
|
347
|
+
finally:
|
|
348
|
+
# No cancelamos 'pending' por defecto (el consumidor decide su
|
|
349
|
+
# ciclo de vida).
|
|
350
|
+
pass
|
|
351
|
+
|
|
352
|
+
return progress(
|
|
353
|
+
_as_completed_async(),
|
|
354
|
+
total=total,
|
|
355
|
+
desc=desc,
|
|
356
|
+
width=width,
|
|
357
|
+
refresh_interval=refresh_interval,
|
|
358
|
+
show_summary=show_summary,
|
|
359
|
+
stream=stream,
|
|
360
|
+
)
|
processit/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: processit
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Process It
|
|
5
|
+
Author: Vicente Ruiz
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Vicente Ruiz
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
License-File: LICENSE
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
33
|
+
Requires-Dist: ruff>=0.5.0; extra == "dev"
|
|
34
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
35
|
+
Dynamic: license-file
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
processit/__init__.py,sha256=Yxs2DR5SYkiZhCAki5q8cvd2oZhCA_SPAEfej97luQ4,201
|
|
2
|
+
processit/_version.py,sha256=wwNaWq9QRCwZjjmdiUn3cN9CnkLNSKflulOdZE819gM,95
|
|
3
|
+
processit/progress.py,sha256=JP0iTwRoVggBhmR2cB-awI2Ofoc63W56r93fOz6EwRA,11613
|
|
4
|
+
processit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
processit-0.0.1.dist-info/licenses/LICENSE,sha256=z5zNysklw5miG2iYUPyPvTG-XalTzjUN399mUFl48WQ,1069
|
|
6
|
+
processit-0.0.1.dist-info/METADATA,sha256=ia9R86VQlOJjRPzgfZN-FCHD_z-yAt-v-jjCMyIp89w,1632
|
|
7
|
+
processit-0.0.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
processit-0.0.1.dist-info/top_level.txt,sha256=zQ9442BBggTNDrtwTRqJjNNP5U2JQ3dhc-kyJe4SJ9Q,10
|
|
9
|
+
processit-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Vicente Ruiz
|
|
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 @@
|
|
|
1
|
+
processit
|