e2a 2.3.0__tar.gz → 2.5.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 (29) hide show
  1. {e2a-2.3.0 → e2a-2.5.0}/CHANGELOG.md +52 -0
  2. {e2a-2.3.0 → e2a-2.5.0}/PKG-INFO +1 -1
  3. {e2a-2.3.0 → e2a-2.5.0}/pyproject.toml +1 -1
  4. {e2a-2.3.0 → e2a-2.5.0}/src/e2a/v1/api.py +46 -0
  5. {e2a-2.3.0 → e2a-2.5.0}/src/e2a/v1/async_client.py +53 -3
  6. {e2a-2.3.0 → e2a-2.5.0}/src/e2a/v1/client.py +37 -3
  7. {e2a-2.3.0 → e2a-2.5.0}/src/e2a/v1/generated/__init__.py +39 -5
  8. {e2a-2.3.0 → e2a-2.5.0}/tests/test_idempotency.py +53 -0
  9. {e2a-2.3.0 → e2a-2.5.0}/tests/test_v1_api.py +2 -2
  10. {e2a-2.3.0 → e2a-2.5.0}/tests/test_v1_async_client.py +2 -2
  11. {e2a-2.3.0 → e2a-2.5.0}/tests/test_v1_client.py +2 -2
  12. {e2a-2.3.0 → e2a-2.5.0}/.gitignore +0 -0
  13. {e2a-2.3.0 → e2a-2.5.0}/README.md +0 -0
  14. {e2a-2.3.0 → e2a-2.5.0}/codegen-requirements.txt +0 -0
  15. {e2a-2.3.0 → e2a-2.5.0}/src/e2a/__init__.py +0 -0
  16. {e2a-2.3.0 → e2a-2.5.0}/src/e2a/v1/__init__.py +0 -0
  17. {e2a-2.3.0 → e2a-2.5.0}/src/e2a/v1/generated/_internal.py +0 -0
  18. {e2a-2.3.0 → e2a-2.5.0}/src/e2a/v1/generated/github_com_Mnexa_AI_e2a_internal_identity.py +0 -0
  19. {e2a-2.3.0 → e2a-2.5.0}/src/e2a/v1/generated/internal_agent.py +0 -0
  20. {e2a-2.3.0 → e2a-2.5.0}/src/e2a/v1/handler.py +0 -0
  21. {e2a-2.3.0 → e2a-2.5.0}/src/e2a/v1/websocket.py +0 -0
  22. {e2a-2.3.0 → e2a-2.5.0}/tests/__init__.py +0 -0
  23. {e2a-2.3.0 → e2a-2.5.0}/tests/test_contract.py +0 -0
  24. {e2a-2.3.0 → e2a-2.5.0}/tests/test_e2e.py +0 -0
  25. {e2a-2.3.0 → e2a-2.5.0}/tests/test_exports.py +0 -0
  26. {e2a-2.3.0 → e2a-2.5.0}/tests/test_generated_models.py +0 -0
  27. {e2a-2.3.0 → e2a-2.5.0}/tests/test_v1_handler.py +0 -0
  28. {e2a-2.3.0 → e2a-2.5.0}/tests/test_v1_websocket.py +0 -0
  29. {e2a-2.3.0 → e2a-2.5.0}/uv.lock +0 -0
@@ -1,5 +1,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.5.0
4
+
5
+ ### Added
6
+ - Generated types for the per-user resource-limits primitive that
7
+ shipped with #158: `LimitsInfo`, `LimitsCaps`, `LimitsUsage`. These
8
+ describe the response shape of `GET /api/v1/users/me/limits`, which
9
+ the hosted dashboard uses to render the upgrade affordance and the
10
+ "you've used X of Y" surface. The high-level `E2AClient` doesn't
11
+ yet expose a typed helper for this endpoint — it's surfaced as a
12
+ dashboard-only concern today, and SDK consumers querying their own
13
+ usage should call `/agents` / `/messages` directly. The types are
14
+ emitted so anyone consuming the raw OpenAPI generation has the
15
+ shapes available.
16
+
17
+ ### Notes
18
+ - No runtime client behavior changed in this release. If you're not
19
+ using the limits primitive (self-host deployments without a paid
20
+ tier), 2.5.0 is functionally identical to 2.4.0.
21
+
22
+ ## 2.4.0
23
+
24
+ ### Added
25
+ - `idempotency_key` parameter on `E2AClient.approve_message()` and its
26
+ async counterpart (and on the lower-level `E2AApi.approve_message()`).
27
+ Approve fires a real SES send, so without a stable key a retry after
28
+ a transient failure could double-send. When supplied it's threaded
29
+ through as the `Idempotency-Key` header; when omitted the SDK mints
30
+ a fresh UUIDv4 per call — that gives network-layer retry safety only.
31
+ Supply a stable key derived from the review event (typically the
32
+ pending `message_id`) to dedupe across an explicit retry loop.
33
+ - `sort`, `from_`, `subject_contains`, `conversation_id`, `since`,
34
+ `until` kwargs on `E2AApi.list_messages()` and the high-level
35
+ `E2AClient.get_messages()` (sync + async). `sort` defaults
36
+ server-side to newest-first; pass `"asc"` for FIFO polling. The
37
+ substring filters are case-insensitive and capped at 200 chars
38
+ server-side. `since` / `until` accept RFC3339 timestamps and
39
+ bracket `created_at`. Filter values are encoded into `next_token`,
40
+ so continuation requests must keep the same filter values.
41
+
42
+ ### Changed
43
+ - **Default sort flipped to newest-first** on `GET /messages`. Prior
44
+ releases silently returned oldest-first for `direction=inbound` (the
45
+ SDK default) and newest-first for `direction=all`. A polling agent
46
+ that relied on FIFO drain order should now pass `sort="asc"` to
47
+ preserve the old behavior.
48
+ - `agent_mode` is now a required field on `RegisterAgentRequest`. The
49
+ server previously silently defaulted to `"cloud"` and then 400'd
50
+ with a cryptic "webhook_url is required" message; it now explicitly
51
+ rejects requests missing `agent_mode` with a clear error. Pydantic
52
+ v2 will raise a validation error if you instantiate the request
53
+ without it. Set `agent_mode="local"` or `"cloud"` explicitly.
54
+
3
55
  ## 2.3.0
4
56
 
5
57
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: e2a
3
- Version: 2.3.0
3
+ Version: 2.5.0
4
4
  Summary: Python SDK for the e2a protocol — email-to-agent authentication
5
5
  Project-URL: Homepage, https://e2a.dev
6
6
  Project-URL: Repository, https://github.com/Mnexa-AI/e2a
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "e2a"
7
- version = "2.3.0"
7
+ version = "2.5.0"
8
8
  description = "Python SDK for the e2a protocol — email-to-agent authentication"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -191,8 +191,44 @@ class E2AApi:
191
191
  status: str = "unread",
192
192
  page_size: int = 50,
193
193
  token: Optional[str] = None,
194
+ sort: Optional[str] = None,
195
+ from_: Optional[str] = None,
196
+ subject_contains: Optional[str] = None,
197
+ conversation_id: Optional[str] = None,
198
+ since: Optional[str] = None,
199
+ until: Optional[str] = None,
194
200
  ) -> ListMessagesResponse:
201
+ """List messages for an agent.
202
+
203
+ ``sort`` defaults server-side to ``"desc"`` (newest first).
204
+ Pass ``"asc"`` for FIFO polling — drain the inbox in arrival
205
+ order. The choice is encoded in ``next_token`` so subsequent
206
+ pages keep the same order; switching mid-pagination returns
207
+ 400.
208
+
209
+ ``from_``, ``subject_contains``: case-insensitive substring
210
+ match (Postgres ILIKE). Capped server-side at 200 chars.
211
+
212
+ ``conversation_id``: exact match — narrow to one thread.
213
+
214
+ ``since`` / ``until``: RFC3339 timestamps (``datetime.isoformat()``
215
+ produces a valid value as long as it ends in ``Z`` or has a
216
+ timezone offset). Bracket on ``created_at`` (``>= since`` and
217
+ ``< until``).
218
+ """
195
219
  params: dict[str, str] = {"status": status, "page_size": str(page_size)}
220
+ if sort:
221
+ params["sort"] = sort
222
+ if from_:
223
+ params["from"] = from_
224
+ if subject_contains:
225
+ params["subject_contains"] = subject_contains
226
+ if conversation_id:
227
+ params["conversation_id"] = conversation_id
228
+ if since:
229
+ params["since"] = since
230
+ if until:
231
+ params["until"] = until
196
232
  if token:
197
233
  params["token"] = token
198
234
  resp = self._client.get(
@@ -264,17 +300,27 @@ class E2AApi:
264
300
  self,
265
301
  message_id: str,
266
302
  overrides: Optional[ApprovePendingMessageRequest] = None,
303
+ idempotency_key: Optional[str] = None,
267
304
  ) -> ApprovePendingMessageResponse:
268
305
  """Approve a held outbound message.
269
306
 
270
307
  Pass ``overrides`` to approve with edits (any subset of
271
308
  subject / body_text / body_html / to / cc / bcc / attachments).
272
309
  Pass ``None`` (the default) to approve the draft as-is.
310
+
311
+ ``idempotency_key`` is sent as the ``Idempotency-Key`` header.
312
+ Approve fires a real SES send, so supplying a stable key
313
+ derived from the review event makes retries safe (the server
314
+ replays the original response instead of double-sending).
315
+ When omitted the SDK mints a fresh UUIDv4 per call — that
316
+ gives network-layer retry safety only; the per-call default
317
+ does not survive an explicit retry loop.
273
318
  """
274
319
  payload = overrides.model_dump(by_alias=True, exclude_none=True) if overrides else {}
275
320
  resp = self._client.post(
276
321
  f"/api/v1/messages/{quote(message_id, safe='')}/approve",
277
322
  json=payload,
323
+ headers=_idempotency_header(idempotency_key),
278
324
  )
279
325
  _check_response(resp)
280
326
  return ApprovePendingMessageResponse.model_validate(resp.json())
@@ -164,8 +164,28 @@ class AsyncE2AApi:
164
164
  status: str = "unread",
165
165
  page_size: int = 50,
166
166
  token: Optional[str] = None,
167
+ sort: Optional[str] = None,
168
+ from_: Optional[str] = None,
169
+ subject_contains: Optional[str] = None,
170
+ conversation_id: Optional[str] = None,
171
+ since: Optional[str] = None,
172
+ until: Optional[str] = None,
167
173
  ) -> ListMessagesResponse:
174
+ """Async variant of :meth:`E2AApi.list_messages`. See that
175
+ method for the full filter / sort docs."""
168
176
  params: dict[str, str] = {"status": status, "page_size": str(page_size)}
177
+ if sort:
178
+ params["sort"] = sort
179
+ if from_:
180
+ params["from"] = from_
181
+ if subject_contains:
182
+ params["subject_contains"] = subject_contains
183
+ if conversation_id:
184
+ params["conversation_id"] = conversation_id
185
+ if since:
186
+ params["since"] = since
187
+ if until:
188
+ params["until"] = until
169
189
  if token:
170
190
  params["token"] = token
171
191
  resp = await self._client.get(
@@ -231,11 +251,15 @@ class AsyncE2AApi:
231
251
  self,
232
252
  message_id: str,
233
253
  overrides: Optional[ApprovePendingMessageRequest] = None,
254
+ idempotency_key: Optional[str] = None,
234
255
  ) -> ApprovePendingMessageResponse:
256
+ """Async variant of :meth:`E2AApi.approve_message`. ``idempotency_key``
257
+ closes the SES double-send window — see that method for details."""
235
258
  payload = overrides.model_dump(by_alias=True, exclude_none=True) if overrides else {}
236
259
  resp = await self._client.post(
237
260
  f"/api/v1/messages/{quote(message_id, safe='')}/approve",
238
261
  json=payload,
262
+ headers=_idempotency_header(idempotency_key),
239
263
  )
240
264
  _check_response(resp)
241
265
  return ApprovePendingMessageResponse.model_validate(resp.json())
@@ -389,10 +413,35 @@ class AsyncE2AClient:
389
413
  page_size: int = 50,
390
414
  token: Optional[str] = None,
391
415
  agent_email: Optional[str] = None,
416
+ sort: Optional[str] = None,
417
+ from_: Optional[str] = None,
418
+ subject_contains: Optional[str] = None,
419
+ conversation_id: Optional[str] = None,
420
+ since: Optional[str] = None,
421
+ until: Optional[str] = None,
392
422
  ) -> MessageList:
393
- """Fetch message summaries with ergonomic field names."""
423
+ """Fetch message summaries with ergonomic field names.
424
+
425
+ ``sort`` defaults server-side to ``"desc"`` (newest first). Pass
426
+ ``"asc"`` to drain the inbox in arrival order — FIFO polling.
427
+
428
+ Search filters (``from_``, ``subject_contains``, ``conversation_id``,
429
+ ``since``, ``until``) match the sync client — see
430
+ :meth:`E2AApi.list_messages` for the full reference.
431
+ """
394
432
  email = self._require_agent_email(agent_email)
395
- resp = await self.api.list_messages(email, status=status, page_size=page_size, token=token)
433
+ resp = await self.api.list_messages(
434
+ email,
435
+ status=status,
436
+ page_size=page_size,
437
+ token=token,
438
+ sort=sort,
439
+ from_=from_,
440
+ subject_contains=subject_contains,
441
+ conversation_id=conversation_id,
442
+ since=since,
443
+ until=until,
444
+ )
396
445
  messages = [
397
446
  MessageSummary(
398
447
  message_id=m.message_id or "",
@@ -564,6 +613,7 @@ class AsyncE2AClient:
564
613
  to: Optional[list[str]] = None,
565
614
  cc: Optional[list[str]] = None,
566
615
  bcc: Optional[list[str]] = None,
616
+ idempotency_key: Optional[str] = None,
567
617
  ):
568
618
  any_override = any(
569
619
  v is not None for v in (subject, body_text, body_html, to, cc, bcc)
@@ -580,7 +630,7 @@ class AsyncE2AClient:
580
630
  if any_override
581
631
  else None
582
632
  )
583
- return await self.api.approve_message(message_id, overrides)
633
+ return await self.api.approve_message(message_id, overrides, idempotency_key=idempotency_key)
584
634
 
585
635
  async def reject_message(self, message_id: str, reason: str = ""):
586
636
  return await self.api.reject_message(message_id, reason)
@@ -185,10 +185,36 @@ class E2AClient:
185
185
  page_size: int = 50,
186
186
  token: Optional[str] = None,
187
187
  agent_email: Optional[str] = None,
188
+ sort: Optional[str] = None,
189
+ from_: Optional[str] = None,
190
+ subject_contains: Optional[str] = None,
191
+ conversation_id: Optional[str] = None,
192
+ since: Optional[str] = None,
193
+ until: Optional[str] = None,
188
194
  ) -> MessageList:
189
- """Fetch message summaries with ergonomic field names."""
195
+ """Fetch message summaries with ergonomic field names.
196
+
197
+ ``sort`` defaults server-side to ``"desc"`` (newest first). Pass
198
+ ``"asc"`` to drain the inbox in arrival order — FIFO polling.
199
+
200
+ ``from_`` / ``subject_contains`` are case-insensitive substring
201
+ filters (capped at 200 chars server-side). ``conversation_id``
202
+ exact-matches a thread. ``since`` / ``until`` are RFC3339
203
+ timestamps bounding ``created_at``.
204
+ """
190
205
  email = self._require_agent_email(agent_email)
191
- resp = self.api.list_messages(email, status=status, page_size=page_size, token=token)
206
+ resp = self.api.list_messages(
207
+ email,
208
+ status=status,
209
+ page_size=page_size,
210
+ token=token,
211
+ sort=sort,
212
+ from_=from_,
213
+ subject_contains=subject_contains,
214
+ conversation_id=conversation_id,
215
+ since=since,
216
+ until=until,
217
+ )
192
218
  messages = [
193
219
  MessageSummary(
194
220
  message_id=m.message_id or "",
@@ -369,11 +395,19 @@ class E2AClient:
369
395
  to: Optional[list[str]] = None,
370
396
  cc: Optional[list[str]] = None,
371
397
  bcc: Optional[list[str]] = None,
398
+ idempotency_key: Optional[str] = None,
372
399
  ):
373
400
  """Approve a held outbound message.
374
401
 
375
402
  Pass any subset of overrides to approve with edits; pass none
376
403
  to approve as-is.
404
+
405
+ ``idempotency_key`` makes retries safe across the SES double-
406
+ send window. Supply a stable key derived from the review event
407
+ (e.g. the dashboard click id or the pending ``message_id``) to
408
+ make retries dedupe. When omitted the SDK mints a fresh UUIDv4
409
+ per call — that gives network-layer retry safety only; the
410
+ per-call default does not survive an explicit retry loop.
377
411
  """
378
412
  any_override = any(
379
413
  v is not None for v in (subject, body_text, body_html, to, cc, bcc)
@@ -390,7 +424,7 @@ class E2AClient:
390
424
  if any_override
391
425
  else None
392
426
  )
393
- return self.api.approve_message(message_id, overrides)
427
+ return self.api.approve_message(message_id, overrides, idempotency_key=idempotency_key)
394
428
 
395
429
  def reject_message(self, message_id: str, reason: str = ""):
396
430
  """Reject a held outbound message. The optional reason is
@@ -143,6 +143,26 @@ class Domain(BaseModel):
143
143
  verified_at: str | None = None
144
144
 
145
145
 
146
+ class LimitsCaps(BaseModel):
147
+ model_config = ConfigDict(
148
+ populate_by_name=True,
149
+ )
150
+ max_agents: int | None = None
151
+ max_domains: int | None = None
152
+ max_messages_month: int | None = None
153
+ max_storage_bytes: int | None = None
154
+
155
+
156
+ class LimitsUsage(BaseModel):
157
+ model_config = ConfigDict(
158
+ populate_by_name=True,
159
+ )
160
+ agents: int | None = None
161
+ domains: int | None = None
162
+ messages_month: int | None = None
163
+ storage_bytes: int | None = None
164
+
165
+
146
166
  class ListAgentsResponse(BaseModel):
147
167
  model_config = ConfigDict(
148
168
  populate_by_name=True,
@@ -267,11 +287,15 @@ class RegisterAgentRequest(BaseModel):
267
287
  model_config = ConfigDict(
268
288
  populate_by_name=True,
269
289
  )
270
- agent_mode: str | None = None
271
- email: str | None = None
272
- name: str | None = None
273
- slug: str | None = None
274
- webhook_url: str | None = None
290
+ agent_mode: Literal['local', 'cloud'] = Field(
291
+ ...,
292
+ description='AgentMode selects how inbound mail is delivered. Required; must be "local" or "cloud". See the type-level docs for the difference.',
293
+ examples=['local'],
294
+ )
295
+ email: str | None = Field(None, examples=['my-bot@yourdomain.com'])
296
+ name: str | None = Field(None, examples=['My Bot'])
297
+ slug: str | None = Field(None, examples=['my-bot'])
298
+ webhook_url: str | None = Field(None, examples=['https://example.com/e2a/webhook'])
275
299
 
276
300
 
277
301
  class RegisterAgentResponse(BaseModel):
@@ -405,6 +429,16 @@ class ApprovePendingMessageRequest(BaseModel):
405
429
  to: list[str] | None = None
406
430
 
407
431
 
432
+ class LimitsInfo(BaseModel):
433
+ model_config = ConfigDict(
434
+ populate_by_name=True,
435
+ )
436
+ limits: LimitsCaps | None = None
437
+ plan_code: str | None = None
438
+ upgrade_url: str | None = None
439
+ usage: LimitsUsage | None = None
440
+
441
+
408
442
  class ListMessagesResponse(BaseModel):
409
443
  model_config = ConfigDict(
410
444
  populate_by_name=True,
@@ -128,3 +128,56 @@ def test_high_level_client_reply_threads_idempotency_key(httpx_mock):
128
128
 
129
129
  req = httpx_mock.get_request()
130
130
  assert req.headers["Idempotency-Key"] == "client-reply-key"
131
+
132
+
133
+ # approve_message is also side-effectful — fires a real SES send when
134
+ # the reviewer approves a held draft. Without an Idempotency-Key a
135
+ # transient retry after a successful approve could double-send. Cover
136
+ # the same contract: auto-generated key by default, caller-supplied
137
+ # key passes through verbatim, high-level client threads it through.
138
+
139
+
140
+ def test_approve_message_auto_generates_idempotency_key(httpx_mock):
141
+ httpx_mock.add_response(
142
+ url=f"{BASE}/api/v1/messages/msg_p/approve",
143
+ method="POST",
144
+ json={"status": "sent", "message_id": "msg_p", "method": "smtp", "edited": False},
145
+ )
146
+
147
+ with E2AApi(api_key="e2a_test") as api:
148
+ api.approve_message("msg_p")
149
+
150
+ req = httpx_mock.get_request()
151
+ key = req.headers["Idempotency-Key"]
152
+ assert key, "Idempotency-Key header not set"
153
+ assert UUIDV4_RE.match(key), f"key {key!r} is not a UUIDv4 hex/canonical shape"
154
+
155
+
156
+ def test_approve_message_honors_caller_supplied_key(httpx_mock):
157
+ httpx_mock.add_response(
158
+ url=f"{BASE}/api/v1/messages/msg_p/approve",
159
+ method="POST",
160
+ json={"status": "sent", "message_id": "msg_p", "method": "smtp", "edited": False},
161
+ )
162
+
163
+ with E2AApi(api_key="e2a_test") as api:
164
+ api.approve_message("msg_p", idempotency_key="approve-key-1")
165
+
166
+ req = httpx_mock.get_request()
167
+ assert req.headers["Idempotency-Key"] == "approve-key-1"
168
+
169
+
170
+ def test_high_level_client_approve_threads_idempotency_key(httpx_mock):
171
+ httpx_mock.add_response(
172
+ url=f"{BASE}/api/v1/messages/msg_p/approve",
173
+ method="POST",
174
+ json={"status": "sent", "message_id": "msg_p", "method": "smtp", "edited": False},
175
+ )
176
+
177
+ with E2AClient(
178
+ api_key="e2a_test", agent_email="bot@test.dev"
179
+ ) as client:
180
+ client.approve_message("msg_p", idempotency_key="high-level-approve-key")
181
+
182
+ req = httpx_mock.get_request()
183
+ assert req.headers["Idempotency-Key"] == "high-level-approve-key"
@@ -158,13 +158,13 @@ def test_register_agent(httpx_mock):
158
158
  )
159
159
 
160
160
  with E2AApi(api_key="k") as api:
161
- result = api.register_agent(RegisterAgentRequest(slug="new"))
161
+ result = api.register_agent(RegisterAgentRequest(slug="new", agent_mode="local"))
162
162
 
163
163
  assert isinstance(result, RegisterAgentResponse)
164
164
  assert result.email == "new@agents.e2a.dev"
165
165
 
166
166
  body = json.loads(httpx_mock.get_request().content)
167
- assert body == {"slug": "new"}
167
+ assert body == {"slug": "new", "agent_mode": "local"}
168
168
 
169
169
 
170
170
  def test_get_agent(httpx_mock):
@@ -294,10 +294,10 @@ async def test_register_agent_slug(httpx_mock):
294
294
  )
295
295
 
296
296
  async with AsyncE2AClient(api_key="k") as client:
297
- await client.register_agent("new")
297
+ await client.register_agent("new", agent_mode="local")
298
298
 
299
299
  body = json.loads(httpx_mock.get_request().content)
300
- assert body == {"slug": "new"}
300
+ assert body == {"slug": "new", "agent_mode": "local"}
301
301
 
302
302
 
303
303
  @pytest.mark.anyio
@@ -442,10 +442,10 @@ def test_register_agent_slug_only(httpx_mock):
442
442
  )
443
443
 
444
444
  with E2AClient(api_key="k") as client:
445
- result = client.register_agent("new")
445
+ result = client.register_agent("new", agent_mode="local")
446
446
 
447
447
  body = json.loads(httpx_mock.get_request().content)
448
- assert body == {"slug": "new"}
448
+ assert body == {"slug": "new", "agent_mode": "local"}
449
449
 
450
450
 
451
451
  def test_register_agent_custom_domain(httpx_mock):
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