cat-stack 1.6.0__tar.gz → 1.6.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.
- {cat_stack-1.6.0 → cat_stack-1.6.1}/PKG-INFO +6 -2
- {cat_stack-1.6.0 → cat_stack-1.6.1}/README.md +5 -1
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/__about__.py +1 -1
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/_providers.py +105 -9
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/pdf_functions.py +63 -4
- {cat_stack-1.6.0 → cat_stack-1.6.1}/.gitignore +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/LICENSE +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/pyproject.toml +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/cat_stack/__init__.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/__init__.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/_batch.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/_category_analysis.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/_chunked.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/_embeddings.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/_formatter.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/_pilot_test.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/_prompts.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/_review_ui.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/_tiebreaker.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/_utils.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/_web_fetch.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/_wrapper_helpers.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/calls/CoVe.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/calls/__init__.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/calls/image_CoVe.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/calls/image_stepback.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/calls/pdf_CoVe.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/calls/pdf_stepback.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/calls/stepback.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/calls/top_n.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/classify.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/explore.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/extract.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/image_functions.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/images/circle.png +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/images/cube.png +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/images/diamond.png +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/images/overlapping_pentagons.png +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/images/rectangles.png +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/model_reference_list.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/prompt_tune.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/summarize.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/text_functions.py +0 -0
- {cat_stack-1.6.0 → cat_stack-1.6.1}/src/catstack/text_functions_ensemble.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cat-stack
|
|
3
|
-
Version: 1.6.
|
|
3
|
+
Version: 1.6.1
|
|
4
4
|
Summary: Domain-agnostic text, image, PDF, and DOCX classification engine powered by LLMs
|
|
5
5
|
Project-URL: Documentation, https://github.com/chrissoria/cat-stack#readme
|
|
6
6
|
Project-URL: Issues, https://github.com/chrissoria/cat-stack/issues
|
|
@@ -177,7 +177,11 @@ All providers use the same `(model_name, provider, api_key)` tuple format. Provi
|
|
|
177
177
|
- **Multi-model ensemble** with consensus voting and agreement scores
|
|
178
178
|
- **Batch API support** for OpenAI, Anthropic, Google, Mistral, and xAI
|
|
179
179
|
- **Prompt strategies**: Chain-of-Thought, Chain-of-Verification, step-back prompting, few-shot examples
|
|
180
|
-
- **Text, image, and PDF** input auto-detection
|
|
180
|
+
- **Text, image, and PDF** input auto-detection (PDF inputs are
|
|
181
|
+
validated against the `%PDF-` magic-byte header before reaching
|
|
182
|
+
PyMuPDF, so a webpage saved with `.pdf` extension surfaces a clear
|
|
183
|
+
`ValueError` instead of silently classifying a blank rendered page
|
|
184
|
+
as `success`)
|
|
181
185
|
- **Embedding similarity** tiebreaker for ensemble consensus ties
|
|
182
186
|
- **Pilot test** — validate classifications on a small sample before committing to the full run
|
|
183
187
|
|
|
@@ -141,7 +141,11 @@ All providers use the same `(model_name, provider, api_key)` tuple format. Provi
|
|
|
141
141
|
- **Multi-model ensemble** with consensus voting and agreement scores
|
|
142
142
|
- **Batch API support** for OpenAI, Anthropic, Google, Mistral, and xAI
|
|
143
143
|
- **Prompt strategies**: Chain-of-Thought, Chain-of-Verification, step-back prompting, few-shot examples
|
|
144
|
-
- **Text, image, and PDF** input auto-detection
|
|
144
|
+
- **Text, image, and PDF** input auto-detection (PDF inputs are
|
|
145
|
+
validated against the `%PDF-` magic-byte header before reaching
|
|
146
|
+
PyMuPDF, so a webpage saved with `.pdf` extension surfaces a clear
|
|
147
|
+
`ValueError` instead of silently classifying a blank rendered page
|
|
148
|
+
as `success`)
|
|
145
149
|
- **Embedding similarity** tiebreaker for ensemble consensus ties
|
|
146
150
|
- **Pilot test** — validate classifications on a small sample before committing to the full run
|
|
147
151
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# SPDX-FileCopyrightText: 2025-present Christopher Soria <chrissoria@berkeley.edu>
|
|
2
2
|
#
|
|
3
3
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
4
|
-
__version__ = "1.6.
|
|
4
|
+
__version__ = "1.6.1"
|
|
5
5
|
__author__ = "Chris Soria"
|
|
6
6
|
__email__ = "chrissoria@berkeley.edu"
|
|
7
7
|
__title__ = "cat-stack"
|
|
@@ -19,6 +19,46 @@ import requests
|
|
|
19
19
|
# short enough that batch ensembles don't stall for half an hour."
|
|
20
20
|
_MAX_TOTAL_WAIT_SECONDS = 300.0
|
|
21
21
|
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# OpenAI reasoning_effort: per-model-family off-equivalent value.
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
#
|
|
27
|
+
# Different OpenAI model generations expose different `reasoning_effort`
|
|
28
|
+
# enum values. The "off" value (what `thinking_budget=0` maps to) is not
|
|
29
|
+
# stable across families:
|
|
30
|
+
#
|
|
31
|
+
# o1 / o3 / o4, gpt-5.0..gpt-5.3 → "minimal" (older floor)
|
|
32
|
+
# gpt-5.4 / gpt-5.5 / gpt-5.6 → "none" (new strict-off; "minimal" deprecated)
|
|
33
|
+
#
|
|
34
|
+
# A model sent the wrong floor returns a 400 `unsupported_value`. The
|
|
35
|
+
# table below is consulted in `_openai_reasoning_effort_floor()` to pick
|
|
36
|
+
# the right value up-front. For unknown future families,
|
|
37
|
+
# `UnifiedLLMClient.complete()` catches the 400 and falls back to "low"
|
|
38
|
+
# (universally accepted across all reasoning_effort-supporting models).
|
|
39
|
+
#
|
|
40
|
+
# Entries are matched longest-prefix-first so "gpt-5.4" matches before
|
|
41
|
+
# "gpt-5" — keep that invariant when extending.
|
|
42
|
+
_OPENAI_REASONING_EFFORT_FLOORS = (
|
|
43
|
+
("gpt-5.4", "none"),
|
|
44
|
+
("gpt-5.5", "none"),
|
|
45
|
+
("gpt-5.6", "none"),
|
|
46
|
+
("gpt-5", "minimal"), # covers 5.0, 5.1, 5.2, 5.3
|
|
47
|
+
("o1", "minimal"),
|
|
48
|
+
("o3", "minimal"),
|
|
49
|
+
("o4", "minimal"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _openai_reasoning_effort_floor(model: str) -> str:
|
|
54
|
+
"""Return the off-equivalent reasoning_effort value for a reasoning-
|
|
55
|
+
capable OpenAI model, based on its name prefix. Defaults to "minimal"
|
|
56
|
+
for models not covered by the table — the safest historical value."""
|
|
57
|
+
for prefix, floor in _OPENAI_REASONING_EFFORT_FLOORS:
|
|
58
|
+
if model.startswith(prefix):
|
|
59
|
+
return floor
|
|
60
|
+
return "minimal"
|
|
61
|
+
|
|
22
62
|
__all__ = [
|
|
23
63
|
# Main client
|
|
24
64
|
"UnifiedLLMClient",
|
|
@@ -350,8 +390,18 @@ class UnifiedLLMClient:
|
|
|
350
390
|
|
|
351
391
|
Args:
|
|
352
392
|
force_json: If False and no json_schema, don't set response_format (for text responses)
|
|
353
|
-
thinking_budget: For OpenAI models, maps to
|
|
354
|
-
|
|
393
|
+
thinking_budget: For OpenAI reasoning-capable models, maps to
|
|
394
|
+
reasoning_effort. `thinking_budget=0` picks the
|
|
395
|
+
provider's off-equivalent value from
|
|
396
|
+
`_OPENAI_REASONING_EFFORT_FLOORS`
|
|
397
|
+
("none" for gpt-5.4+, "minimal" for o-series
|
|
398
|
+
and gpt-5.0-5.3). `thinking_budget>0` maps to
|
|
399
|
+
"high". If the chosen value is rejected at
|
|
400
|
+
runtime with 400 `unsupported_value`,
|
|
401
|
+
`complete()` retries with "low" (universally
|
|
402
|
+
accepted) and caches the override on the
|
|
403
|
+
client so subsequent calls skip the bad
|
|
404
|
+
value.
|
|
355
405
|
"""
|
|
356
406
|
payload = {
|
|
357
407
|
"model": self.model,
|
|
@@ -388,7 +438,14 @@ class UnifiedLLMClient:
|
|
|
388
438
|
if thinking_budget > 0:
|
|
389
439
|
payload["reasoning_effort"] = "high"
|
|
390
440
|
else:
|
|
391
|
-
|
|
441
|
+
# Off-equivalent value depends on the model family —
|
|
442
|
+
# see `_OPENAI_REASONING_EFFORT_FLOORS`. A previously-
|
|
443
|
+
# discovered fallback (from a 400 retry in complete())
|
|
444
|
+
# wins if cached on the client.
|
|
445
|
+
payload["reasoning_effort"] = (
|
|
446
|
+
getattr(self, "_reasoning_effort_override", None)
|
|
447
|
+
or _openai_reasoning_effort_floor(self.model)
|
|
448
|
+
)
|
|
392
449
|
elif creativity is not None:
|
|
393
450
|
payload["temperature"] = creativity
|
|
394
451
|
|
|
@@ -637,7 +694,13 @@ class UnifiedLLMClient:
|
|
|
637
694
|
creativity: Temperature setting (None for default)
|
|
638
695
|
thinking_budget: Controls reasoning behavior per provider:
|
|
639
696
|
- Google: Token budget for extended thinking (0 to disable, >0 to enable)
|
|
640
|
-
- OpenAI: Maps to reasoning_effort
|
|
697
|
+
- OpenAI: Maps to reasoning_effort. `thinking_budget=0`
|
|
698
|
+
picks the model's off-equivalent value from
|
|
699
|
+
`_OPENAI_REASONING_EFFORT_FLOORS` ("none" for gpt-5.4+,
|
|
700
|
+
"minimal" for older o-series / gpt-5.0-5.3). If the
|
|
701
|
+
picked value is rejected at runtime, the client falls
|
|
702
|
+
back to "low" (universally accepted) and caches the
|
|
703
|
+
override. `thinking_budget>0` maps to "high".
|
|
641
704
|
- Anthropic: Enables extended thinking (0 to disable, >0 to enable with min 1024)
|
|
642
705
|
force_json: If True and no json_schema, still request JSON output.
|
|
643
706
|
Set to False for text-only responses (e.g., CoVe intermediate steps)
|
|
@@ -693,15 +756,23 @@ class UnifiedLLMClient:
|
|
|
693
756
|
payload.pop("response_format")
|
|
694
757
|
continue # Retry immediately without response_format
|
|
695
758
|
|
|
696
|
-
# HF: some routers
|
|
697
|
-
#
|
|
698
|
-
#
|
|
699
|
-
# "
|
|
759
|
+
# HF: some routers reject `chat_template_kwargs` outright.
|
|
760
|
+
# The wording varies per router:
|
|
761
|
+
# Groq: "property 'chat_template_kwargs' is unsupported"
|
|
762
|
+
# Fireworks: "Extra inputs are not permitted, field:
|
|
763
|
+
# 'chat_template_kwargs'"
|
|
700
764
|
# The kwarg is only there to disable thinking on Qwen3-
|
|
701
765
|
# family models when thinking_budget=0 — dropping it on
|
|
702
766
|
# a router that doesn't honor it is harmless. Strip and
|
|
703
767
|
# retry, mirror the response_format pattern above.
|
|
704
|
-
|
|
768
|
+
_ctk_rejected = (
|
|
769
|
+
"chat_template_kwargs" in error_text
|
|
770
|
+
and any(phrase in error_text for phrase in (
|
|
771
|
+
"unsupported", "not permitted", "not allowed",
|
|
772
|
+
"extra inputs", "extra fields", "unknown field",
|
|
773
|
+
))
|
|
774
|
+
)
|
|
775
|
+
if _ctk_rejected:
|
|
705
776
|
if "chat_template_kwargs" in payload:
|
|
706
777
|
if not getattr(self, '_warned_no_chat_template_kwargs', False):
|
|
707
778
|
print(f"\n[CatLLM] Model '{self.model}' does not accept chat_template_kwargs.")
|
|
@@ -710,6 +781,31 @@ class UnifiedLLMClient:
|
|
|
710
781
|
payload.pop("chat_template_kwargs")
|
|
711
782
|
continue # Retry immediately without chat_template_kwargs
|
|
712
783
|
|
|
784
|
+
# OpenAI reasoning_effort enum varies across model
|
|
785
|
+
# families — gpt-5.4+ deprecated "minimal" in favor of
|
|
786
|
+
# "none"; older models reject "none". If the model
|
|
787
|
+
# rejects our chosen value with 400 unsupported_value,
|
|
788
|
+
# fall back to "low" (universally accepted across all
|
|
789
|
+
# OpenAI reasoning-effort-supporting models) and cache
|
|
790
|
+
# the override so subsequent calls skip the doomed
|
|
791
|
+
# value. If "low" itself is rejected, drop reasoning_effort
|
|
792
|
+
# entirely.
|
|
793
|
+
if "reasoning_effort" in error_text and (
|
|
794
|
+
"unsupported" in error_text or "invalid" in error_text
|
|
795
|
+
):
|
|
796
|
+
current = payload.get("reasoning_effort")
|
|
797
|
+
if current not in (None, "low"):
|
|
798
|
+
if not getattr(self, '_warned_reasoning_effort_fallback', False):
|
|
799
|
+
print(f"\n[CatLLM] Model '{self.model}' rejected reasoning_effort='{current}'.")
|
|
800
|
+
print(f" Falling back to 'low' and caching for subsequent calls on this client.\n")
|
|
801
|
+
self._warned_reasoning_effort_fallback = True
|
|
802
|
+
self._reasoning_effort_override = "low"
|
|
803
|
+
payload["reasoning_effort"] = "low"
|
|
804
|
+
continue
|
|
805
|
+
elif current == "low" and "reasoning_effort" in payload:
|
|
806
|
+
payload.pop("reasoning_effort")
|
|
807
|
+
continue
|
|
808
|
+
|
|
713
809
|
# HuggingFace: try other routers when the current one
|
|
714
810
|
# rejects the model with a "wrong router" 400.
|
|
715
811
|
if self._is_hf_wrong_router_400(response.text):
|
|
@@ -39,16 +39,63 @@ def _anthropic_supports_pdf(model_name):
|
|
|
39
39
|
return False
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
def _is_likely_pdf(path) -> bool:
|
|
43
|
+
"""True if the first 1024 bytes of `path` contain the PDF magic bytes
|
|
44
|
+
(`%PDF-`). PyMuPDF is permissive and will happily "open" an HTML
|
|
45
|
+
file saved with .pdf extension, render a junk page, and let the
|
|
46
|
+
downstream VLM produce a "successful" classification dict from
|
|
47
|
+
blank content. The cheap fix is to refuse files that don't have
|
|
48
|
+
the canonical PDF header before they reach PyMuPDF.
|
|
49
|
+
|
|
50
|
+
Scanning 1024 bytes (rather than checking offset 0 strictly) matches
|
|
51
|
+
what most PDF parsers do — the spec technically allows leading bytes
|
|
52
|
+
before the header (e.g. MIME-wrapped PDFs).
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
with open(path, "rb") as f:
|
|
56
|
+
head = f.read(1024)
|
|
57
|
+
return b"%PDF-" in head
|
|
58
|
+
except OSError:
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
42
62
|
def _load_pdf_files(pdf_input):
|
|
43
|
-
"""Load PDF files from directory path, single file path, or return list as-is.
|
|
63
|
+
"""Load PDF files from directory path, single file path, or return list as-is.
|
|
64
|
+
|
|
65
|
+
Files are validated against the PDF magic-byte header before being
|
|
66
|
+
returned — a single bogus path raises `ValueError`; bogus files
|
|
67
|
+
found during a directory glob are skipped with a warning. This
|
|
68
|
+
prevents PyMuPDF from silently rendering non-PDF content into
|
|
69
|
+
near-blank pages that then get classified as "success" downstream.
|
|
70
|
+
"""
|
|
44
71
|
import os
|
|
45
72
|
import glob
|
|
46
73
|
|
|
47
74
|
if isinstance(pdf_input, list):
|
|
48
|
-
pdf_files =
|
|
49
|
-
|
|
75
|
+
pdf_files = []
|
|
76
|
+
for path in pdf_input:
|
|
77
|
+
if not os.path.isfile(path):
|
|
78
|
+
raise FileNotFoundError(f"PDF input not found: {path}")
|
|
79
|
+
if not _is_likely_pdf(path):
|
|
80
|
+
raise ValueError(
|
|
81
|
+
f"File '{path}' does not have a PDF header "
|
|
82
|
+
f"(first 1024 bytes don't contain b'%PDF-'). "
|
|
83
|
+
f"PyMuPDF would happily render it as a junk page "
|
|
84
|
+
f"and the VLM would classify the result as 'success' — "
|
|
85
|
+
f"refusing instead. Check the file is a real PDF."
|
|
86
|
+
)
|
|
87
|
+
pdf_files.append(path)
|
|
88
|
+
print(f"Provided a list of {len(pdf_files)} PDFs.")
|
|
50
89
|
elif os.path.isfile(pdf_input):
|
|
51
90
|
# Single file path
|
|
91
|
+
if not _is_likely_pdf(pdf_input):
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"File '{pdf_input}' does not have a PDF header "
|
|
94
|
+
f"(first 1024 bytes don't contain b'%PDF-'). "
|
|
95
|
+
f"PyMuPDF would happily render it as a junk page "
|
|
96
|
+
f"and the VLM would classify the result as 'success' — "
|
|
97
|
+
f"refusing instead. Check the file is a real PDF."
|
|
98
|
+
)
|
|
52
99
|
pdf_files = [pdf_input]
|
|
53
100
|
print(f"Provided 1 PDF file.")
|
|
54
101
|
elif os.path.isdir(pdf_input):
|
|
@@ -62,7 +109,19 @@ def _load_pdf_files(pdf_input):
|
|
|
62
109
|
if f.lower() not in seen:
|
|
63
110
|
seen.add(f.lower())
|
|
64
111
|
unique_files.append(f)
|
|
65
|
-
|
|
112
|
+
# Filter out files that don't have the PDF header (e.g. a webpage
|
|
113
|
+
# saved with .pdf extension). Warn per skipped file so the user
|
|
114
|
+
# knows what didn't make it into the run.
|
|
115
|
+
validated = []
|
|
116
|
+
for f in unique_files:
|
|
117
|
+
if _is_likely_pdf(f):
|
|
118
|
+
validated.append(f)
|
|
119
|
+
else:
|
|
120
|
+
print(
|
|
121
|
+
f"[CatLLM] Warning: skipping '{f}' — does not have a "
|
|
122
|
+
f"PDF header (first 1024 bytes don't contain b'%PDF-')."
|
|
123
|
+
)
|
|
124
|
+
pdf_files = validated
|
|
66
125
|
print(f"Found {len(pdf_files)} PDFs in directory.")
|
|
67
126
|
else:
|
|
68
127
|
raise FileNotFoundError(f"PDF input not found: {pdf_input}")
|
|
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
|
|
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
|