hookbase 2.9.0__tar.gz → 3.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.
Files changed (81) hide show
  1. {hookbase-2.9.0 → hookbase-3.0.0}/PKG-INFO +1 -1
  2. hookbase-3.0.0/src/hookbase/_version.py +1 -0
  3. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/sources.py +78 -33
  4. hookbase-3.0.0/tests/resources/test_source_fields.py +105 -0
  5. {hookbase-2.9.0 → hookbase-3.0.0}/tests/resources/test_sources.py +6 -3
  6. hookbase-2.9.0/src/hookbase/_version.py +0 -1
  7. {hookbase-2.9.0 → hookbase-3.0.0}/.github/workflows/python-publish.yml +0 -0
  8. {hookbase-2.9.0 → hookbase-3.0.0}/.gitignore +0 -0
  9. {hookbase-2.9.0 → hookbase-3.0.0}/LICENSE +0 -0
  10. {hookbase-2.9.0 → hookbase-3.0.0}/README.md +0 -0
  11. {hookbase-2.9.0 → hookbase-3.0.0}/examples/async_usage.py +0 -0
  12. {hookbase-2.9.0 → hookbase-3.0.0}/examples/basic_inbound.py +0 -0
  13. {hookbase-2.9.0 → hookbase-3.0.0}/examples/error_handling.py +0 -0
  14. {hookbase-2.9.0 → hookbase-3.0.0}/examples/send_webhooks.py +0 -0
  15. {hookbase-2.9.0 → hookbase-3.0.0}/examples/verify_webhooks.py +0 -0
  16. {hookbase-2.9.0 → hookbase-3.0.0}/pyproject.toml +0 -0
  17. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/__init__.py +0 -0
  18. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/_client.py +0 -0
  19. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/_constants.py +0 -0
  20. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/_pagination.py +0 -0
  21. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/client.py +0 -0
  22. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/errors.py +0 -0
  23. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/__init__.py +0 -0
  24. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/_base.py +0 -0
  25. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/analytics.py +0 -0
  26. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/api_keys.py +0 -0
  27. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/applications.py +0 -0
  28. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/common.py +0 -0
  29. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/cron_jobs.py +0 -0
  30. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/deliveries.py +0 -0
  31. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/destinations.py +0 -0
  32. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/dlq.py +0 -0
  33. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/endpoints.py +0 -0
  34. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/event_types.py +0 -0
  35. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/events.py +0 -0
  36. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/filters.py +0 -0
  37. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/messages.py +0 -0
  38. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/organizations.py +0 -0
  39. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/portal_tokens.py +0 -0
  40. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/routes.py +0 -0
  41. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/schemas.py +0 -0
  42. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/subscriptions.py +0 -0
  43. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/transforms.py +0 -0
  44. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/models/tunnels.py +0 -0
  45. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/py.typed +0 -0
  46. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/__init__.py +0 -0
  47. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/_base.py +0 -0
  48. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/analytics.py +0 -0
  49. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/api_keys.py +0 -0
  50. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/applications.py +0 -0
  51. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/cron_jobs.py +0 -0
  52. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/deliveries.py +0 -0
  53. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/destinations.py +0 -0
  54. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/dlq.py +0 -0
  55. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/endpoints.py +0 -0
  56. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/event_types.py +0 -0
  57. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/events.py +0 -0
  58. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/filters.py +0 -0
  59. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/message_log.py +0 -0
  60. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/messages.py +0 -0
  61. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/organizations.py +0 -0
  62. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/portal_tokens.py +0 -0
  63. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/routes.py +0 -0
  64. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/schemas.py +0 -0
  65. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/sources.py +0 -0
  66. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/subscriptions.py +0 -0
  67. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/transforms.py +0 -0
  68. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/resources/tunnels.py +0 -0
  69. {hookbase-2.9.0 → hookbase-3.0.0}/src/hookbase/webhook.py +0 -0
  70. {hookbase-2.9.0 → hookbase-3.0.0}/tests/__init__.py +0 -0
  71. {hookbase-2.9.0 → hookbase-3.0.0}/tests/conftest.py +0 -0
  72. {hookbase-2.9.0 → hookbase-3.0.0}/tests/resources/__init__.py +0 -0
  73. {hookbase-2.9.0 → hookbase-3.0.0}/tests/resources/test_applications.py +0 -0
  74. {hookbase-2.9.0 → hookbase-3.0.0}/tests/resources/test_cron_jobs.py +0 -0
  75. {hookbase-2.9.0 → hookbase-3.0.0}/tests/resources/test_destinations.py +0 -0
  76. {hookbase-2.9.0 → hookbase-3.0.0}/tests/resources/test_dlq.py +0 -0
  77. {hookbase-2.9.0 → hookbase-3.0.0}/tests/resources/test_messages.py +0 -0
  78. {hookbase-2.9.0 → hookbase-3.0.0}/tests/test_client.py +0 -0
  79. {hookbase-2.9.0 → hookbase-3.0.0}/tests/test_errors.py +0 -0
  80. {hookbase-2.9.0 → hookbase-3.0.0}/tests/test_pagination.py +0 -0
  81. {hookbase-2.9.0 → hookbase-3.0.0}/tests/test_webhook.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hookbase
3
- Version: 2.9.0
3
+ Version: 3.0.0
4
4
  Summary: Official Python SDK for the Hookbase webhook management platform
5
5
  Project-URL: Homepage, https://hookbase.app
6
6
  Project-URL: Documentation, https://www.hookbase.app/docs/send/sdk
@@ -0,0 +1 @@
1
+ __version__ = "3.0.0"
@@ -21,9 +21,10 @@ from ._base import HookbaseModel
21
21
  #
22
22
  # "svix" is an alias of "standard-webhooks"; both resolve to the same scheme server-side.
23
23
  SourceProvider = Literal[
24
- "bitbucket", "custom", "generic", "github", "gitlab", "heroku",
25
- "lemonsqueezy", "paddle", "sentry", "shopify", "slack", "standard-webhooks",
26
- "stripe", "svix", "twilio", "typeform", "zoom",
24
+ "airtable", "asana", "bitbucket", "calendly", "custom", "generic",
25
+ "github", "gitlab", "heroku", "intercom", "lemonsqueezy", "notion",
26
+ "paddle", "razorpay", "sentry", "shopify", "slack", "standard-webhooks",
27
+ "stripe", "svix", "twilio", "typeform", "workos", "zoom",
27
28
  ]
28
29
  # Corrected alongside SourceProvider: "header" and "event_id" were never accepted, and the three
29
30
  # values the API actually defaults to and documents were missing. "auto" is the default.
@@ -35,48 +36,77 @@ IngestMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]
35
36
 
36
37
 
37
38
  class Source(HookbaseModel):
39
+ """A source as the API returns it.
40
+
41
+ The signing secret is not included. Responses carry ``has_signing_secret`` and the last four
42
+ characters; the full value comes back exactly twice, from :meth:`Sources.create` (which
43
+ returns a :class:`SourceWithSecret`) and from :meth:`Sources.reveal_secret`.
44
+ """
45
+
38
46
  id: str
39
47
  organization_id: str | None = None
40
48
  name: str
41
49
  slug: str
50
+ provider: SourceProvider | None = None
42
51
  description: str | None = None
43
- provider: SourceProvider = "generic"
52
+ #: Whether a signing secret is set. The secret itself is not returned here.
53
+ has_signing_secret: bool = False
54
+ #: Last four characters of the signing secret, prefixed with "...", or None if unset.
55
+ signing_secret_last4: str | None = None
56
+ #: Reject events whose signature fails verification. Defaults to False, in which case a
57
+ #: failing event is flagged with signature_valid=False and delivered anyway.
58
+ reject_invalid_signatures: bool = False
59
+ rate_limit_per_minute: int | None = None
44
60
  is_active: bool = True
45
- signing_secret: str | None = None
46
- ingest_url: str | None = None
47
- verify_signature: bool = False
48
- dedup_strategy: str | None = "none"
49
- dedup_window: int | None = None
50
- dedup_header_name: str | None = None
61
+ custom_domain_id: str | None = None
51
62
  ip_filter_mode: IpFilterMode = "none"
52
- ip_allowlist: list[str] | None = None
53
- ip_denylist: list[str] | None = None
54
- rate_limit: int | None = None
55
- rate_limit_window: int | None = None
63
+ ip_allowlist: list[str] = []
64
+ ip_denylist: list[str] = []
65
+ #: JSONPath expressions whose values are encrypted at rest.
66
+ encrypt_fields: list[str] = []
67
+ #: JSONPath expressions whose values are masked in stored payloads.
68
+ mask_fields: list[str] = []
69
+ dedup_enabled: bool = False
70
+ dedup_strategy: DedupStrategy = "auto"
71
+ dedup_window_hours: int = 24
72
+ dedup_custom_header: str | None = None
56
73
  transient_mode: bool = False
57
74
  #: HTTP verbs the ingest endpoint accepts. Empty list means any method.
58
75
  allowed_methods: list[str] = []
59
76
  event_count: int = 0
60
- last_event_at: str | None = None
77
+ route_count: int = 0
78
+ #: Returned by get() and create(). Not included in list() responses.
79
+ ingest_url: str | None = None
61
80
  created_at: str = ""
62
81
  updated_at: str = ""
63
82
 
64
- @field_validator("is_active", "verify_signature", "transient_mode", mode="before")
83
+ @field_validator(
84
+ "is_active",
85
+ "reject_invalid_signatures",
86
+ "has_signing_secret",
87
+ "dedup_enabled",
88
+ "transient_mode",
89
+ mode="before",
90
+ )
65
91
  @classmethod
66
92
  def parse_bool_from_int(cls, v: Any) -> Any:
67
93
  if isinstance(v, int):
68
94
  return bool(v)
69
95
  return v
70
96
 
71
- @field_validator("ip_allowlist", "ip_denylist", mode="before")
97
+ @field_validator(
98
+ "ip_allowlist", "ip_denylist", "encrypt_fields", "mask_fields", mode="before"
99
+ )
72
100
  @classmethod
73
101
  def parse_json_list(cls, v: Any) -> Any:
102
+ if v is None:
103
+ return []
74
104
  if isinstance(v, str):
75
105
  try:
76
106
  parsed = json.loads(v)
77
- return parsed if isinstance(parsed, list) else None
107
+ return parsed if isinstance(parsed, list) else []
78
108
  except (json.JSONDecodeError, ValueError):
79
- return None
109
+ return []
80
110
  return v
81
111
 
82
112
  @field_validator("allowed_methods", mode="before")
@@ -95,23 +125,34 @@ class Source(HookbaseModel):
95
125
 
96
126
 
97
127
  class SourceWithSecret(Source):
128
+ """What create() returns: a source plus the one look at its signing secret you get."""
129
+
98
130
  signing_secret: str = ""
99
131
 
100
132
 
101
133
  class CreateSourceParams(HookbaseModel):
102
134
  name: str
103
- slug: str | None = None
104
- description: str | None = None
135
+ #: Required: it forms the ingest URL, /ingest/<org>/<slug>, and cannot be changed later.
136
+ slug: str
105
137
  provider: SourceProvider | None = None
106
- verify_signature: bool | None = None
107
- dedup_strategy: DedupStrategy | None = None
108
- dedup_window: int | None = None
109
- dedup_header_name: str | None = None
138
+ description: str | None = None
139
+ #: Supply your own secret to match what the provider is already configured with. Omit it and
140
+ #: the API generates one, returned once on the created source.
141
+ signing_secret: str | None = None
142
+ #: Reject events whose signature fails verification. Defaults to False.
143
+ reject_invalid_signatures: bool | None = None
144
+ rate_limit_per_minute: int | None = None
110
145
  ip_filter_mode: IpFilterMode | None = None
111
146
  ip_allowlist: list[str] | None = None
112
147
  ip_denylist: list[str] | None = None
113
- rate_limit: int | None = None
114
- rate_limit_window: int | None = None
148
+ encrypt_fields: list[str] | None = None
149
+ mask_fields: list[str] | None = None
150
+ dedup_enabled: bool | None = None
151
+ dedup_strategy: DedupStrategy | None = None
152
+ #: Deduplication window in hours, 1 to 168.
153
+ dedup_window_hours: int | None = None
154
+ #: Header to deduplicate on when dedup_strategy is "idempotency_key".
155
+ dedup_custom_header: str | None = None
115
156
  transient_mode: bool | None = None
116
157
  #: Restrict the ingest endpoint to these verbs. None/[] accepts any method.
117
158
  allowed_methods: list[IngestMethod] | None = None
@@ -120,16 +161,20 @@ class CreateSourceParams(HookbaseModel):
120
161
  class UpdateSourceParams(HookbaseModel):
121
162
  name: str | None = None
122
163
  description: str | None = None
164
+ provider: SourceProvider | None = None
123
165
  is_active: bool | None = None
124
- verify_signature: bool | None = None
125
- dedup_strategy: DedupStrategy | None = None
126
- dedup_window: int | None = None
127
- dedup_header_name: str | None = None
166
+ signing_secret: str | None = None
167
+ reject_invalid_signatures: bool | None = None
168
+ rate_limit_per_minute: int | None = None
128
169
  ip_filter_mode: IpFilterMode | None = None
129
170
  ip_allowlist: list[str] | None = None
130
171
  ip_denylist: list[str] | None = None
131
- rate_limit: int | None = None
132
- rate_limit_window: int | None = None
172
+ encrypt_fields: list[str] | None = None
173
+ mask_fields: list[str] | None = None
174
+ dedup_enabled: bool | None = None
175
+ dedup_strategy: DedupStrategy | None = None
176
+ dedup_window_hours: int | None = None
177
+ dedup_custom_header: str | None = None
133
178
  transient_mode: bool | None = None
134
179
  #: Restrict the ingest endpoint to these verbs. Pass [] to accept any method again.
135
180
  allowed_methods: list[IngestMethod] | None = None
@@ -0,0 +1,105 @@
1
+ """The field names this SDK puts on the wire, as POST/PATCH /api/sources accept them.
2
+
3
+ These lists exist because they were wrong for months and nothing said so. The models named
4
+ ``verify_signature``, ``dedup_window``, ``dedup_header_name``, ``rate_limit`` and
5
+ ``rate_limit_window``, which the camelCase alias generator sent as ``verifySignature`` and
6
+ friends. The API's create schema was a plain zod object, which strips unknown keys rather than
7
+ refusing them, so every one of those calls returned 201 having quietly dropped the setting: a
8
+ source created with ``verify_signature=True`` came back with verification off and raised no error.
9
+
10
+ The create schema is strict now, so a stale name is a 400 rather than a silent drop — which is why
11
+ a rename here has to be deliberate.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import pytest
17
+ from pydantic import ValidationError
18
+
19
+ from hookbase.models.sources import (
20
+ CreateSourceParams,
21
+ Source,
22
+ SourceWithSecret,
23
+ UpdateSourceParams,
24
+ )
25
+
26
+ CREATE_FIELDS = {
27
+ "name", "slug", "provider", "description", "signingSecret",
28
+ "rejectInvalidSignatures", "rateLimitPerMinute", "ipFilterMode", "ipAllowlist",
29
+ "ipDenylist", "encryptFields", "maskFields", "dedupEnabled", "dedupStrategy",
30
+ "dedupWindowHours", "dedupCustomHeader", "transientMode", "allowedMethods",
31
+ }
32
+
33
+ UPDATE_FIELDS = (CREATE_FIELDS - {"slug"}) | {"isActive"}
34
+
35
+
36
+ def _aliases(model: type) -> set[str]:
37
+ return {f.alias or name for name, f in model.model_fields.items()}
38
+
39
+
40
+ def test_create_params_send_exactly_the_accepted_fields() -> None:
41
+ assert _aliases(CreateSourceParams) == CREATE_FIELDS
42
+
43
+
44
+ def test_update_params_send_exactly_the_accepted_fields() -> None:
45
+ assert _aliases(UpdateSourceParams) == UPDATE_FIELDS
46
+
47
+
48
+ def test_create_and_update_differ_only_by_slug_and_is_active() -> None:
49
+ create, update = _aliases(CreateSourceParams), _aliases(UpdateSourceParams)
50
+ assert create - update == {"slug"}
51
+ assert update - create == {"isActive"}
52
+
53
+
54
+ def test_slug_is_required_on_create() -> None:
55
+ """slug forms the ingest URL and cannot be changed afterwards, so create demands it."""
56
+ with pytest.raises(ValidationError, match="slug"):
57
+ CreateSourceParams(name="No slug")
58
+
59
+
60
+ def test_a_full_create_serializes_every_field_under_its_wire_name() -> None:
61
+ params = CreateSourceParams(
62
+ name="Stripe Production",
63
+ slug="stripe",
64
+ provider="stripe",
65
+ description="Live charge events",
66
+ signing_secret="whsec_abc",
67
+ reject_invalid_signatures=True,
68
+ rate_limit_per_minute=600,
69
+ ip_filter_mode="allowlist",
70
+ ip_allowlist=["203.0.113.0/24"],
71
+ ip_denylist=[],
72
+ encrypt_fields=["$.data.object.customer_email"],
73
+ mask_fields=["$.data.object.last4"],
74
+ dedup_enabled=True,
75
+ dedup_strategy="provider_id",
76
+ dedup_window_hours=24,
77
+ dedup_custom_header="X-Idempotency-Key",
78
+ transient_mode=False,
79
+ allowed_methods=["POST"],
80
+ )
81
+ assert set(params.model_dump(by_alias=True, exclude_none=True)) == CREATE_FIELDS
82
+
83
+
84
+ def test_a_listed_source_carries_the_masked_pair_not_the_secret() -> None:
85
+ src = Source(
86
+ id="src_1", name="Stripe", slug="stripe",
87
+ hasSigningSecret=True, signingSecretLast4="...c123",
88
+ rejectInvalidSignatures=True, dedupWindowHours=24, routeCount=3,
89
+ )
90
+ assert src.has_signing_secret is True
91
+ assert src.signing_secret_last4 == "...c123"
92
+ assert src.reject_invalid_signatures is True
93
+ assert src.dedup_window_hours == 24
94
+ assert src.route_count == 3
95
+ assert not hasattr(src, "signing_secret")
96
+
97
+
98
+ def test_a_created_source_carries_the_secret_and_the_ingest_url() -> None:
99
+ created = SourceWithSecret(
100
+ id="src_1", name="Stripe", slug="stripe",
101
+ signingSecret="whsec_abc",
102
+ ingestUrl="https://api.hookbase.app/ingest/acme/stripe",
103
+ )
104
+ assert created.signing_secret == "whsec_abc"
105
+ assert created.ingest_url is not None and "/ingest/" in created.ingest_url
@@ -30,10 +30,13 @@ SOURCE_DATA = {
30
30
  "slug": "github",
31
31
  "provider": "github",
32
32
  "isActive": True,
33
- "verifySignature": True,
33
+ "hasSigningSecret": True,
34
+ "signingSecretLast4": "...c123",
35
+ "rejectInvalidSignatures": True,
34
36
  "dedupStrategy": "none",
35
37
  "ipFilterMode": "none",
36
38
  "eventCount": 42,
39
+ "routeCount": 3,
37
40
  "createdAt": "2024-01-01T00:00:00Z",
38
41
  "updatedAt": "2024-01-01T00:00:00Z",
39
42
  }
@@ -158,7 +161,7 @@ def test_create_source_with_transient_mode(mock_api, client):
158
161
 
159
162
 
160
163
  def test_create_source_params_serialization():
161
- params = CreateSourceParams(name="test", transient_mode=True)
164
+ params = CreateSourceParams(name="test", slug="test", transient_mode=True)
162
165
  dumped = params.model_dump(by_alias=True, exclude_none=True)
163
166
  assert dumped["transientMode"] is True
164
167
 
@@ -227,7 +230,7 @@ def test_source_allowed_methods_malformed_fails_open():
227
230
 
228
231
 
229
232
  def test_create_source_params_serializes_allowed_methods():
230
- params = CreateSourceParams(name="s", allowed_methods=["GET", "POST"])
233
+ params = CreateSourceParams(name="s", slug="s", allowed_methods=["GET", "POST"])
231
234
  assert params.model_dump(by_alias=True, exclude_none=True)["allowedMethods"] == ["GET", "POST"]
232
235
 
233
236
 
@@ -1 +0,0 @@
1
- __version__ = "2.9.0"
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