nabla-cli 0.6.2__tar.gz → 0.6.3__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.
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/PKG-INFO +1 -1
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/__init__.py +1 -1
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/cli.py +52 -46
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/recorder.py +41 -27
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/scanner.py +2 -1
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/ui.py +43 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla_cli.egg-info/PKG-INFO +1 -1
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/pyproject.toml +1 -1
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_cli_ux.py +51 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/README.md +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/__main__.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/branding.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/config.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/contract.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/induce.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/profile.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/prompt_recon.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/samplegen.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/schema_resolver.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/schemas/site_profile.schema.json +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla/term.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla_cli.egg-info/SOURCES.txt +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla_cli.egg-info/dependency_links.txt +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla_cli.egg-info/entry_points.txt +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla_cli.egg-info/requires.txt +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/nabla_cli.egg-info/top_level.txt +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/setup.cfg +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_config.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_e2e_profile.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_induce.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_prompt_recon.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_recorder.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_samplegen.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_scanner.py +0 -0
- {nabla_cli-0.6.2 → nabla_cli-0.6.3}/tests/test_schema_resolver.py +0 -0
|
@@ -493,19 +493,9 @@ def _cmd_record(args) -> int:
|
|
|
493
493
|
elif args.proxy_cmd:
|
|
494
494
|
source = RecordSource(kind="proxy", proxy_command=args.proxy_cmd.split())
|
|
495
495
|
else:
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if p.exists()),
|
|
500
|
-
None,
|
|
501
|
-
)
|
|
502
|
-
if conventional is not None:
|
|
503
|
-
rail(_out, f"using {conventional} (found by convention; pass --inputs to override)")
|
|
504
|
-
source = RecordSource(kind="samples", samples_file=str(conventional))
|
|
505
|
-
else:
|
|
506
|
-
source, source_kind, err = _resolve_inputs_interactively(args, site)
|
|
507
|
-
if err is not None:
|
|
508
|
-
return err
|
|
496
|
+
source, source_kind, err = _resolve_inputs_interactively(args, site)
|
|
497
|
+
if err is not None:
|
|
498
|
+
return err
|
|
509
499
|
|
|
510
500
|
from .ui import make_progress, rail_end, rail_ok
|
|
511
501
|
|
|
@@ -554,46 +544,60 @@ def _cmd_record(args) -> int:
|
|
|
554
544
|
return 0
|
|
555
545
|
|
|
556
546
|
|
|
557
|
-
def _inputs_menu(label: str) -> tuple[str, object]:
|
|
558
|
-
"""The interactive inputs menu
|
|
559
|
-
|
|
547
|
+
def _inputs_menu(label: str, existing: Path | None = None) -> tuple[str, object]:
|
|
548
|
+
"""The interactive inputs menu — shown every capture, so reusing a
|
|
549
|
+
previous inputs file is a choice, not an ambush. Returns ("file", path)
|
|
550
|
+
or ("generate", n)."""
|
|
551
|
+
from .ui import prompt_digits, prompt_text, select
|
|
560
552
|
|
|
561
|
-
options = [
|
|
553
|
+
options = []
|
|
554
|
+
if existing is not None:
|
|
555
|
+
try:
|
|
556
|
+
count = sum(1 for line in existing.read_text(encoding="utf-8").splitlines()
|
|
557
|
+
if line.strip())
|
|
558
|
+
except OSError:
|
|
559
|
+
count = "?"
|
|
560
|
+
options.append(("use existing", f"{existing} · {count} inputs"))
|
|
561
|
+
options += [
|
|
562
562
|
("generate 5", "synthetic inputs — quick look, fewest credits"),
|
|
563
563
|
("generate 10", "synthetic inputs — balanced"),
|
|
564
564
|
("generate 20", "synthetic inputs — handoff floor, most credits"),
|
|
565
565
|
("custom count", "type how many synthetic inputs to generate"),
|
|
566
566
|
("my own file", "type a path to your JSONL of inputs"),
|
|
567
567
|
]
|
|
568
|
+
shift = 1 if existing is not None else 0
|
|
568
569
|
choice = select(_out, label, options)
|
|
569
|
-
if choice ==
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
_warn("enter a positive number")
|
|
575
|
-
if choice == 4:
|
|
570
|
+
if existing is not None and choice == 0:
|
|
571
|
+
return "file", str(existing)
|
|
572
|
+
if choice == shift + 3:
|
|
573
|
+
return "generate", prompt_digits(_out, "How many", default=5)
|
|
574
|
+
if choice == shift + 4:
|
|
576
575
|
while True:
|
|
577
576
|
typed = prompt_text(_out, "Path to JSONL")
|
|
578
577
|
if typed and Path(typed).exists():
|
|
579
578
|
return "file", typed
|
|
580
579
|
_warn(f"{typed or '(empty)'} not found — try again, or Ctrl+C to abort")
|
|
581
|
-
return "generate", (5, 10, 20)[choice]
|
|
580
|
+
return "generate", (5, 10, 20)[choice - shift]
|
|
582
581
|
|
|
583
582
|
|
|
584
583
|
def _resolve_inputs_interactively(args, site):
|
|
585
|
-
"""No --inputs given
|
|
586
|
-
|
|
587
|
-
generate the default quietly. Returns
|
|
588
|
-
with exit_code None on success."""
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
584
|
+
"""No --inputs given. Interactively, ask where inputs come from (existing
|
|
585
|
+
file / generate N / custom count / own file); otherwise use an existing
|
|
586
|
+
conventional file or generate the default quietly. Returns
|
|
587
|
+
(source, source_kind, exit_code) with exit_code None on success."""
|
|
588
|
+
conventional = _find_conventional_inputs(site)
|
|
589
|
+
|
|
590
|
+
if args.num_samples is None and _out.is_terminal and sys.stdin.isatty():
|
|
591
|
+
kind, value = _inputs_menu("Inputs", existing=conventional)
|
|
592
592
|
if kind == "file":
|
|
593
593
|
return RecordSource(kind="samples", samples_file=value), None, None
|
|
594
594
|
n = value
|
|
595
|
-
elif
|
|
596
|
-
|
|
595
|
+
elif conventional is not None and args.num_samples is None:
|
|
596
|
+
from .ui import rail
|
|
597
|
+
rail(_out, f"using {conventional} (found by convention; pass --inputs to override)")
|
|
598
|
+
return RecordSource(kind="samples", samples_file=str(conventional)), None, None
|
|
599
|
+
else:
|
|
600
|
+
n = args.num_samples or 5
|
|
597
601
|
|
|
598
602
|
source, err = _generate_samples_source(args, site, Path(f"{site.symbol}.inputs.jsonl"), n)
|
|
599
603
|
if err is not None:
|
|
@@ -637,25 +641,27 @@ def _capture_many(args, supported) -> int:
|
|
|
637
641
|
rail(_out, f"capturing all {len(targets)} supported call sites")
|
|
638
642
|
|
|
639
643
|
# Phase 1 — decide inputs per site, sequentially (menus can't overlap).
|
|
644
|
+
# The menu is shown every time; an existing inputs file is offered as
|
|
645
|
+
# the default option, never silently assumed.
|
|
640
646
|
plans: list[dict] = []
|
|
641
647
|
for site in targets:
|
|
642
648
|
conventional = _find_conventional_inputs(site)
|
|
643
|
-
if
|
|
649
|
+
if args.num_samples is None and interactive:
|
|
650
|
+
kind, value = _inputs_menu(f"Inputs · {site.symbol}", existing=conventional)
|
|
651
|
+
if kind == "file":
|
|
652
|
+
plans.append({"site": site, "kind": "file", "path": value,
|
|
653
|
+
"source_kind": "samples"})
|
|
654
|
+
else:
|
|
655
|
+
plans.append({"site": site, "kind": "generate", "n": value,
|
|
656
|
+
"source_kind": "generated_samples"})
|
|
657
|
+
continue
|
|
658
|
+
if conventional is not None and args.num_samples is None:
|
|
644
659
|
rail(_out, f"{site.symbol}: using {conventional}")
|
|
645
660
|
plans.append({"site": site, "kind": "file", "path": str(conventional),
|
|
646
661
|
"source_kind": "samples"})
|
|
647
662
|
continue
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
"n": args.num_samples or 5, "source_kind": "generated_samples"})
|
|
651
|
-
continue
|
|
652
|
-
kind, value = _inputs_menu(f"Inputs · {site.symbol}")
|
|
653
|
-
if kind == "file":
|
|
654
|
-
plans.append({"site": site, "kind": "file", "path": value,
|
|
655
|
-
"source_kind": "samples"})
|
|
656
|
-
else:
|
|
657
|
-
plans.append({"site": site, "kind": "generate", "n": value,
|
|
658
|
-
"source_kind": "generated_samples"})
|
|
663
|
+
plans.append({"site": site, "kind": "generate",
|
|
664
|
+
"n": args.num_samples or 5, "source_kind": "generated_samples"})
|
|
659
665
|
|
|
660
666
|
# Phase 2 — run all captures in parallel.
|
|
661
667
|
progress = make_progress(_out) if _out.is_terminal else None
|
|
@@ -131,42 +131,56 @@ def _forward_to_openai_responder(body: dict, headers: dict) -> dict:
|
|
|
131
131
|
model_override = os.environ.get("NABLA_UPSTREAM_MODEL")
|
|
132
132
|
if model_override:
|
|
133
133
|
body = {**body, "model": model_override}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
# Some providers' CDNs (e.g. Cloudflare in front of Groq) reject
|
|
142
|
-
# urllib's default Python-urllib User-Agent with a 403.
|
|
143
|
-
"User-Agent": "nabla-recorder/0.1",
|
|
144
|
-
},
|
|
145
|
-
)
|
|
134
|
+
request_headers = {
|
|
135
|
+
"Content-Type": "application/json",
|
|
136
|
+
"Authorization": auth,
|
|
137
|
+
# Some providers' CDNs (e.g. Cloudflare in front of Groq) reject
|
|
138
|
+
# urllib's default Python-urllib User-Agent with a 403.
|
|
139
|
+
"User-Agent": "nabla-recorder/0.1",
|
|
140
|
+
}
|
|
146
141
|
# Rate limits (429) and transient upstream errors are expected during a
|
|
147
142
|
# long record run, especially on free-tier stand-in oracles: back off and
|
|
148
|
-
# retry, honoring Retry-After when the upstream sends one.
|
|
143
|
+
# retry, honoring Retry-After when the upstream sends one. A stand-in
|
|
144
|
+
# that rejects the site's json_schema outright (some providers support a
|
|
145
|
+
# narrower schema dialect, e.g. no type arrays) gets one retry in
|
|
146
|
+
# json_object mode — the production schema in the profile is unaffected.
|
|
149
147
|
delays = [5, 10, 20, 30, 45]
|
|
150
|
-
|
|
148
|
+
attempt = 0
|
|
149
|
+
fell_back = False
|
|
150
|
+
while True:
|
|
151
|
+
req = urllib.request.Request(
|
|
152
|
+
f"{base_url}/chat/completions",
|
|
153
|
+
data=json.dumps(body).encode("utf-8"),
|
|
154
|
+
method="POST",
|
|
155
|
+
headers=request_headers,
|
|
156
|
+
)
|
|
151
157
|
try:
|
|
152
158
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
|
153
159
|
return json.loads(resp.read().decode("utf-8"))
|
|
154
160
|
except urllib.error.HTTPError as exc:
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
161
|
+
try:
|
|
162
|
+
detail = exc.read().decode("utf-8", "replace")[:500]
|
|
163
|
+
except Exception: # noqa: BLE001 - body read is best-effort
|
|
164
|
+
detail = ""
|
|
165
|
+
rf = body.get("response_format")
|
|
166
|
+
if (exc.code == 400 and model_override and not fell_back
|
|
167
|
+
and isinstance(rf, dict) and rf.get("type") == "json_schema"
|
|
168
|
+
and ("schema" in detail.lower() or "invalid" in detail.lower())):
|
|
169
|
+
body = {**body, "response_format": {"type": "json_object"}}
|
|
170
|
+
fell_back = True
|
|
171
|
+
print("nabla capture: stand-in oracle rejected this site's json_schema — "
|
|
172
|
+
"retrying in json_object mode (the profile still carries the real schema)",
|
|
173
|
+
file=sys.stderr)
|
|
174
|
+
continue
|
|
175
|
+
if attempt >= len(delays) or exc.code not in (429, 500, 502, 503):
|
|
162
176
|
raise RuntimeError(f"upstream HTTP {exc.code}: {detail or exc.reason}") from exc
|
|
163
177
|
retry_after = exc.headers.get("Retry-After") if exc.headers else None
|
|
164
178
|
try:
|
|
165
|
-
wait = max(float(retry_after),
|
|
179
|
+
wait = max(float(retry_after), delays[attempt]) if retry_after else delays[attempt]
|
|
166
180
|
except ValueError:
|
|
167
|
-
wait =
|
|
181
|
+
wait = delays[attempt]
|
|
168
182
|
time.sleep(min(wait, 60))
|
|
169
|
-
|
|
183
|
+
attempt += 1
|
|
170
184
|
|
|
171
185
|
|
|
172
186
|
def _default_responder() -> Responder:
|
|
@@ -415,10 +429,10 @@ def _run_site_in_subprocess(site: RawSite, repo_path: str, input_slots: dict, en
|
|
|
415
429
|
timeout = int(os.environ.get("NABLA_SAMPLE_TIMEOUT", "300"))
|
|
416
430
|
proc = subprocess.run(args, cwd=repo_path, env=env, capture_output=True, text=True, timeout=timeout)
|
|
417
431
|
if proc.returncode != 0:
|
|
418
|
-
# The last stderr
|
|
419
|
-
# traceback scaffolding that buries
|
|
432
|
+
# The last stderr line carries the actual exception; everything above
|
|
433
|
+
# it is traceback scaffolding that buries the signal.
|
|
420
434
|
lines = [l for l in (proc.stderr or proc.stdout or "").splitlines() if l.strip()]
|
|
421
|
-
detail =
|
|
435
|
+
detail = lines[-1].strip() if lines else "no output"
|
|
422
436
|
raise RuntimeError(
|
|
423
437
|
f"Sample invocation of {site.symbol!r} in {site.file!r} failed "
|
|
424
438
|
f"(exit {proc.returncode}): {detail}"
|
|
@@ -40,7 +40,8 @@ from .contract import (
|
|
|
40
40
|
RawSiteRef,
|
|
41
41
|
)
|
|
42
42
|
|
|
43
|
-
_SKIP_DIR_NAMES = {".git", "node_modules", "__pycache__", ".venv", "venv"
|
|
43
|
+
_SKIP_DIR_NAMES = {".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
44
|
+
"build", "dist", ".eggs", ".tox", ".mypy_cache"}
|
|
44
45
|
|
|
45
46
|
_VISION_MARKERS = {"image_url", "input_image"}
|
|
46
47
|
|
|
@@ -267,3 +267,46 @@ def make_progress(console: Console, unit: str = "samples") -> Progress:
|
|
|
267
267
|
console=console,
|
|
268
268
|
transient=True,
|
|
269
269
|
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def prompt_digits(console: Console, label: str, default: int) -> int:
|
|
273
|
+
"""Numeric input that cannot go wrong: on an interactive Windows console
|
|
274
|
+
it reads keys raw — digits echo, backspace works, everything else
|
|
275
|
+
(arrows, brackets, console line-editing artifacts) is ignored. Elsewhere
|
|
276
|
+
it strips non-digits from a plain input()."""
|
|
277
|
+
prompt = Text(f"{RAIL} ", style=_RAIL_STYLE)
|
|
278
|
+
prompt.append(label, style="bold")
|
|
279
|
+
prompt.append(f" [{default}]", style="cyan")
|
|
280
|
+
prompt.append(" ")
|
|
281
|
+
|
|
282
|
+
interactive = (
|
|
283
|
+
sys.platform == "win32"
|
|
284
|
+
and console.is_terminal
|
|
285
|
+
and sys.stdin.isatty()
|
|
286
|
+
)
|
|
287
|
+
if not interactive:
|
|
288
|
+
console.print(prompt, end="")
|
|
289
|
+
raw = "".join(ch for ch in input() if ch.isdigit())
|
|
290
|
+
return int(raw) if raw else default
|
|
291
|
+
|
|
292
|
+
import msvcrt
|
|
293
|
+
|
|
294
|
+
console.print(prompt, end="")
|
|
295
|
+
chars: list[str] = []
|
|
296
|
+
while True:
|
|
297
|
+
ch = msvcrt.getwch()
|
|
298
|
+
if ch in ("\r", "\n"):
|
|
299
|
+
print()
|
|
300
|
+
break
|
|
301
|
+
if ch == "\x03":
|
|
302
|
+
raise KeyboardInterrupt
|
|
303
|
+
if ch == "\b":
|
|
304
|
+
if chars:
|
|
305
|
+
chars.pop()
|
|
306
|
+
print("\b \b", end="", flush=True)
|
|
307
|
+
elif ch.isdigit():
|
|
308
|
+
chars.append(ch)
|
|
309
|
+
print(ch, end="", flush=True)
|
|
310
|
+
elif ch in ("\xe0", "\x00"):
|
|
311
|
+
msvcrt.getwch() # swallow the arrow/function-key payload
|
|
312
|
+
return int("".join(chars)) if chars else default
|
|
@@ -466,3 +466,54 @@ def test_ctrl_c_is_one_quiet_line(monkeypatch, capsys):
|
|
|
466
466
|
captured = capsys.readouterr()
|
|
467
467
|
assert "cancelled" in captured.err
|
|
468
468
|
assert "Traceback" not in captured.err + captured.out
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ---------------------------------------------------------------------------
|
|
472
|
+
# 0.6.3 fixes: digit prompt, always-shown menu, scan skips build dirs
|
|
473
|
+
# ---------------------------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
def test_prompt_digits_noninteractive_strips_garbage(monkeypatch):
|
|
476
|
+
from rich.console import Console
|
|
477
|
+
|
|
478
|
+
from nabla.ui import prompt_digits
|
|
479
|
+
|
|
480
|
+
console = Console(width=100)
|
|
481
|
+
monkeypatch.setattr("builtins.input", lambda *a: "[1]")
|
|
482
|
+
assert prompt_digits(console, "How many", default=5) == 1
|
|
483
|
+
|
|
484
|
+
monkeypatch.setattr("builtins.input", lambda *a: "")
|
|
485
|
+
assert prompt_digits(console, "How many", default=5) == 5
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def test_inputs_menu_offers_existing_file(tmp_path, monkeypatch, capsys):
|
|
489
|
+
from nabla.cli import _inputs_menu
|
|
490
|
+
|
|
491
|
+
existing = tmp_path / "site.inputs.jsonl"
|
|
492
|
+
existing.write_text('{"input_slots": {"q": "x"}}\n' * 3, encoding="utf-8")
|
|
493
|
+
picked = {}
|
|
494
|
+
|
|
495
|
+
def fake_select(console, label, options):
|
|
496
|
+
picked["options"] = options
|
|
497
|
+
return 0 # choose "use existing"
|
|
498
|
+
|
|
499
|
+
monkeypatch.setattr("nabla.ui.select", fake_select)
|
|
500
|
+
kind, value = _inputs_menu("Inputs", existing=existing)
|
|
501
|
+
|
|
502
|
+
assert kind == "file"
|
|
503
|
+
assert value == str(existing)
|
|
504
|
+
assert picked["options"][0][0] == "use existing"
|
|
505
|
+
assert "3 inputs" in picked["options"][0][1]
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def test_scan_skips_build_and_dist_dirs(tmp_path, monkeypatch, capsys):
|
|
509
|
+
repo = _make_single_site_repo(tmp_path)
|
|
510
|
+
build_dir = repo / "build" / "lib"
|
|
511
|
+
build_dir.mkdir(parents=True)
|
|
512
|
+
(build_dir / "copy_of_site.py").write_text(_SINGLE_SITE_SRC, encoding="utf-8")
|
|
513
|
+
|
|
514
|
+
rc = main(["scan", str(repo)])
|
|
515
|
+
|
|
516
|
+
assert rc == 0
|
|
517
|
+
out = capsys.readouterr().out
|
|
518
|
+
assert "1 call site found" in out
|
|
519
|
+
assert "copy_of_site" not in out
|
|
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
|