From 76dbc8e3180d5fb1b3b27b04aae9b4b095a423c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 21:56:24 +0000 Subject: [PATCH 1/3] Initial plan From 114151ad20fd81e0c8ee1b6ee2ac5d80323f3694 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 22:34:56 +0000 Subject: [PATCH 2/3] Add tests to increase coverage: json, file, core/utils, exceptions, classes, formatting, tests modules Co-authored-by: gb119 <4428426+gb119@users.noreply.github.com> Agent-Logs-Url: https://github.com/stonerlab/Stoner-PythonCode/sessions/75f784fd-7c8b-4c8d-8e27-ca55f951f845 --- tests/Stoner/core/test_exceptions.py | 42 +++++++++- tests/Stoner/core/test_utils.py | 59 ++++++++++++++ tests/Stoner/tools/test_classes.py | 69 +++++++++++++++- tests/Stoner/tools/test_file.py | 65 ++++++++++++++++ tests/Stoner/tools/test_formatting.py | 54 +++++++++++++ tests/Stoner/tools/test_json.py | 108 ++++++++++++++++++++++++++ tests/Stoner/tools/test_tests.py | 25 ++++++ 7 files changed, 420 insertions(+), 2 deletions(-) create mode 100644 tests/Stoner/core/test_utils.py create mode 100644 tests/Stoner/tools/test_file.py create mode 100644 tests/Stoner/tools/test_json.py diff --git a/tests/Stoner/core/test_exceptions.py b/tests/Stoner/core/test_exceptions.py index ba5e760df..3b8583bb7 100755 --- a/tests/Stoner/core/test_exceptions.py +++ b/tests/Stoner/core/test_exceptions.py @@ -2,7 +2,13 @@ """Test Stoner.core.exceptions module""" import pytest -from Stoner.core.exceptions import assertion +from Stoner.core.exceptions import ( + StonerAssertionError, + StonerLoadError, + StonerSetasError, + StonerUnrecognisedFormat, + assertion, +) def test_assertion(): @@ -10,5 +16,39 @@ def test_assertion(): assertion(False, "Triggered an assertion") +def test_assertion_true_does_not_raise(): + # Calling assertion with a truthy condition should not raise + assertion(True, "Should not raise") + + +def test_StonerLoadError_is_exception(): + err = StonerLoadError("test load error") + assert isinstance(err, Exception), "StonerLoadError should be an Exception" + with pytest.raises(StonerLoadError): + raise StonerLoadError("could not load file") + + +def test_StonerUnrecognisedFormat_is_IOError(): + err = StonerUnrecognisedFormat("unknown format") + assert isinstance(err, IOError), "StonerUnrecognisedFormat should be an IOError" + with pytest.raises(StonerUnrecognisedFormat): + raise StonerUnrecognisedFormat("no loader found") + + +def test_StonerSetasError_is_AttributeError(): + err = StonerSetasError("setas not set") + assert isinstance(err, AttributeError), "StonerSetasError should be an AttributeError" + with pytest.raises(StonerSetasError): + raise StonerSetasError("column not accessible") + + +def test_StonerAssertionError_is_RuntimeError(): + err = StonerAssertionError("assertion failed") + assert isinstance(err, RuntimeError), "StonerAssertionError should be a RuntimeError" + with pytest.raises(StonerAssertionError): + assertion(False) + + if __name__ == "__main__": pytest.main(["--pdb", __file__]) + diff --git a/tests/Stoner/core/test_utils.py b/tests/Stoner/core/test_utils.py new file mode 100644 index 000000000..519d6eb8f --- /dev/null +++ b/tests/Stoner/core/test_utils.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +"""Tests for Stoner.core.utils""" + +import csv + +import pytest + +from Stoner.core.utils import Tab_Delimited, decode_string + + +def test_decode_string_simple(): + assert decode_string("xxyy") == "xxyy", "decode_string with no patterns should return unchanged" + + +def test_decode_string_repeated_x(): + assert decode_string("3x") == "xxx", "decode_string failed to expand 3x" + + +def test_decode_string_repeated_y(): + assert decode_string("2y") == "yy", "decode_string failed to expand 2y" + + +def test_decode_string_mixed(): + result = decode_string("x2yz") + assert result == "xyyz", "decode_string failed for mixed pattern x2yz" + + +def test_decode_string_dots_and_dashes(): + assert decode_string("3.") == "...", "decode_string failed to expand 3." + assert decode_string("2-") == "--", "decode_string failed to expand 2-" + + +def test_decode_string_multiple_patterns(): + result = decode_string("2x3y") + assert result == "xxyyy", "decode_string failed for multiple patterns 2x3y" + + +def test_Tab_Delimited_is_csv_dialect(): + assert issubclass(Tab_Delimited, csv.Dialect), "Tab_Delimited should subclass csv.Dialect" + assert Tab_Delimited.delimiter == "\t", "Tab_Delimited delimiter should be a tab" + assert Tab_Delimited.quoting == csv.QUOTE_NONE, "Tab_Delimited quoting should be QUOTE_NONE" + assert Tab_Delimited.doublequote is False, "Tab_Delimited doublequote should be False" + assert Tab_Delimited.lineterminator == "\r\n", "Tab_Delimited lineterminator should be CRLF" + + +def test_Tab_Delimited_roundtrip(): + import io + + output = io.StringIO() + writer = csv.writer(output, dialect=Tab_Delimited) + writer.writerow(["a", "b", "c"]) + output.seek(0) + reader = csv.reader(output, dialect=Tab_Delimited) + row = next(reader) + assert row == ["a", "b", "c"], "Tab_Delimited roundtrip failed" + + +if __name__ == "__main__": + pytest.main(["--pdb", __file__]) diff --git a/tests/Stoner/tools/test_classes.py b/tests/Stoner/tools/test_classes.py index 6c79a9625..be588879b 100755 --- a/tests/Stoner/tools/test_classes.py +++ b/tests/Stoner/tools/test_classes.py @@ -4,7 +4,7 @@ import pytest -from Stoner import Options +from Stoner import Data, Options from Stoner.tools import classes @@ -146,5 +146,72 @@ def test_Options(): assert repr(Options) == opt_repr, "Representation of Options failed" +def test_AttributeStore(): + store = classes.AttributeStore({"x": 1, "y": 2}) + assert store.x == 1, "AttributeStore getattr failed" + assert store["y"] == 2, "AttributeStore getitem failed" + store.z = 3 + assert store["z"] == 3, "AttributeStore setattr failed" + try: + _ = store.missing + except AttributeError: + pass + else: + assert False, "AttributeStore getattr for missing key didn't raise AttributeError" + + +def test_AttributeStore_init_no_dict(): + store = classes.AttributeStore() + store["key"] = "value" + assert store.key == "value", "AttributeStore set via dict key and get via attr failed" + + +def test_TypedList_missing_methods(): + tl = classes.TypedList(int, (1, 2, 3)) + # append + tl.append(4) + assert tl == [1, 2, 3, 4], "TypedList append failed" + try: + tl.append("bad") + except TypeError: + pass + else: + assert False, "TypedList append with bad type didn't raise TypeError" + # __len__ + assert len(tl) == 4, "TypedList __len__ failed" + # __iter__ + items = list(tl) + assert items == [1, 2, 3, 4], "TypedList __iter__ failed" + # count + tl.append(1) + assert tl.count(1) == 2, "TypedList count failed" + # remove + tl.remove(1) + assert tl.count(1) == 1, "TypedList remove failed" + # pop + val = tl.pop() + assert val == 1, "TypedList pop failed" + # reverse + tl2 = classes.TypedList(int, (1, 2, 3)) + tl2.reverse() + assert tl2 == [3, 2, 1], "TypedList reverse failed" + # clear + tl2.clear() + assert len(tl2) == 0, "TypedList clear failed" + + +def test_copy_into(): + import numpy as np + + src = Data(np.column_stack((np.arange(5), np.arange(5) * 2.0)), column_headers=["x", "y"]) + src.setas = "xy" + dest = Data() + result = classes.copy_into(src, dest) + assert result is dest, "copy_into should return the destination object" + assert list(dest.column_headers) == ["x", "y"], "copy_into failed to copy column headers" + assert len(dest) == 5, "copy_into failed to copy data rows" + + if __name__ == "__main__": pytest.main(["--pdb", __file__]) + diff --git a/tests/Stoner/tools/test_file.py b/tests/Stoner/tools/test_file.py new file mode 100644 index 000000000..c73cb281a --- /dev/null +++ b/tests/Stoner/tools/test_file.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""Tests for Stoner.tools.file (test_is_zip function).""" + +import os +import tempfile +import zipfile + +import pytest + +from Stoner.tools.file import test_is_zip as is_zip_file + + +def test_is_zip_with_empty_string(): + assert is_zip_file("") is False, "test_is_zip should return False for empty string" + + +def test_is_zip_with_none(): + assert is_zip_file(None) is False, "test_is_zip should return False for None" + + +def test_is_zip_with_bytes_containing_null(): + assert is_zip_file(b"data\x00more") is False, "test_is_zip should return False for bytes with null" + + +def test_is_zip_with_real_zip(): + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp: + tmp_name = tmp.name + try: + with zipfile.ZipFile(tmp_name, "w") as zf: + zf.writestr("hello.txt", "Hello, world!") + result = is_zip_file(tmp_name) + assert result is not False, "test_is_zip should detect a real zip file" + assert result[0] == tmp_name, "test_is_zip should return the zip filename" + assert result[1] == "", "test_is_zip should return empty member for direct zip" + finally: + os.unlink(tmp_name) + + +def test_is_zip_with_non_zip_file(): + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False, mode="w") as tmp: + tmp.write("Not a zip file") + tmp_name = tmp.name + try: + result = is_zip_file(tmp_name) + assert result is False, "test_is_zip should return False for non-zip file" + finally: + os.unlink(tmp_name) + + +def test_is_zip_with_path_inside_zip(): + with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as tmp: + tmp_name = tmp.name + try: + with zipfile.ZipFile(tmp_name, "w") as zf: + zf.writestr("subdir/data.txt", "content") + # Test with a path that includes the zip file + member path + result = is_zip_file(os.path.join(tmp_name, "subdir", "data.txt")) + assert result is not False, "test_is_zip should find zip when path goes through a zip" + assert result[0] == tmp_name, "test_is_zip should find the zip file path" + finally: + os.unlink(tmp_name) + + +if __name__ == "__main__": + pytest.main(["--pdb", __file__]) diff --git a/tests/Stoner/tools/test_formatting.py b/tests/Stoner/tools/test_formatting.py index 1580d5984..52da95c82 100755 --- a/tests/Stoner/tools/test_formatting.py +++ b/tests/Stoner/tools/test_formatting.py @@ -67,8 +67,62 @@ def test_ordinal(): else: assert False, "ordinal didn't raise a ValueError for non integer value" assert formatting.ordinal(11).endswith("th"), "Failed special handling for 11th in ordinal" + assert formatting.ordinal(12).endswith("th"), "Failed special handling for 12th in ordinal" + assert formatting.ordinal(13).endswith("th"), "Failed special handling for 13th in ordinal" assert formatting.ordinal(21).endswith("st"), "Failed to add st to 21st in ordinal" + assert formatting.ordinal(1).endswith("st"), "Failed to add st to 1st in ordinal" + assert formatting.ordinal(2).endswith("nd"), "Failed to add nd to 2nd in ordinal" + assert formatting.ordinal(3).endswith("rd"), "Failed to add rd to 3rd in ordinal" + assert formatting.ordinal(4).endswith("th"), "Failed to add th to 4th in ordinal" + assert formatting.ordinal(0).endswith("th"), "Failed to add th to 0th in ordinal" + + +def test_quantize(): + assert formatting.quantize(1.23, 0.1) == pytest.approx(1.2), "quantize(1.23, 0.1) failed" + assert formatting.quantize(1.26, 0.1) == pytest.approx(1.3), "quantize(1.26, 0.1) failed" + assert formatting.quantize(7, 2) == pytest.approx(8), "quantize(7, 2) failed" + assert formatting.quantize(6, 2) == pytest.approx(6), "quantize(6, 2) failed" + + +def test_tex_escape(): + assert formatting.tex_escape("&") == r"\&", "tex_escape & failed" + assert formatting.tex_escape("%") == r"\%", "tex_escape % failed" + assert formatting.tex_escape("$") == r"\$", "tex_escape $ failed" + assert formatting.tex_escape("#") == r"\#", "tex_escape # failed" + assert formatting.tex_escape("_") == r"\_", "tex_escape _ failed" + assert formatting.tex_escape("{") == r"\{", "tex_escape { failed" + assert formatting.tex_escape("}") == r"\}", "tex_escape } failed" + assert formatting.tex_escape("~") == r"\textasciitilde{}", "tex_escape ~ failed" + assert formatting.tex_escape("^") == r"\^{}", "tex_escape ^ failed" + assert formatting.tex_escape("\\") == r"\textbackslash{}", "tex_escape \\ failed" + assert formatting.tex_escape("<") == r"\textless", "tex_escape < failed" + assert formatting.tex_escape(">") == r"\textgreater", "tex_escape > failed" + assert formatting.tex_escape("hello") == "hello", "tex_escape plain text should be unchanged" + + +def test_format_val_modes(): + value = 1.2345e-6 + # eng mode text + result = formatting.format_val(value, fmt="text", mode="eng") + assert "u" in result or "1" in result, "format_val eng text mode failed" + # sci mode html + result = formatting.format_val(value, fmt="html", mode="sci") + assert "10" in result, "format_val sci html mode failed" + # sci mode latex + result = formatting.format_val(value, fmt="latex", mode="sci") + assert r"\times" in result, "format_val sci latex mode failed" + # float mode (default) + result = formatting.format_val(value, fmt="text", mode="float") + assert "1.2345" in result, "format_val float text mode failed" + # bad mode raises RuntimeError + try: + formatting.format_val(value, fmt="text", mode="bad") + except RuntimeError: + pass + else: + assert False, "format_val bad mode didn't raise RuntimeError" if __name__ == "__main__": pytest.main(["--pdb", __file__]) + diff --git a/tests/Stoner/tools/test_json.py b/tests/Stoner/tools/test_json.py new file mode 100644 index 000000000..fc23e23c5 --- /dev/null +++ b/tests/Stoner/tools/test_json.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +"""Tests for Stoner.tools.json""" + +import pytest + +from Stoner.tools.json import find_parent_dicts, find_paths, flatten_json + + +def test_flatten_json_simple_dict(): + data = {"a": 1, "b": 2} + result = flatten_json(data) + assert result == {"a": 1, "b": 2}, "Simple dict flattening failed" + + +def test_flatten_json_nested_dict(): + data = {"a": {"b": 1}, "c": {"d": {"e": 2}}} + result = flatten_json(data) + assert result == {"a.b": 1, "c.d.e": 2}, "Nested dict flattening failed" + + +def test_flatten_json_with_list(): + data = {"a": {"b": 1}, "c": [10, 20]} + result = flatten_json(data) + assert result == {"a.b": 1, "c[0]": 10, "c[1]": 20}, "List flattening failed" + + +def test_flatten_json_scalar(): + result = flatten_json(42, parent_key="x") + assert result == {"x": 42}, "Scalar flattening failed" + + +def test_flatten_json_nested_list(): + data = {"items": [{"HasData": True}, {"HasData": False}]} + result = flatten_json(data) + assert "items[0].HasData" in result + assert result["items[0].HasData"] is True + assert result["items[1].HasData"] is False + + +def test_flatten_json_bool_and_none(): + data = {"flag": True, "nothing": None} + result = flatten_json(data) + assert result == {"flag": True, "nothing": None}, "Bool and None flattening failed" + + +def test_find_paths_simple(): + data = {"A": {"B": {"HasData": True}}} + result = list(find_paths(data, "HasData", True)) + assert result == [["A", "B", "HasData"]], "Simple nested path not found" + + +def test_find_paths_in_list(): + data = {"items": [{"HasData": True}, {"HasData": False}]} + result = list(find_paths(data, "HasData", True)) + assert len(result) == 1 + assert result[0][-1] == "HasData", "Path through list not found correctly" + + +def test_find_paths_no_match(): + data = {"A": {"B": {"HasData": False}}} + result = list(find_paths(data, "HasData", True)) + assert result == [], "find_paths returned results for non-matching value" + + +def test_find_paths_multiple_matches(): + data = {"A": {"HasData": True}, "B": {"HasData": True}} + result = list(find_paths(data, "HasData", True)) + assert len(result) == 2, "find_paths should find multiple matches" + + +def test_find_paths_scalar_input(): + result = list(find_paths(42, "key", "val")) + assert result == [], "find_paths with scalar should return empty" + + +def test_find_parent_dicts_simple(): + data = {"A": {"B": {"HasData": True, "Other": 5}}} + result = list(find_parent_dicts(data, "HasData", True)) + assert len(result) == 1 + assert result[0] == {"HasData": True, "Other": 5}, "Parent dict not found" + + +def test_find_parent_dicts_in_list(): + data = {"items": [{"HasData": True}, {"HasData": False}]} + result = list(find_parent_dicts(data, "HasData", True)) + assert len(result) == 1 + assert result[0] == {"HasData": True}, "Parent dict in list not found" + + +def test_find_parent_dicts_no_match(): + data = {"A": {"B": {"HasData": False}}} + result = list(find_parent_dicts(data, "HasData", True)) + assert result == [], "find_parent_dicts returned results for non-matching value" + + +def test_find_parent_dicts_multiple_matches(): + data = {"A": {"HasData": True, "v": 1}, "B": {"HasData": True, "v": 2}} + result = list(find_parent_dicts(data, "HasData", True)) + assert len(result) == 2, "find_parent_dicts should find multiple parent dicts" + + +def test_find_parent_dicts_scalar_input(): + result = list(find_parent_dicts(42, "key", "val")) + assert result == [], "find_parent_dicts with scalar should return empty" + + +if __name__ == "__main__": + pytest.main(["--pdb", __file__]) diff --git a/tests/Stoner/tools/test_tests.py b/tests/Stoner/tools/test_tests.py index 4141e9029..7e5508d15 100755 --- a/tests/Stoner/tools/test_tests.py +++ b/tests/Stoner/tools/test_tests.py @@ -76,5 +76,30 @@ def test_is_tuple(): assert not tests.istuple((4, "Hi"), str, int), "istuple failed to match types as bad" +def test_ClassTester(): + ct = tests.ClassTester(Data="Stoner.Data") + # Accessing the attribute should trigger lazy import and return the class + DataClass = ct.Data + assert DataClass is not None, "ClassTester failed to return a class" + # After first access, the value should be cached as a type, not a string + assert isinstance(ct._kwargs["Data"], type), "ClassTester should cache resolved type" + # Second access should return cached type + DataClass2 = ct.Data + assert DataClass is DataClass2, "ClassTester should return cached type on second access" + + +def test_ClassTester_update(): + ct = tests.ClassTester() + ct(MyData="Stoner.Data") + assert "MyData" in ct._kwargs, "ClassTester __call__ should update kwargs" + + +def test_ClassTester_missing_attr(): + ct = tests.ClassTester() + result = ct.nonexistent + assert isinstance(result, AttributeError), "ClassTester should return AttributeError for missing attribute" + + if __name__ == "__main__": pytest.main() + From e3c0f7a76c90c7263799cda316c66bb1c4242eb6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 23:14:00 +0000 Subject: [PATCH 3/3] Fix Python 3.11 super() in comprehension and CI exit code masking Co-authored-by: gb119 <4428426+gb119@users.noreply.github.com> Agent-Logs-Url: https://github.com/stonerlab/Stoner-PythonCode/sessions/23150260-1c22-4a6a-a53b-790927cd3751 --- .github/workflows/run-tests-action.yaml | 3 ++- Stoner/core/base.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests-action.yaml b/.github/workflows/run-tests-action.yaml index f1742b35a..93c13c7a2 100755 --- a/.github/workflows/run-tests-action.yaml +++ b/.github/workflows/run-tests-action.yaml @@ -42,8 +42,9 @@ jobs: sudo apt-get install qtbase5-dev - name: Test with xvfb run: | - xvfb-run --auto-servernum pytest -n 2 --cov-report= --cov=Stoner --junitxml pytest.xml + xvfb-run --auto-servernum pytest -n 2 --cov-report= --cov=Stoner --junitxml pytest.xml || PYTEST_EXIT=$? coverage xml + exit ${PYTEST_EXIT:-0} env: TZ: Europe/London LC_CTYPE: en_GB.UTF-8 diff --git a/Stoner/core/base.py b/Stoner/core/base.py index d1ca00929..be1f89141 100755 --- a/Stoner/core/base.py +++ b/Stoner/core/base.py @@ -477,7 +477,8 @@ def __getitem__(self, name: Union[str, RegExp]) -> Any: key = name (name, typehint) = self._get_name_(name) name = self.__lookup__(name, True) - value = [super().__getitem__(nm) for nm in name] + _super = super() + value = [_super.__getitem__(nm) for nm in name] if typehint is not None: value = [self.__mungevalue(typehint, v) for v in value] if len(value) == 0: # pylint: disable=len-as-condition