webhook-co 0.2.1__tar.gz → 0.3.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.
- {webhook_co-0.2.1 → webhook_co-0.3.0}/PKG-INFO +1 -1
- {webhook_co-0.2.1 → webhook_co-0.3.0}/pyproject.toml +1 -1
- webhook_co-0.3.0/src/webhook_co/_advisory.py +133 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_http.py +15 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/client.py +12 -0
- webhook_co-0.3.0/tests/test_advisory.py +131 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/.gitignore +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/README.md +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/scripts/__init__.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/scripts/generate_models.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/scripts/packaged_smoke.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/__init__.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_config.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_errors.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_generated/__init__.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_generated/models.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_pagination.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_query.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_redaction.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/_retry.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/src/webhook_co/py.typed +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/__init__.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_client.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_config.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_coverage_extra.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_errors.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_generated_drift.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_http.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_pagination.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_query.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_redaction.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_retry.py +0 -0
- {webhook_co-0.2.1 → webhook_co-0.3.0}/tests/test_spec_coverage.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: webhook-co
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: The official Python SDK for the webhook.co API — a typed client with retries, cursor pagination, idempotency, and secret redaction.
|
|
5
5
|
Project-URL: Homepage, https://webhook.co
|
|
6
6
|
Project-URL: Repository, https://github.com/webhook-co/webhook
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "webhook-co"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "The official Python SDK for the webhook.co API — a typed client with retries, cursor pagination, idempotency, and secret redaction."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""CLIENT VERSION ADVISORIES (receiving end) + the versioned User-Agent.
|
|
2
|
+
|
|
3
|
+
The SDK identifies itself in its User-Agent; when this version is behind, the API answers with an
|
|
4
|
+
``x-webhook-advisory`` header ON A RESPONSE THE CALLER ALREADY ASKED FOR. So the SDK never polls PyPI,
|
|
5
|
+
never makes an unsolicited network call from inside your Lambda, and works fine offline — you simply hear
|
|
6
|
+
nothing.
|
|
7
|
+
|
|
8
|
+
House rules for surfacing it, because a library that nags is a library people vendor to shut it up:
|
|
9
|
+
- report ONCE per client, not once per request;
|
|
10
|
+
- prefer the caller's own handler; only fall back to a single ``warnings`` emission;
|
|
11
|
+
- be silenceable, and never break a request if any of this goes wrong.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import platform
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
import warnings
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Callable
|
|
22
|
+
|
|
23
|
+
ADVISORY_HEADER = "x-webhook-advisory"
|
|
24
|
+
DEPRECATION_HEADER = "deprecation"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _sdk_version() -> str:
|
|
28
|
+
"""The installed version, read from package metadata.
|
|
29
|
+
|
|
30
|
+
Unlike the TS and Go SDKs, Python does not need a stamped constant: the wheel's own metadata IS the
|
|
31
|
+
version, so the User-Agent physically cannot disagree with what is installed. Falls back to 0.0.0 when
|
|
32
|
+
running from a source tree with no metadata (a dev checkout).
|
|
33
|
+
"""
|
|
34
|
+
try:
|
|
35
|
+
from importlib.metadata import version
|
|
36
|
+
|
|
37
|
+
return version("webhook-co")
|
|
38
|
+
except (
|
|
39
|
+
Exception
|
|
40
|
+
): # pragma: no cover - defensive: never let identifying ourselves break a request
|
|
41
|
+
return "0.0.0"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
SDK_VERSION = _sdk_version()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def user_agent() -> str:
|
|
48
|
+
"""``webhook-co-python/0.2.1 (python/3.12.3; darwin)`` — client, version, runtime. Nothing about you."""
|
|
49
|
+
try:
|
|
50
|
+
py = platform.python_version()
|
|
51
|
+
os_name = sys.platform
|
|
52
|
+
return f"webhook-co-python/{SDK_VERSION} (python/{py}; {os_name})"
|
|
53
|
+
except Exception: # pragma: no cover
|
|
54
|
+
return f"webhook-co-python/{SDK_VERSION}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class WebhookAdvisory:
|
|
59
|
+
"""A version advisory from the server. ``deprecated`` = BELOW the supported floor: broken, not just old."""
|
|
60
|
+
|
|
61
|
+
deprecated: bool
|
|
62
|
+
current: str
|
|
63
|
+
latest: str
|
|
64
|
+
message: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_KIND = re.compile(
|
|
68
|
+
r"^(update-available|deprecated);\s*current=([\w.+-]+);\s*latest=([\w.+-]+)$"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_advisory(
|
|
73
|
+
header: str | None, deprecation: str | None
|
|
74
|
+
) -> WebhookAdvisory | None:
|
|
75
|
+
"""Parse the advisory header. Returns None for absent OR malformed input.
|
|
76
|
+
|
|
77
|
+
The server is not this SDK's parser: a garbled or hostile header must never raise inside a caller's
|
|
78
|
+
request path. The worst it can do is say nothing.
|
|
79
|
+
"""
|
|
80
|
+
if not header:
|
|
81
|
+
return None
|
|
82
|
+
m = _KIND.match(header.strip())
|
|
83
|
+
if not m:
|
|
84
|
+
return None
|
|
85
|
+
kind, current, latest = m.group(1), m.group(2), m.group(3)
|
|
86
|
+
deprecated = kind == "deprecated" or deprecation == "true"
|
|
87
|
+
if deprecated:
|
|
88
|
+
message = (
|
|
89
|
+
f"webhook.co: this SDK version ({current}) is no longer supported and may misbehave. "
|
|
90
|
+
f"Upgrade to {latest}: pip install --upgrade webhook-co"
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
message = (
|
|
94
|
+
f"webhook.co: a newer SDK is available ({current} -> {latest}). "
|
|
95
|
+
f"Upgrade with: pip install --upgrade webhook-co"
|
|
96
|
+
)
|
|
97
|
+
return WebhookAdvisory(
|
|
98
|
+
deprecated=deprecated, current=current, latest=latest, message=message
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def make_advisory_reporter(
|
|
103
|
+
on_advisory: Callable[[WebhookAdvisory], None] | None = None,
|
|
104
|
+
*,
|
|
105
|
+
silent: bool = False,
|
|
106
|
+
warn: Callable[[str], None] | None = None,
|
|
107
|
+
) -> Callable[[str | None, str | None], None]:
|
|
108
|
+
"""Build the per-client reporter: fires AT MOST ONCE, swallows a caller handler's exception.
|
|
109
|
+
|
|
110
|
+
A per-request nag would be a bug, not a feature. And if the caller's handler raises, that is their bug —
|
|
111
|
+
it must not surface as a failed API call.
|
|
112
|
+
"""
|
|
113
|
+
state = {"reported": False}
|
|
114
|
+
emit = warn or (lambda message: warnings.warn(message, UserWarning, stacklevel=2))
|
|
115
|
+
|
|
116
|
+
def report(header: str | None, deprecation: str | None) -> None:
|
|
117
|
+
if state["reported"] or silent:
|
|
118
|
+
return
|
|
119
|
+
advisory = parse_advisory(header, deprecation)
|
|
120
|
+
if advisory is None:
|
|
121
|
+
return
|
|
122
|
+
state["reported"] = True
|
|
123
|
+
try:
|
|
124
|
+
if on_advisory is not None:
|
|
125
|
+
on_advisory(advisory)
|
|
126
|
+
else:
|
|
127
|
+
emit(advisory.message)
|
|
128
|
+
except (
|
|
129
|
+
Exception
|
|
130
|
+
): # noqa: BLE001 - the caller's logging bug must not fail their request
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
return report
|
|
@@ -18,6 +18,7 @@ from typing import Any
|
|
|
18
18
|
|
|
19
19
|
import httpx
|
|
20
20
|
|
|
21
|
+
from ._advisory import ADVISORY_HEADER, DEPRECATION_HEADER, user_agent
|
|
21
22
|
from ._errors import (
|
|
22
23
|
WebhookErrorCode,
|
|
23
24
|
WebhookUnexpectedResponseError,
|
|
@@ -68,10 +69,12 @@ class HttpClient:
|
|
|
68
69
|
on_debug: Callable[[str], None] | None = None,
|
|
69
70
|
sleep: Callable[[float], None] | None = None,
|
|
70
71
|
rand: Callable[[], float] | None = None,
|
|
72
|
+
report_advisory: Callable[[str | None, str | None], None] | None = None,
|
|
71
73
|
) -> None:
|
|
72
74
|
self._base_url = base_url
|
|
73
75
|
self._api_key = api_key
|
|
74
76
|
self._client = http_client
|
|
77
|
+
self._report_advisory = report_advisory
|
|
75
78
|
self._max_retries = DEFAULT_MAX_RETRIES if max_retries is None else max_retries
|
|
76
79
|
self._timeout_s = timeout_s
|
|
77
80
|
self._refresh_auth = refresh_auth
|
|
@@ -108,6 +111,10 @@ class HttpClient:
|
|
|
108
111
|
headers = {
|
|
109
112
|
"authorization": f"Bearer {bearer}",
|
|
110
113
|
"accept": "application/json",
|
|
114
|
+
# Identify the client + version. This is what lets the server answer with a version
|
|
115
|
+
# advisory on a response you already asked for, instead of the SDK polling PyPI behind
|
|
116
|
+
# your back.
|
|
117
|
+
"user-agent": user_agent(),
|
|
111
118
|
}
|
|
112
119
|
content: bytes | None = None
|
|
113
120
|
if body is not None:
|
|
@@ -137,6 +144,14 @@ class HttpClient:
|
|
|
137
144
|
continue
|
|
138
145
|
raise error_from_transport(self._base_url) from None
|
|
139
146
|
|
|
147
|
+
# The server rides a version advisory on a response we already asked for. Surface it (at most
|
|
148
|
+
# once) regardless of status: a client too old to work is precisely the one seeing errors.
|
|
149
|
+
if self._report_advisory is not None:
|
|
150
|
+
self._report_advisory(
|
|
151
|
+
res.headers.get(ADVISORY_HEADER),
|
|
152
|
+
res.headers.get(DEPRECATION_HEADER),
|
|
153
|
+
)
|
|
154
|
+
|
|
140
155
|
if res.is_success:
|
|
141
156
|
return self._parse_success(res)
|
|
142
157
|
|
|
@@ -19,6 +19,7 @@ from urllib.parse import quote
|
|
|
19
19
|
import httpx
|
|
20
20
|
import pydantic
|
|
21
21
|
|
|
22
|
+
from ._advisory import WebhookAdvisory, make_advisory_reporter
|
|
22
23
|
from ._config import resolve_base_url
|
|
23
24
|
from ._errors import WebhookConfigError, WebhookUnexpectedResponseError
|
|
24
25
|
from ._generated import models as m
|
|
@@ -674,6 +675,11 @@ class WebhookClient:
|
|
|
674
675
|
default 30. Not a single total-wall-clock deadline.
|
|
675
676
|
refresh_auth: Hook to swap in a rotated bearer on a 401 (OAuth flows).
|
|
676
677
|
on_debug: Optional sink for redacted, single-line diagnostics (never the raw key).
|
|
678
|
+
on_advisory: Called AT MOST ONCE if the server reports this SDK version is behind. The advisory
|
|
679
|
+
rides an ``x-webhook-advisory`` header on a response you already made — the SDK never polls
|
|
680
|
+
PyPI on your behalf. Give a handler and it is yours to log; give none and the SDK emits a
|
|
681
|
+
single ``UserWarning``.
|
|
682
|
+
silence_advisories: Suppress version advisories entirely, including the warning.
|
|
677
683
|
"""
|
|
678
684
|
|
|
679
685
|
def __init__(
|
|
@@ -686,6 +692,8 @@ class WebhookClient:
|
|
|
686
692
|
timeout_s: float | None = None,
|
|
687
693
|
refresh_auth: Callable[[], str | None] | None = None,
|
|
688
694
|
on_debug: Callable[[str], None] | None = None,
|
|
695
|
+
on_advisory: Callable[[WebhookAdvisory], None] | None = None,
|
|
696
|
+
silence_advisories: bool = False,
|
|
689
697
|
sleep: Callable[[float], None] | None = None,
|
|
690
698
|
rand: Callable[[], float] | None = None,
|
|
691
699
|
) -> None:
|
|
@@ -708,6 +716,10 @@ class WebhookClient:
|
|
|
708
716
|
timeout_s=timeout_s if timeout_s is not None else DEFAULT_TIMEOUT_S,
|
|
709
717
|
refresh_auth=refresh_auth,
|
|
710
718
|
on_debug=on_debug,
|
|
719
|
+
# One reporter per client: the advisory fires at most once, not once per request.
|
|
720
|
+
report_advisory=make_advisory_reporter(
|
|
721
|
+
on_advisory, silent=silence_advisories
|
|
722
|
+
),
|
|
711
723
|
sleep=sleep,
|
|
712
724
|
rand=rand,
|
|
713
725
|
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The server-driven version advisory: the SDK identifies itself, the server answers on a response the
|
|
2
|
+
caller already made. No PyPI polling from inside someone's Lambda, no extra request."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from webhook_co import WebhookClient
|
|
10
|
+
from webhook_co._advisory import (
|
|
11
|
+
make_advisory_reporter,
|
|
12
|
+
parse_advisory,
|
|
13
|
+
user_agent,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TestUserAgent:
|
|
18
|
+
def test_identifies_client_and_version(self):
|
|
19
|
+
ua = user_agent()
|
|
20
|
+
assert ua.startswith("webhook-co-python/")
|
|
21
|
+
assert "python/" in ua
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestParseAdvisory:
|
|
25
|
+
def test_parses_an_update(self):
|
|
26
|
+
a = parse_advisory("update-available; current=0.2.0; latest=0.3.0", None)
|
|
27
|
+
assert a is not None and a.current == "0.2.0" and a.latest == "0.3.0"
|
|
28
|
+
assert a.deprecated is False
|
|
29
|
+
|
|
30
|
+
def test_deprecation_is_louder_than_an_update(self):
|
|
31
|
+
a = parse_advisory("deprecated; current=0.1.0; latest=0.3.0", "true")
|
|
32
|
+
assert a is not None and a.deprecated is True
|
|
33
|
+
assert "no longer supported" in a.message
|
|
34
|
+
|
|
35
|
+
def test_absent_or_malformed_returns_none_and_never_raises(self):
|
|
36
|
+
# The server is not this SDK's parser. A garbled header must never raise in a caller's request path.
|
|
37
|
+
for bad in [
|
|
38
|
+
None,
|
|
39
|
+
"",
|
|
40
|
+
"garbage",
|
|
41
|
+
"update-available",
|
|
42
|
+
"update-available; current=; latest=",
|
|
43
|
+
]:
|
|
44
|
+
assert parse_advisory(bad, None) is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class TestReporter:
|
|
48
|
+
def test_reports_once_however_many_requests(self):
|
|
49
|
+
seen = []
|
|
50
|
+
report = make_advisory_reporter(seen.append)
|
|
51
|
+
for _ in range(5):
|
|
52
|
+
report("update-available; current=0.2.0; latest=0.3.0", None)
|
|
53
|
+
assert len(seen) == 1 # a per-request nag is a bug, not a feature
|
|
54
|
+
|
|
55
|
+
def test_falls_back_to_a_single_warning_with_no_handler(self):
|
|
56
|
+
warned = []
|
|
57
|
+
report = make_advisory_reporter(None, warn=warned.append)
|
|
58
|
+
report("update-available; current=0.2.0; latest=0.3.0", None)
|
|
59
|
+
report("update-available; current=0.2.0; latest=0.3.0", None)
|
|
60
|
+
assert len(warned) == 1 and "0.2.0 -> 0.3.0" in warned[0]
|
|
61
|
+
|
|
62
|
+
def test_silent_says_nothing_at_all(self):
|
|
63
|
+
warned = []
|
|
64
|
+
report = make_advisory_reporter(None, silent=True, warn=warned.append)
|
|
65
|
+
report("deprecated; current=0.1.0; latest=0.3.0", "true")
|
|
66
|
+
assert warned == []
|
|
67
|
+
|
|
68
|
+
def test_a_throwing_handler_never_breaks_the_request(self):
|
|
69
|
+
def boom(_a):
|
|
70
|
+
raise RuntimeError("caller's logging bug")
|
|
71
|
+
|
|
72
|
+
report = make_advisory_reporter(boom)
|
|
73
|
+
report("update-available; current=0.2.0; latest=0.3.0", None) # must not raise
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestEndToEnd:
|
|
77
|
+
"""Through the real client: we send the UA, the server rides an advisory, we surface it ONCE."""
|
|
78
|
+
|
|
79
|
+
# A valid whoami body: the SDK validates responses, so a thin stub is (correctly) rejected.
|
|
80
|
+
WHOAMI = {
|
|
81
|
+
"orgId": "22222222-2222-4222-8222-222222222222",
|
|
82
|
+
"userId": None,
|
|
83
|
+
"scopes": [],
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
def _client(self, headers, seen, **kw):
|
|
87
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
88
|
+
seen.append(request)
|
|
89
|
+
return httpx.Response(200, json=self.WHOAMI, headers=headers)
|
|
90
|
+
|
|
91
|
+
return WebhookClient(
|
|
92
|
+
api_key="whk_advisory_test_key_abc",
|
|
93
|
+
http_client=httpx.Client(transport=httpx.MockTransport(handler)),
|
|
94
|
+
**kw,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def test_sends_a_versioned_user_agent(self):
|
|
98
|
+
seen = []
|
|
99
|
+
client = self._client({}, seen)
|
|
100
|
+
client.whoami()
|
|
101
|
+
assert seen[0].headers["user-agent"].startswith("webhook-co-python/")
|
|
102
|
+
|
|
103
|
+
def test_surfaces_the_advisory_exactly_once(self):
|
|
104
|
+
got, seen = [], []
|
|
105
|
+
client = self._client(
|
|
106
|
+
{"x-webhook-advisory": "update-available; current=0.0.0; latest=9.9.9"},
|
|
107
|
+
seen,
|
|
108
|
+
on_advisory=got.append,
|
|
109
|
+
)
|
|
110
|
+
client.whoami()
|
|
111
|
+
client.whoami()
|
|
112
|
+
client.whoami()
|
|
113
|
+
assert len(got) == 1 # once per client, not once per request
|
|
114
|
+
assert got[0].latest == "9.9.9"
|
|
115
|
+
|
|
116
|
+
def test_silent_when_the_server_sends_no_advisory(self):
|
|
117
|
+
got, seen = [], []
|
|
118
|
+
client = self._client({}, seen, on_advisory=got.append)
|
|
119
|
+
client.whoami()
|
|
120
|
+
assert got == []
|
|
121
|
+
|
|
122
|
+
def test_can_be_silenced(self):
|
|
123
|
+
got, seen = [], []
|
|
124
|
+
client = self._client(
|
|
125
|
+
{"x-webhook-advisory": "update-available; current=0.0.0; latest=9.9.9"},
|
|
126
|
+
seen,
|
|
127
|
+
on_advisory=got.append,
|
|
128
|
+
silence_advisories=True,
|
|
129
|
+
)
|
|
130
|
+
client.whoami()
|
|
131
|
+
assert got == []
|
|
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
|
|
File without changes
|