dev-bubble 0.2.0__tar.gz → 0.2.1__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 (55) hide show
  1. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/.claude/CLAUDE.md +26 -0
  2. dev_bubble-0.2.1/.github/workflows/publish.yml +35 -0
  3. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/PKG-INFO +6 -4
  4. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/README.md +5 -3
  5. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/cli.py +26 -12
  6. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/hooks/lean.py +1 -1
  7. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/images/scripts/base.sh +3 -8
  8. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/network.py +1 -1
  9. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/relay.py +27 -3
  10. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/repo_registry.py +1 -1
  11. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/dev_bubble.egg-info/PKG-INFO +6 -4
  12. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/dev_bubble.egg-info/SOURCES.txt +1 -0
  13. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/pyproject.toml +1 -1
  14. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/tests/test_target.py +0 -3
  15. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/.github/workflows/ci.yml +0 -0
  16. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/.gitignore +0 -0
  17. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/LICENSE +0 -0
  18. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/__init__.py +0 -0
  19. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/automation.py +0 -0
  20. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/clean.py +0 -0
  21. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/config.py +0 -0
  22. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/git_store.py +0 -0
  23. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/hooks/__init__.py +0 -0
  24. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/images/__init__.py +0 -0
  25. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/images/builder.py +0 -0
  26. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/images/scripts/lean-toolchain.sh +0 -0
  27. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/images/scripts/lean.sh +0 -0
  28. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/lifecycle.py +0 -0
  29. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/naming.py +0 -0
  30. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/runtime/__init__.py +0 -0
  31. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/runtime/base.py +0 -0
  32. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/runtime/colima.py +0 -0
  33. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/runtime/incus.py +0 -0
  34. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/target.py +0 -0
  35. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/bubble/vscode.py +0 -0
  36. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/claude-skill/SKILL.md +0 -0
  37. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/config/com.bubble.git-update.plist +0 -0
  38. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/config/com.bubble.image-refresh.plist +0 -0
  39. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/config/com.bubble.relay-daemon.plist +0 -0
  40. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/dev_bubble.egg-info/dependency_links.txt +0 -0
  41. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/dev_bubble.egg-info/entry_points.txt +0 -0
  42. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/dev_bubble.egg-info/requires.txt +0 -0
  43. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/dev_bubble.egg-info/top_level.txt +0 -0
  44. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/setup.cfg +0 -0
  45. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/tests/conftest.py +0 -0
  46. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/tests/test_config.py +0 -0
  47. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/tests/test_git_store.py +0 -0
  48. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/tests/test_hooks.py +0 -0
  49. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/tests/test_integration.py +0 -0
  50. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/tests/test_lifecycle.py +0 -0
  51. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/tests/test_naming.py +0 -0
  52. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/tests/test_network.py +0 -0
  53. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/tests/test_relay.py +0 -0
  54. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/tests/test_repo_registry.py +0 -0
  55. {dev_bubble-0.2.0 → dev_bubble-0.2.1}/tests/test_vscode.py +0 -0
@@ -164,3 +164,29 @@ VS Code must be restarted after modifying this database.
164
164
 
165
165
  ### Pre-baked VS Code Server
166
166
  The base image pre-installs the VS Code Server binary matching the host's `code --version` commit hash. On each `bubble open`, if the hash has changed (VS Code updated), a background `bubble images build base` is triggered. The current bubble proceeds immediately; the next one gets the pre-baked server.
167
+
168
+ ## PyPI Publishing
169
+
170
+ The package is published to PyPI as **`dev-bubble`** (the CLI command is still `bubble`). Users install with `pipx install dev-bubble` or `uv tool install dev-bubble`.
171
+
172
+ ### Releasing a New Version
173
+
174
+ When making changes that warrant a release (new features, bug fixes, improvements), create a new version tag:
175
+
176
+ 1. Bump `version` in `pyproject.toml` (use semver: patch for fixes, minor for features)
177
+ 2. Commit the version bump
178
+ 3. Tag and push:
179
+ ```bash
180
+ git tag v0.X.Y
181
+ git push origin v0.X.Y
182
+ ```
183
+
184
+ The `.github/workflows/publish.yml` workflow runs tests then publishes to PyPI automatically via trusted publishing (no API tokens needed).
185
+
186
+ ### When to Release
187
+
188
+ **Proactively tag a new minor/patch version** after completing work that changes user-visible behavior. Don't let changes accumulate unreleased — small frequent releases are preferred. If you've just made a meaningful change, bump the version and tag it.
189
+
190
+ ### Trusted Publisher Setup
191
+
192
+ PyPI is configured to trust GitHub Actions from `kim-em/bubble` with the `publish.yml` workflow and `pypi` environment. No secrets or API tokens are stored in the repository.
@@ -0,0 +1,35 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ permissions:
9
+ contents: read
10
+ id-token: write # Required for trusted publishing
11
+
12
+ jobs:
13
+ test:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.12"
20
+ - run: pip install -e ".[dev]"
21
+ - run: ruff check bubble/ tests/
22
+ - run: pytest -v -m "not integration"
23
+
24
+ publish:
25
+ needs: test
26
+ runs-on: ubuntu-latest
27
+ environment: pypi
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+ - uses: actions/setup-python@v5
31
+ with:
32
+ python-version: "3.12"
33
+ - run: pip install build
34
+ - run: python -m build
35
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Containerized development environments powered by Incus
5
5
  Author-email: Kim Morrison <kim@tqft.net>
6
6
  License-Expression: Apache-2.0
@@ -37,9 +37,11 @@ Containerized development environments for the Lean language, powered by [Incus]
37
37
  ## Quick Start
38
38
 
39
39
  ```bash
40
- # Install (use pipx or uv to avoid PEP 668 issues with system Python)
41
- pipx install dev-bubble # or: uv tool install dev-bubble
42
- # For development: uv pip install -e '.[dev]'
40
+ # Install
41
+ uv tool install git+https://github.com/kim-em/bubble.git
42
+
43
+ # Also available on PyPI: uv tool install dev-bubble
44
+ # For development: uv pip install -e '.[dev]'
43
45
 
44
46
  # Open a bubble for a GitHub PR — just paste the URL, and you get a containerized VSCode window!
45
47
  bubble https://github.com/leanprover-community/mathlib4/pull/35219
@@ -5,9 +5,11 @@ Containerized development environments for the Lean language, powered by [Incus]
5
5
  ## Quick Start
6
6
 
7
7
  ```bash
8
- # Install (use pipx or uv to avoid PEP 668 issues with system Python)
9
- pipx install dev-bubble # or: uv tool install dev-bubble
10
- # For development: uv pip install -e '.[dev]'
8
+ # Install
9
+ uv tool install git+https://github.com/kim-em/bubble.git
10
+
11
+ # Also available on PyPI: uv tool install dev-bubble
12
+ # For development: uv pip install -e '.[dev]'
11
13
 
12
14
  # Open a bubble for a GitHub PR — just paste the URL, and you get a containerized VSCode window!
13
15
  bubble https://github.com/leanprover-community/mathlib4/pull/35219
@@ -12,11 +12,11 @@ from pathlib import Path
12
12
  import click
13
13
 
14
14
  from . import __version__
15
- from .config import DATA_DIR, ensure_dirs, load_config, repo_short_name, save_config
15
+ from .clean import CleanStatus, check_clean, format_reasons
16
+ from .config import ensure_dirs, load_config, repo_short_name, save_config
16
17
  from .git_store import bare_repo_path, ensure_repo, fetch_ref, github_url, update_all_repos
17
18
  from .hooks import select_hook
18
19
  from .images.builder import VSCODE_COMMIT_FILE, get_vscode_commit
19
- from .clean import CleanStatus, check_clean, format_reasons
20
20
  from .lifecycle import load_registry, register_bubble, unregister_bubble
21
21
  from .naming import deduplicate_name, generate_name
22
22
  from .repo_registry import RepoRegistry
@@ -669,8 +669,8 @@ def _provision_container(runtime, name, image_name, ref_path, mount_name, config
669
669
  connect=connect_addr,
670
670
  listen="unix:/bubble/relay.sock",
671
671
  bind="container",
672
- uid="1000",
673
- gid="1000",
672
+ uid="1001",
673
+ gid="1001",
674
674
  )
675
675
  from .relay import generate_relay_token
676
676
 
@@ -681,14 +681,17 @@ def _provision_container(runtime, name, image_name, ref_path, mount_name, config
681
681
  "bash",
682
682
  "-c",
683
683
  f"echo {shlex.quote(token)} > /bubble/relay-token"
684
- " && chown 1000:1000 /bubble/relay-token"
684
+ " && chown user:user /bubble/relay-token"
685
685
  " && chmod 600 /bubble/relay-token",
686
686
  ],
687
687
  )
688
688
 
689
689
 
690
690
  def _get_pr_metadata(owner: str, repo: str, pr_number: str) -> tuple[str, str, str] | None:
691
- """Query GitHub API for PR head branch info. Returns (head_ref, head_repo, clone_url) or None."""
691
+ """Query GitHub API for PR head branch info.
692
+
693
+ Returns (head_ref, head_repo, clone_url) or None.
694
+ """
692
695
  try:
693
696
  result = subprocess.run(
694
697
  [
@@ -967,7 +970,7 @@ def _format_bytes(n: int) -> str:
967
970
  return f"{n:.1f} PB"
968
971
 
969
972
 
970
- def _format_age(dt: "datetime | None") -> str:
973
+ def _format_age(dt: "datetime | None") -> str: # noqa: F821
971
974
  """Format a datetime as a human-readable age string."""
972
975
  if dt is None:
973
976
  return "-"
@@ -1042,16 +1045,23 @@ def list_bubbles(as_json, verbose, show_clean):
1042
1045
 
1043
1046
  if verbose:
1044
1047
  if show_clean:
1045
- click.echo(f"{'NAME':<30} {'STATE':<10} {'CREATED':<12} {'LAST USED':<12} {'DISK':<10} {'IPv4':<16} {'STATUS'}")
1048
+ click.echo(
1049
+ f"{'NAME':<30} {'STATE':<10} {'CREATED':<12} {'LAST USED':<12}"
1050
+ f" {'DISK':<10} {'IPv4':<16} {'STATUS'}"
1051
+ )
1046
1052
  click.echo("-" * 110)
1047
1053
  else:
1048
- click.echo(f"{'NAME':<30} {'STATE':<10} {'CREATED':<12} {'LAST USED':<12} {'DISK':<10} {'IPv4':<16}")
1054
+ click.echo(
1055
+ f"{'NAME':<30} {'STATE':<10} {'CREATED':<12} {'LAST USED':<12}"
1056
+ f" {'DISK':<10} {'IPv4':<16}"
1057
+ )
1049
1058
  click.echo("-" * 90)
1050
1059
  for c in containers:
1051
1060
  disk = _format_bytes(c.disk_usage) if c.disk_usage else "-"
1052
1061
  created = _format_age(c.created_at)
1053
1062
  used = _format_age(c.last_used_at)
1054
- line = f"{c.name:<30} {c.state:<10} {created:<12} {used:<12} {disk:<10} {c.ipv4 or '-':<16}"
1063
+ ipv4 = c.ipv4 or "-"
1064
+ line = f"{c.name:<30} {c.state:<10} {created:<12} {used:<12} {disk:<10} {ipv4:<16}"
1055
1065
  if show_clean:
1056
1066
  cs = clean_statuses.get(c.name)
1057
1067
  line += f" {cs.summary}" if cs else ""
@@ -1140,7 +1150,10 @@ def destroy(name, force):
1140
1150
  @main.command()
1141
1151
  @click.option("-n", "--dry-run", is_flag=True, help="Show what would be destroyed")
1142
1152
  @click.option("-f", "--force", is_flag=True, help="Skip confirmation prompt")
1143
- @click.option("-a", "--all", "check_all", is_flag=True, help="Start stopped/frozen bubbles to check them")
1153
+ @click.option(
1154
+ "-a", "--all", "check_all", is_flag=True,
1155
+ help="Start stopped/frozen bubbles to check them",
1156
+ )
1144
1157
  @click.option("--age", type=int, default=0, help="Only clean up bubbles unused for N+ days")
1145
1158
  def cleanup(dry_run, force, check_all, age):
1146
1159
  """Destroy all clean bubbles (safe, no unsaved work)."""
@@ -1210,7 +1223,8 @@ def cleanup(dry_run, force, check_all, age):
1210
1223
  return
1211
1224
 
1212
1225
  if dry_run:
1213
- click.echo(f"\nWould destroy {len(clean_list)} clean bubble{'s' if len(clean_list) != 1 else ''}.")
1226
+ n = len(clean_list)
1227
+ click.echo(f"\nWould destroy {n} clean bubble{'s' if n != 1 else ''}.")
1214
1228
  # Re-stop clean containers that were started for checking
1215
1229
  for name in clean_list:
1216
1230
  if name in started_names:
@@ -7,8 +7,8 @@ from pathlib import Path
7
7
 
8
8
  import click
9
9
 
10
- from . import Hook
11
10
  from ..runtime.base import ContainerRuntime
11
+ from . import Hook
12
12
 
13
13
  # Matches stable releases (v4.16.0) and release candidates (v4.16.0-rc2)
14
14
  _STABLE_OR_RC_RE = re.compile(r"^v\d+\.\d+\.\d+(-rc\d+)?$")
@@ -73,14 +73,9 @@ def main():
73
73
  s.settimeout(30)
74
74
  s.connect(sock_path)
75
75
  request = json.dumps({"target": target, "token": token})
76
- s.sendall(request.encode() + b"\n")
77
- s.shutdown(socket.SHUT_WR)
78
- data = b""
79
- while True:
80
- chunk = s.recv(4096)
81
- if not chunk:
82
- break
83
- data += chunk
76
+ s.sendall(request.encode())
77
+ # Read response (single recv — response is always < 4KB)
78
+ data = s.recv(4096)
84
79
  response = json.loads(data)
85
80
  print(response.get("message", ""))
86
81
  sys.exit(0 if response.get("status") == "ok" else 1)
@@ -95,7 +95,7 @@ def _build_allowlist_script(domains: list[str]) -> str:
95
95
  resolve_domain = domain[2:]
96
96
  lines.append(f"IPS=$(getent ahostsv4 {resolve_domain} 2>/dev/null"
97
97
  " | awk '{print $1}' | sort -u)")
98
- lines.append(f'if [ -z "$IPS" ]; then')
98
+ lines.append('if [ -z "$IPS" ]; then')
99
99
  lines.append(f' echo "Warning: wildcard domain {domain} did not resolve.'
100
100
  f' Use explicit subdomains instead." >&2')
101
101
  lines.append("else")
@@ -28,7 +28,6 @@ import threading
28
28
  import time
29
29
  from collections import deque
30
30
  from concurrent.futures import ThreadPoolExecutor
31
- from pathlib import Path
32
31
 
33
32
  from .config import DATA_DIR
34
33
  from .git_store import repo_is_known
@@ -196,6 +195,10 @@ def validate_relay_target(target: str) -> tuple[str, str]:
196
195
  if target.startswith((".", "/", "~")):
197
196
  return "error", "Local paths are not allowed via relay."
198
197
 
198
+ # Reject targets starting with '-' to prevent CLI option injection
199
+ if target.startswith("-"):
200
+ return "error", "Invalid target."
201
+
199
202
  if "--path" in target:
200
203
  return "error", "The --path flag is not allowed via relay."
201
204
 
@@ -350,12 +353,20 @@ def _open_bubble(target: str, runtime_factory):
350
353
  import subprocess
351
354
 
352
355
  subprocess.Popen(
353
- ["bubble", "open", "--no-clone", target],
356
+ ["bubble", "open", "--no-clone", "--no-interactive", target],
354
357
  stdout=subprocess.DEVNULL,
355
358
  stderr=subprocess.DEVNULL,
356
359
  )
357
360
 
358
361
 
362
+ def _guarded_handle(semaphore, conn, rate_limiter, token_registry, runtime_factory):
363
+ """Wrapper that releases the handler semaphore after connection handling."""
364
+ try:
365
+ _handle_connection(conn, rate_limiter, token_registry, runtime_factory)
366
+ finally:
367
+ semaphore.release()
368
+
369
+
359
370
  def run_daemon(runtime_factory=None):
360
371
  """Run the relay daemon.
361
372
 
@@ -390,6 +401,9 @@ def run_daemon(runtime_factory=None):
390
401
  rate_limiter = RateLimiter()
391
402
  token_registry = TokenRegistry()
392
403
  executor = ThreadPoolExecutor(max_workers=MAX_CONCURRENT_HANDLERS)
404
+ # Pre-auth connection cap: reject new connections when all handler slots
405
+ # are busy. Prevents unauthenticated DoS from blocking legitimate requests.
406
+ handler_semaphore = threading.Semaphore(MAX_CONCURRENT_HANDLERS)
393
407
 
394
408
  logger.info("Relay daemon started on %s", listen_addr)
395
409
  print(f"Relay daemon listening on {listen_addr}")
@@ -397,7 +411,17 @@ def run_daemon(runtime_factory=None):
397
411
  try:
398
412
  while True:
399
413
  conn, _ = server.accept()
400
- executor.submit(_handle_connection, conn, rate_limiter, token_registry, runtime_factory)
414
+ if not handler_semaphore.acquire(blocking=False):
415
+ # All handler slots busy — drop the connection immediately
416
+ try:
417
+ conn.close()
418
+ except Exception:
419
+ pass
420
+ continue
421
+ executor.submit(
422
+ _guarded_handle, handler_semaphore, conn,
423
+ rate_limiter, token_registry, runtime_factory,
424
+ )
401
425
  except KeyboardInterrupt:
402
426
  logger.info("Relay daemon stopped")
403
427
  finally:
@@ -8,7 +8,7 @@ from .config import REPOS_FILE
8
8
 
9
9
 
10
10
  class RepoRegistry:
11
- """Maps short repo names (e.g. 'mathlib4') to full owner/repo (e.g. 'leanprover-community/mathlib4').
11
+ """Maps short repo names to full owner/repo pairs.
12
12
 
13
13
  Repos are learned on first use and stored in ~/.bubble/repos.json.
14
14
  """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dev-bubble
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Containerized development environments powered by Incus
5
5
  Author-email: Kim Morrison <kim@tqft.net>
6
6
  License-Expression: Apache-2.0
@@ -37,9 +37,11 @@ Containerized development environments for the Lean language, powered by [Incus]
37
37
  ## Quick Start
38
38
 
39
39
  ```bash
40
- # Install (use pipx or uv to avoid PEP 668 issues with system Python)
41
- pipx install dev-bubble # or: uv tool install dev-bubble
42
- # For development: uv pip install -e '.[dev]'
40
+ # Install
41
+ uv tool install git+https://github.com/kim-em/bubble.git
42
+
43
+ # Also available on PyPI: uv tool install dev-bubble
44
+ # For development: uv pip install -e '.[dev]'
43
45
 
44
46
  # Open a bubble for a GitHub PR — just paste the URL, and you get a containerized VSCode window!
45
47
  bubble https://github.com/leanprover-community/mathlib4/pull/35219
@@ -4,6 +4,7 @@ README.md
4
4
  pyproject.toml
5
5
  .claude/CLAUDE.md
6
6
  .github/workflows/ci.yml
7
+ .github/workflows/publish.yml
7
8
  bubble/__init__.py
8
9
  bubble/automation.py
9
10
  bubble/clean.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "dev-bubble"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "Containerized development environments powered by Incus"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -1,15 +1,12 @@
1
1
  """Tests for the target parsing module."""
2
2
 
3
- import os
4
3
  import subprocess
5
4
 
6
5
  import pytest
7
6
 
8
7
  from bubble.repo_registry import RepoRegistry
9
8
  from bubble.target import (
10
- Target,
11
9
  TargetParseError,
12
- _git_repo_info,
13
10
  _parse_github_remote,
14
11
  _parse_local_path,
15
12
  parse_target,
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