async-task-api-worker 0.1.0rc2__tar.gz → 0.1.0rc3__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.
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/CHANGELOG.md +9 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/PKG-INFO +6 -1
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/README.md +5 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/pyproject.toml +1 -1
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/amqp_transport.py +8 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/lifecycle.py +22 -2
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/cli.py +16 -1
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/errors.py +19 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/settings.py +9 -3
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/.gitignore +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/LICENSE +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/__init__.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/__main__.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/__init__.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/declare.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/engine.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/inflight.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/log_context.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/observer.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/progress.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/retry.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/sync_bridge.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_runtime/transport.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/_version.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/context.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/observability/__init__.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/observability/health.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/observability/logging.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/observability/loop_lag.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/observability/metrics.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/protocol/__init__.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/protocol/envelope.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/protocol/json_types.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/protocol/messages.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/protocol/version.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/py.typed +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/testing/__init__.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/testing/_context.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/testing/_harness.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/testing/_memory_broker.py +0 -0
- {async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/worker.py +0 -0
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.0-rc.3](https://github.com/IA-Generative/async-api/compare/async-task-api-worker-v0.1.0-rc.2...async-task-api-worker-v0.1.0-rc.3) (2026-10-23)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **sdk:** close an already closed channel quietly and tie the close budgets together ([51e0281](https://github.com/IA-Generative/async-api/commit/51e0281dfe7ea22686f0967d4c8ce55efa470d0e))
|
|
9
|
+
* **sdk:** name the invalid settings instead of quoting pydantic's text about them ([df6c747](https://github.com/IA-Generative/async-api/commit/df6c7475464600f31f767997269d3f86b1cf432e))
|
|
10
|
+
* **sdk:** treat a pydantic validation error in a startup hook as a configuration error ([5e98af1](https://github.com/IA-Generative/async-api/commit/5e98af109a027fc2903a3f98d5d0028f936efbba))
|
|
11
|
+
|
|
3
12
|
## [0.1.0-rc.2](https://github.com/IA-Generative/async-api/compare/async-task-api-worker-v0.1.0-rc.1...async-task-api-worker-v0.1.0-rc.2) (2026-10-23)
|
|
4
13
|
|
|
5
14
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: async-task-api-worker
|
|
3
|
-
Version: 0.1.0-rc.
|
|
3
|
+
Version: 0.1.0-rc.3
|
|
4
4
|
Summary: SDK to write workers for the AsyncTaskAPI platform: protocol models, runtime and conformance kit.
|
|
5
5
|
Project-URL: Repository, https://github.com/IA-Generative/async-api
|
|
6
6
|
License: MIT
|
|
@@ -77,6 +77,11 @@ process is stopped and `await worker.serve()` serves from an existing event loop
|
|
|
77
77
|
- The settings are read and validated first: an invalid configuration, or a task timeout that does not fit under the
|
|
78
78
|
broker's consumer timeout (`task timeout + WORKER_RETRY_BACKOFF_MAX_S + 60 s < WORKER_CONSUMER_TIMEOUT_S`), exits with
|
|
79
79
|
code 2 before any connection. Exit codes: 0 clean stop, 1 failure, 2 configuration or usage error.
|
|
80
|
+
- A pydantic `ValidationError` raised by a startup hook (a settings class of your own, say) is a configuration error too:
|
|
81
|
+
one line naming the invalid fields, never their values (and `<Model> (a model-level check failed)` for a check that
|
|
82
|
+
belongs to no field), exit code 2. In job mode that fails the Job without a retry. So a startup hook must only validate
|
|
83
|
+
**configuration**: a hook that validates external data (a downloaded document, a response) must catch its own errors and
|
|
84
|
+
raise a `FatalError` or `RetryableError` from the task, where the task-level error model applies.
|
|
80
85
|
- The queues are declared durable and with no arguments (the broker decides their type and policies), and consumed with
|
|
81
86
|
manual acknowledgements: a task is acked only after its result is published.
|
|
82
87
|
- SIGTERM or SIGINT stops consuming, lets the in-flight tasks finish within `WORKER_SHUTDOWN_GRACE_S` (default 25 s),
|
|
@@ -56,6 +56,11 @@ process is stopped and `await worker.serve()` serves from an existing event loop
|
|
|
56
56
|
- The settings are read and validated first: an invalid configuration, or a task timeout that does not fit under the
|
|
57
57
|
broker's consumer timeout (`task timeout + WORKER_RETRY_BACKOFF_MAX_S + 60 s < WORKER_CONSUMER_TIMEOUT_S`), exits with
|
|
58
58
|
code 2 before any connection. Exit codes: 0 clean stop, 1 failure, 2 configuration or usage error.
|
|
59
|
+
- A pydantic `ValidationError` raised by a startup hook (a settings class of your own, say) is a configuration error too:
|
|
60
|
+
one line naming the invalid fields, never their values (and `<Model> (a model-level check failed)` for a check that
|
|
61
|
+
belongs to no field), exit code 2. In job mode that fails the Job without a retry. So a startup hook must only validate
|
|
62
|
+
**configuration**: a hook that validates external data (a downloaded document, a response) must catch its own errors and
|
|
63
|
+
raise a `FatalError` or `RetryableError` from the task, where the task-level error model applies.
|
|
59
64
|
- The queues are declared durable and with no arguments (the broker decides their type and policies), and consumed with
|
|
60
65
|
manual acknowledgements: a task is acked only after its result is published.
|
|
61
66
|
- SIGTERM or SIGINT stops consuming, lets the in-flight tasks finish within `WORKER_SHUTDOWN_GRACE_S` (default 25 s),
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "async-task-api-worker"
|
|
7
|
-
version = "0.1.0-rc.
|
|
7
|
+
version = "0.1.0-rc.3"
|
|
8
8
|
description = "SDK to write workers for the AsyncTaskAPI platform: protocol models, runtime and conformance kit."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -217,6 +217,8 @@ class AmqpTransport:
|
|
|
217
217
|
self._queue = None
|
|
218
218
|
if connection is None:
|
|
219
219
|
return
|
|
220
|
+
# ``close_timeout_s`` (the channels' share plus the connection's) is the whole budget of this method, and it
|
|
221
|
+
# must stay within the lifecycle's own bound on ``close()``: both default to ``CLOSE_TIMEOUT_S``.
|
|
220
222
|
channels_timeout_s = self._close_timeout_s * _CHANNELS_SHARE
|
|
221
223
|
try:
|
|
222
224
|
async with asyncio.timeout(channels_timeout_s):
|
|
@@ -231,8 +233,14 @@ class AmqpTransport:
|
|
|
231
233
|
|
|
232
234
|
@staticmethod
|
|
233
235
|
async def _close_channel(channel: AbstractChannel) -> None:
|
|
236
|
+
if channel.is_closed:
|
|
237
|
+
# A robust channel reports itself closed while the connection reconnects: nothing to close or wait for.
|
|
238
|
+
_LOGGER.debug("A channel was already closed (an outage, most likely).")
|
|
239
|
+
return
|
|
234
240
|
try:
|
|
235
241
|
await channel.close()
|
|
242
|
+
except ChannelInvalidStateError:
|
|
243
|
+
_LOGGER.debug("A channel became invalid while closing (an outage, most likely).")
|
|
236
244
|
except Exception:
|
|
237
245
|
_LOGGER.warning("Closing a channel failed: closing the connection anyway.", exc_info=True)
|
|
238
246
|
|
|
@@ -28,9 +28,15 @@ from concurrent.futures import ThreadPoolExecutor
|
|
|
28
28
|
from enum import Enum
|
|
29
29
|
from typing import TYPE_CHECKING, Final, NoReturn
|
|
30
30
|
|
|
31
|
+
from pydantic import ValidationError
|
|
32
|
+
|
|
31
33
|
from async_task_api_worker._runtime.engine import Engine
|
|
32
34
|
from async_task_api_worker._runtime.transport import CLOSE_TIMEOUT_S, Delivery, Transport, TransportUnavailableError
|
|
33
|
-
from async_task_api_worker.errors import
|
|
35
|
+
from async_task_api_worker.errors import (
|
|
36
|
+
WorkerConfigurationError,
|
|
37
|
+
WorkerDefinitionError,
|
|
38
|
+
configuration_error_from_validation,
|
|
39
|
+
)
|
|
34
40
|
from async_task_api_worker.observability.health import HealthServer, RuntimeState
|
|
35
41
|
from async_task_api_worker.observability.logging import configure_logging
|
|
36
42
|
from async_task_api_worker.observability.loop_lag import EventLoopLagProbe
|
|
@@ -137,7 +143,7 @@ class Lifecycle:
|
|
|
137
143
|
await health.start()
|
|
138
144
|
loop_lag.start()
|
|
139
145
|
for hook in self._worker._startup_hooks:
|
|
140
|
-
if not await self.
|
|
146
|
+
if not await self._run_startup_hook(hook):
|
|
141
147
|
break # stopped during startup: nothing was consumed, drain and exit
|
|
142
148
|
started_hooks += 1
|
|
143
149
|
if started_hooks == len(self._worker._startup_hooks) and await self._connect(settings):
|
|
@@ -226,6 +232,20 @@ class Lifecycle:
|
|
|
226
232
|
return True
|
|
227
233
|
return False
|
|
228
234
|
|
|
235
|
+
async def _run_startup_hook(self, hook: Callable[[], Awaitable[None]]) -> bool:
|
|
236
|
+
"""Run a startup hook. False when a stop came first.
|
|
237
|
+
|
|
238
|
+
A pydantic ``ValidationError`` from a hook (a settings class of its own, say) is a configuration error: one
|
|
239
|
+
line, exit code 2, and no value of the input in the message nor chained to it.
|
|
240
|
+
"""
|
|
241
|
+
configuration_error: WorkerConfigurationError | None = None
|
|
242
|
+
try:
|
|
243
|
+
return await self._until_stopped(hook())
|
|
244
|
+
except ValidationError as error:
|
|
245
|
+
configuration_error = configuration_error_from_validation(error)
|
|
246
|
+
assert configuration_error is not None
|
|
247
|
+
raise configuration_error
|
|
248
|
+
|
|
229
249
|
async def _until_stopped(self, work: Awaitable[object]) -> bool:
|
|
230
250
|
"""Run ``work``, cancelling it if a stop is requested first. False when it was cancelled by the stop.
|
|
231
251
|
|
{async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/cli.py
RENAMED
|
@@ -8,8 +8,14 @@ from collections.abc import Sequence
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Final
|
|
10
10
|
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
11
13
|
from async_task_api_worker._runtime.lifecycle import EXIT_CONFIGURATION, EXIT_FAILURE, EXIT_OK, run_blocking
|
|
12
|
-
from async_task_api_worker.errors import
|
|
14
|
+
from async_task_api_worker.errors import (
|
|
15
|
+
WorkerConfigurationError,
|
|
16
|
+
WorkerDefinitionError,
|
|
17
|
+
configuration_error_from_validation,
|
|
18
|
+
)
|
|
13
19
|
from async_task_api_worker.worker import Worker
|
|
14
20
|
|
|
15
21
|
__all__ = ["EXIT_CONFIGURATION", "EXIT_FAILURE", "EXIT_OK", "load_worker", "main"]
|
|
@@ -31,6 +37,11 @@ def _working_directory_first() -> None:
|
|
|
31
37
|
sys.path.insert(0, working_directory)
|
|
32
38
|
|
|
33
39
|
|
|
40
|
+
def _configuration_target_error(where: str, error: ValidationError) -> TargetError:
|
|
41
|
+
"""A pydantic ``ValidationError`` while loading is a configuration error: field names, never their values."""
|
|
42
|
+
return TargetError(f"Invalid worker configuration in {where}: {configuration_error_from_validation(error)}")
|
|
43
|
+
|
|
44
|
+
|
|
34
45
|
def load_worker(target: str) -> Worker:
|
|
35
46
|
"""Import ``module:attr``: ``attr`` is a ``Worker`` or a zero-argument factory function returning one."""
|
|
36
47
|
module_name, separator, attribute = target.partition(_TARGET_SEPARATOR)
|
|
@@ -46,6 +57,8 @@ def load_worker(target: str) -> Worker:
|
|
|
46
57
|
except _DEFINITION_ERRORS as error:
|
|
47
58
|
msg = f"Invalid worker definition in module {module_name!r}: {error}"
|
|
48
59
|
raise TargetError(msg) from error
|
|
60
|
+
except ValidationError as error:
|
|
61
|
+
raise _configuration_target_error(f"module {module_name!r}", error) from None
|
|
49
62
|
try:
|
|
50
63
|
candidate = getattr(module, attribute)
|
|
51
64
|
except AttributeError as error:
|
|
@@ -57,6 +70,8 @@ def load_worker(target: str) -> Worker:
|
|
|
57
70
|
except _DEFINITION_ERRORS as error:
|
|
58
71
|
msg = f"Invalid worker definition in {target!r}: {error}"
|
|
59
72
|
raise TargetError(msg) from error
|
|
73
|
+
except ValidationError as error:
|
|
74
|
+
raise _configuration_target_error(repr(target), error) from None
|
|
60
75
|
except TypeError as error:
|
|
61
76
|
msg = f"{target!r} cannot be called without arguments: {error}"
|
|
62
77
|
raise TargetError(msg) from error
|
|
@@ -6,6 +6,8 @@ from enum import StrEnum
|
|
|
6
6
|
from types import MappingProxyType
|
|
7
7
|
from typing import Final
|
|
8
8
|
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
9
11
|
_CODE_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Z][A-Z0-9_]{2,63}$")
|
|
10
12
|
_MIN_MESSAGE_LENGTH: Final[int] = 1
|
|
11
13
|
MAX_MESSAGE_LENGTH: Final[int] = 500
|
|
@@ -114,3 +116,20 @@ class WorkerConfigurationError(Exception):
|
|
|
114
116
|
|
|
115
117
|
class WorkerDefinitionError(Exception):
|
|
116
118
|
"""Invalid decorator usage, raised at import time."""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def configuration_error_from_validation(error: ValidationError) -> WorkerConfigurationError:
|
|
122
|
+
"""A configuration error for a pydantic ``ValidationError`` raised while setting the worker up (a hook, say).
|
|
123
|
+
|
|
124
|
+
The message names the invalid fields (``loc[0]``) and nothing else, never a value and never pydantic's ``msg``:
|
|
125
|
+
``input`` can hold a whole settings dict, secrets included, and a validator written by the user can format the very
|
|
126
|
+
value it rejects. An issue with no location (a model-level check) is reported as a fixed text with the name of the
|
|
127
|
+
model (``error.title``). The caller adds the prefix. Build the error outside an ``except`` block and raise it
|
|
128
|
+
``from None``, so that the original one, with its ``input``, is not chained to it.
|
|
129
|
+
"""
|
|
130
|
+
issues = error.errors(include_input=False, include_url=False, include_context=False)
|
|
131
|
+
names = list(dict.fromkeys(str(issue["loc"][0]) for issue in issues if issue["loc"]))
|
|
132
|
+
parts = [", ".join(names)] if names else []
|
|
133
|
+
if any(not issue["loc"] for issue in issues):
|
|
134
|
+
parts.append(f"{error.title} (a model-level check failed)")
|
|
135
|
+
return WorkerConfigurationError("; ".join(parts))
|
|
@@ -8,6 +8,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
8
8
|
|
|
9
9
|
from async_task_api_worker.errors import WorkerConfigurationError
|
|
10
10
|
|
|
11
|
+
_MISSING_TYPE: Final[str] = "missing"
|
|
11
12
|
DEFAULT_WORKER_CONCURRENCY: Final[int] = 1
|
|
12
13
|
DEFAULT_HEALTH_CHECK_HOST: Final[str] = "0.0.0.0" # noqa: S104 - the probe must be reachable from the pod network
|
|
13
14
|
DEFAULT_HEALTH_CHECK_PORT: Final[int] = 8083
|
|
@@ -116,12 +117,17 @@ class WorkerSettings(BaseSettings):
|
|
|
116
117
|
try:
|
|
117
118
|
return handler(data)
|
|
118
119
|
except ValidationError as error:
|
|
119
|
-
#
|
|
120
|
+
# THE RULE: the message is built from field names, never from a value or from pydantic's text about one. A
|
|
121
|
+
# field-level ``msg`` can quote the input (a validator that formats it, BROKER_URL above all, which carries
|
|
122
|
+
# credentials), so for those only the setting name and whether it is missing are said. A model-level ``msg``
|
|
123
|
+
# (no location) is the text of one of OUR validators, written without a value. That is deliberate: the
|
|
124
|
+
# user's validators, handled by ``configuration_error_from_validation``, can format theirs, so that one
|
|
125
|
+
# never uses a ``msg``.
|
|
120
126
|
problems = "; ".join(
|
|
121
|
-
f"{location}
|
|
127
|
+
f"{location} is {'missing' if issue['type'] == _MISSING_TYPE else 'invalid'}"
|
|
122
128
|
if (location := ".".join(map(str, issue["loc"])).upper())
|
|
123
129
|
else issue["msg"]
|
|
124
|
-
for issue in error.errors()
|
|
130
|
+
for issue in error.errors(include_input=False, include_url=False, include_context=False)
|
|
125
131
|
)
|
|
126
132
|
msg = f"Invalid worker settings: {problems}"
|
|
127
133
|
raise WorkerConfigurationError(msg) from None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{async_task_api_worker-0.1.0rc2 → async_task_api_worker-0.1.0rc3}/src/async_task_api_worker/py.typed
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|