From fd27cd03bc44a3295221857269e7215373a29e6d Mon Sep 17 00:00:00 2001 From: teutoburg Date: Tue, 29 Apr 2025 11:34:11 +0200 Subject: [PATCH 1/5] Use Badges from astar-utils --- irdb/tests/test_package_contents.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/irdb/tests/test_package_contents.py b/irdb/tests/test_package_contents.py index baa3e28c..a112f873 100644 --- a/irdb/tests/test_package_contents.py +++ b/irdb/tests/test_package_contents.py @@ -7,11 +7,12 @@ import pytest import yaml -from scopesim.effects.data_container import DataContainer from astropy.io.ascii import InconsistentTableError +from scopesim.effects.data_container import DataContainer +from astar_utils import BadgeReport + from irdb.utils import get_packages, recursive_filename_search -from irdb.badges import BadgeReport from irdb.fileversions import IRDBFile From 90ff1b05c58e1b9125e488cbd606d55d4702d2aa Mon Sep 17 00:00:00 2001 From: teutoburg Date: Tue, 29 Apr 2025 11:46:21 +0200 Subject: [PATCH 2/5] Add astar-utils to requirements.github_actions.txt --- requirements.github_actions.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.github_actions.txt b/requirements.github_actions.txt index 9ed7f400..cb715d66 100644 --- a/requirements.github_actions.txt +++ b/requirements.github_actions.txt @@ -10,3 +10,4 @@ scopesim_templates jupytext ipykernel nbconvert +astar-utils From 85bbaf3079bdae34c80f2b14bf97803131b06c32 Mon Sep 17 00:00:00 2001 From: teutoburg Date: Tue, 29 Apr 2025 11:57:30 +0200 Subject: [PATCH 3/5] Add irdb test marker to deselect non-instrument-package tests Those don't need to run in the full test matrix. --- irdb/tests/test_publish.py | 3 +++ irdb/tests/test_utils.py | 4 ++++ pytest.ini | 3 ++- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/irdb/tests/test_publish.py b/irdb/tests/test_publish.py index d6124bb0..08a0126f 100644 --- a/irdb/tests/test_publish.py +++ b/irdb/tests/test_publish.py @@ -65,6 +65,9 @@ # # Put the original values back. # PATH_TEST_PACKAGE_VERSION_YAML.write_bytes(b_yaml_test_package) +# Note: This module doesn't need to run always, so mark it. +pytestmark = pytest.mark.irdb + @pytest.fixture(scope="module") def temp_zipfiles(tmp_path_factory): diff --git a/irdb/tests/test_utils.py b/irdb/tests/test_utils.py index dd2b9062..e39847e0 100644 --- a/irdb/tests/test_utils.py +++ b/irdb/tests/test_utils.py @@ -12,6 +12,10 @@ from irdb.utils import get_packages +# Note: This module doesn't need to run always, so mark it. +pytestmark = pytest.mark.irdb + + @pytest.fixture(name="packages", scope="class") def fixture_packages(): return dict(get_packages()) diff --git a/pytest.ini b/pytest.ini index 34d8628e..0947adaf 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,8 +1,9 @@ [pytest] # Prevent recursion into MICADO/docs/example_notebooks/inst_pkgs -addopts = --ignore-glob="*/inst_pkgs/*" -p no:randomly -m "not badges" +addopts = --ignore-glob="*/inst_pkgs/*" -p no:randomly -m "not badges and not irdb" # Badge report needs order (at least for now, should be solved by using astar-utils NestedMapping) markers = webtest: mark a test as using network resources. slow: mark test as slow. badges: tests for the badge report + irdb: tests for IRDB functionality (unrelated to a specific instrument package) From 369ad213e37c2b13a5edb1fed690c8f2b72bfd03 Mon Sep 17 00:00:00 2001 From: teutoburg Date: Tue, 29 Apr 2025 11:44:57 +0200 Subject: [PATCH 4/5] Delete redundant code This has been moved to astar-utils for the longest time, but never actually update the code to use that... --- irdb/badges.py | 318 -------------------------------------- irdb/tests/test_badges.py | 135 ---------------- 2 files changed, 453 deletions(-) delete mode 100644 irdb/badges.py delete mode 100644 irdb/tests/test_badges.py diff --git a/irdb/badges.py b/irdb/badges.py deleted file mode 100644 index 0479ef60..00000000 --- a/irdb/badges.py +++ /dev/null @@ -1,318 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -"""Everything to do with report badges and more.""" - -import logging -from pathlib import Path -from typing import TextIO -from numbers import Number -from string import Template -from datetime import datetime as dt, timezone -from collections.abc import Mapping - -import yaml - -from astar_utils import NestedMapping - -# After 3.11, can just import UTC directly from datetime -UTC = timezone.utc - -PKG_DIR = Path(__file__).parent.parent - - -def _fix_badge_str(badge_str: str) -> str: - """Eliminate any spaces and single dashes in badge string.""" - return badge_str.replace(" ", "_").replace("-", "--") - - -class Badge(): - """Base class for markdown report badges. - - Based on the type and (in case of strings) value of the parameter `value`, - the appropriate subclass is returned, which also deals with the colour of - the badge. These subclasses should *not* be instantiated directly, but - rather this base class should always be used. - - In the case of a string-type `value`, the colour of the badge is based on - a set of special strings, e.g. red for 'error' or green for 'found'. - A complete list of these special strings can be accessed via - ``StrBadge.special_strings``. The default colour for any string value not - listed as a special string is lightgrey. - - By default, all badges appear as "key/label-value" badges with a grey label - on the left side and a coloured value on the right side. For simple - messages, it is also possible to produce a "message-only" badge. This can - simply be done by adding a leading '!' to the (string) `value` parameter. - The message of the badge is then only the `key` parameter, while the colour - of the badge is again decided by the special strings, after the leading '!' - is stripped. - - Any spaces or single dashes present in either `key` or `value` are - automatically replaced by underscores or double dashes, respectively, to - comply with the format requirements for the badges. - - It is possible to manually change the colour of any badge after creation - by setting the desired colour (string) for the `colour` attribute. - - Parameters - ---------- - key : str - Dictionary key, become (left-side) label of the badge. - value : str, bool, int or float - Dictionary key, become (right-side) value of the badge. - Subclass dispatch is decided based on type and value of this parameter. - - Attributes - ---------- - colour : str - The (auto-assigned) colour of the badge. - """ - - pattern = Template("[![](https://img.shields.io/badge/$key-$val-$col)]()") - colour = "lightgrey" - - def __new__(cls, key: str, value): - if isinstance(value, bool): - return super().__new__(BoolBadge) - if isinstance(value, Number): - return super().__new__(NumBadge) - if isinstance(value, str): - if value.startswith("!"): - return super().__new__(MsgOnlyBadge) - return super().__new__(StrBadge) - raise TypeError(value) - - def __init__(self, key: str, value): - self.key = _fix_badge_str(key) - self.value = _fix_badge_str(value) if isinstance(value, str) else value - - def write(self, stream: TextIO) -> None: - """Write formatted pattern to I/O stream.""" - _dict = {"key": self.key, "val": self.value, "col": self.colour} - stream.write(self.pattern.substitute(_dict)) - - -class BoolBadge(Badge): - """Key-value Badge for bool values, True -> green, False -> red.""" - - colour = "red" - - def __init__(self, key: str, value: bool): - super().__init__(key, value) - if self.value: - self.colour = "green" - - -class NumBadge(Badge): - """Key-value Badge for numerical values, lightblue.""" - - colour = "lightblue" - - -class StrBadge(Badge): - """Key-value Badge for string values, colour based on special strings.""" - - special_strings = { - "observation": "blueviolet", - "support": "deepskyblue", - "error": "red", - "missing": "red", - "warning": "orange", - "conflict": "orange", - "incomplete": "orange", - "ok": "green", - "found": "green", - "not_found": "red", - "none": "yellowgreen", - } - - def __init__(self, key: str, value: str): - super().__init__(key, value) - self.colour = self.special_strings.get(self.value.lower(), "lightgrey") - - -class MsgOnlyBadge(StrBadge): - """Key-only Badge for string values, colour based on special strings.""" - - pattern = Template("[![](https://img.shields.io/badge/$key-$col)]()") - - def __init__(self, key: str, value: str): - value = value.removeprefix("!") - super().__init__(key, value) - - -class BadgeReport(NestedMapping): - """Context manager class for collection and generation of report badges. - - Intended usage is in a pytest fixture with a scope that covers all tests - that should be included in that report file: - - >>> import pytest - >>> - >>> @pytest.fixture(name="badges", scope="module") - >>> def fixture_badges(): - >>> with BadgeReport() as report: - >>> yield report - - This fixture can then be used inside the tests like a dictionary: - - >>> def test_something(self, badges): - >>> badges[f"!foo.bar.baz"] = "OK" - - Because `BadgeReport` inherits from ``NestedMapping``, the use of '!'-type - "bang-strings" is supported. - - Additionally, any logging generated within a test can be captured and - stored in the report, to be written in a separate log file at teardown: - - >>> import logging - >>> - >>> def test_something_else(self, badges, caplog): - >>> logging.warning("Oh no!") - >>> badges.logs.extend(caplog.records) - - Note the use of ``caplog.records`` to access the ``logging.LogRecord`` - objects rather then the string output, as `BadgeReport` performs very basic - custom formatting. Further note the use of ``logs.extend()``, because - ``caplog.records`` returns a ``list``, to not end up with nested lists. - - The level of logging recorded is controlled by the logging settings in the - test script. `BadgeReport` handles all ``logging.LogRecord`` objects in - the final `.logs` list. - - Parameters - ---------- - filename : str, optional - Name for yaml file, should end in '.yaml. The default is "badges.yaml". - report_filename : str, optional - Name for report file, should end in '.md'. The default is "badges.md". - logs_filename : str, optional - Name for log file. The default is "badge_report_log.txt". - save_logs : bool, optional - Whether to output logs. The default is True. - - Attributes - ---------- - yamlpath : Path - Full path for yaml file. - report_path : Path - Full path for report file. - log_path : Path - Full path for log file. - logs : list of logging.LogRecord - List of logging.LogRecord objects to be saved to `logs_filename`. - """ - - def __init__( - self, - filename: str = "badges.yaml", - report_filename: str = "badges.md", - logs_filename: str = "badge_report_log.txt", - save_logs: bool = True, - ) -> None: - logging.debug("REPORT INIT") - base_path = Path(PKG_DIR, "_REPORTS") - - self.filename = filename - self.yamlpath = base_path / self.filename - self.report_name = report_filename - self.report_path = base_path / self.report_name - - self.save_logs = save_logs - self.logs = [] - logs_name = logs_filename or "badge_report_log.txt" - self.log_path = base_path / logs_name - - super().__init__() - - def __enter__(self): - """Context manager setup.""" - logging.debug("REPORT ENTER") - # try: - # # TODO: WHY do we actually load this first? It caused some issues - # # with 'old' badges that are not cleared. Is there any good - # # reason at all to load the previous yaml file??? - # with self.yamlpath.open(encoding="utf-8") as file: - # self.update(yaml.full_load(file)) - # except FileNotFoundError: - # logging.warning("%s not found, init empty dict", self.yamlpath) - logging.debug("Init emtpy dict.") - return self - - def __exit__(self, exc_type, exc_value, exc_traceback): - """Context manager teardown.""" - logging.debug("REPORT EXIT") - self.write_yaml() - self.generate_report() - if self.save_logs: - self.write_logs() - logging.debug("REPORT DONE") - - def write_logs(self) -> None: - """Dump logs to file (`logs_filename`).""" - with self.log_path.open("w", encoding="utf-8") as file: - for log in self.logs: - file.write(f"{log.levelname}::{log.message}\n") - - def write_yaml(self) -> None: - """Dump dict to yaml file (`filename`).""" - dumpstr = yaml.dump(self.dic, sort_keys=False) - self.yamlpath.write_text(dumpstr, encoding="utf-8") - - def _make_preamble(self) -> str: - preamble = ("# IRDB Packages Report\n\n" - f"**Created on UTC {dt.now(UTC):%Y-%m-%d %H:%M:%S}**\n\n" - "For details on errors and conflicts, see badge report " - "log file in this directory.\n\n") - return preamble - - def generate_report(self) -> None: - """Write markdown badge report to `report_filename`.""" - if not self.report_path.suffix == ".md": - logging.warning(("Expected '.md' suffix for report file name, but " - "found %s. Report file might not be readable."), - self.report_path.suffix) - with self.report_path.open("w", encoding="utf-8") as file: - file.write(self._make_preamble()) - make_entries(file, self.dic) - - -def _get_nested_header(key: str, level: int) -> str: - if level > 2: - return f"* {key}: " - return f"{'#' * (level + 2)} {key.title() if level else key}" - - -def make_entries(stream: TextIO, entry, level=0) -> None: - """ - Recursively write lines of text from a nested dictionary to text stream. - - Parameters - ---------- - stream : TextIO - I/O stream to write the badges to. - - entry : dict, str, bool, float, int - A level from a nested dictionary - - level : int - How far down the rabbit hole we are w.r.t the nested dictionary - - Returns - ------- - None - """ - if not isinstance(entry, Mapping): - return - - for key, value in entry.items(): - stream.write("\n") - stream.write(" " * (level - 2)) - if isinstance(value, Mapping): - stream.write(_get_nested_header(key, level)) - # recursive - make_entries(stream, value, level=level+1) - else: - if level > 1: - stream.write("* ") - Badge(key, value).write(stream) diff --git a/irdb/tests/test_badges.py b/irdb/tests/test_badges.py deleted file mode 100644 index 4d134455..00000000 --- a/irdb/tests/test_badges.py +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Tests for irdb.badges -""" - -from io import StringIO -from unittest import mock - -import yaml -import pytest - -from irdb.badges import ( - BadgeReport, - Badge, - BoolBadge, - NumBadge, - StrBadge, - MsgOnlyBadge, -) -from astar_utils import NestedMapping - - -@pytest.fixture(name="temp_dir", scope="module") -def fixture_temp_dir(tmp_path_factory): - tmpdir = tmp_path_factory.mktemp("PKG_DIR") - (tmpdir / "_REPORTS").mkdir() - return tmpdir - - -class TestBadgeSubclasses: - def test_bool(self): - assert isinstance(Badge("bogus", True), BoolBadge) - assert isinstance(Badge("bogus", False), BoolBadge) - - def test_num(self): - assert isinstance(Badge("bogus", 7), NumBadge) - assert isinstance(Badge("bogus", 3.14), NumBadge) - - def test_str(self): - assert isinstance(Badge("bogus", "foo"), StrBadge) - - def test_msgonly(self): - assert isinstance(Badge("bogus", "!foo"), MsgOnlyBadge) - - -class TestColours: - @pytest.mark.parametrize("value, colour", [ - ("observation", "blueviolet"), - ("support", "deepskyblue"), - ("error", "red"), - ("missing", "red"), - ("warning", "orange"), - ("conflict", "orange"), - ("incomplete", "orange"), - ("ok", "green"), - ("found", "green"), - ("not_found", "red"), - ("none", "yellowgreen"), - ]) - def test_special_strings(self, value, colour): - assert Badge("bogus", value).colour == colour - - def test_bool(self): - assert Badge("bogus", True).colour == "green" - assert Badge("bogus", False).colour == "red" - - def test_num(self): - assert Badge("bogus", 7).colour == "lightblue" - - -class TestPattern: - def test_simple(self): - with StringIO() as str_stream: - Badge("bogus", "Error").write(str_stream) - pattern = "[![](https://img.shields.io/badge/bogus-Error-red)]()" - assert pattern in str_stream.getvalue() - - def test_msg_only(self): - with StringIO() as str_stream: - Badge("bogus", "!OK").write(str_stream) - pattern = "[![](https://img.shields.io/badge/bogus-green)]()" - assert pattern in str_stream.getvalue() - - -class TestSpecialChars: - def test_space(self): - badge = Badge("bogus foo", "bar baz") - assert badge.key == "bogus_foo" - assert badge.value == "bar_baz" - - def test_dash(self): - badge = Badge("bogus-foo", "bar-baz") - assert badge.key == "bogus--foo" - assert badge.value == "bar--baz" - - -class TestReport: - # TODO: the repeated setup stuff should be a fixture or something I guess - - @pytest.mark.usefixtures("temp_dir") - def test_writes_yaml(self, temp_dir): - with mock.patch("irdb.badges.PKG_DIR", temp_dir): - with BadgeReport("test.yaml", "test.md") as report: - report["!foo.bar"] = "bogus" - assert (temp_dir / "_REPORTS/test.yaml").exists() - - @pytest.mark.usefixtures("temp_dir") - def test_writes_md(self, temp_dir): - with mock.patch("irdb.badges.PKG_DIR", temp_dir): - with BadgeReport("test.yaml", "test.md") as report: - report["!foo.bar"] = "bogus" - assert (temp_dir / "_REPORTS/test.md").exists() - - @pytest.mark.usefixtures("temp_dir") - def test_yaml_content(self, temp_dir): - with mock.patch("irdb.badges.PKG_DIR", temp_dir): - with BadgeReport("test.yaml", "test.md") as report: - report["!foo.bar"] = "bogus" - path = temp_dir / "_REPORTS/test.yaml" - with path.open(encoding="utf-8") as file: - dic = NestedMapping(yaml.full_load(file)) - assert "!foo.bar" in dic - assert dic["!foo.bar"] == "bogus" - - @pytest.mark.usefixtures("temp_dir") - def test_md_content(self, temp_dir): - with mock.patch("irdb.badges.PKG_DIR", temp_dir): - with BadgeReport("test.yaml", "test.md") as report: - report["!foo.bar"] = "bogus" - path = temp_dir / "_REPORTS/test.md" - markdown = path.read_text(encoding="utf-8") - assert "## foo" in markdown - badge = "[![](https://img.shields.io/badge/bar-bogus-lightgrey)]()" - assert badge in markdown From 607470149970c1203481bb08a936097e710b0391 Mon Sep 17 00:00:00 2001 From: teutoburg Date: Thu, 5 Feb 2026 13:32:11 +0100 Subject: [PATCH 5/5] Run IRDB tests separately --- .github/workflows/internal_tests.yml | 71 ++++++++++++++++++++++++++++ .github/workflows/tests_dev.yml | 4 ++ .github/workflows/tests_dev_pr.yml | 9 ++++ .github/workflows/tests_main_pr.yml | 6 +++ 4 files changed, 90 insertions(+) create mode 100644 .github/workflows/internal_tests.yml diff --git a/.github/workflows/internal_tests.yml b/.github/workflows/internal_tests.yml new file mode 100644 index 00000000..354e284e --- /dev/null +++ b/.github/workflows/internal_tests.yml @@ -0,0 +1,71 @@ +# Any IRDB functionality not related to a specific instrument package. +# Has to be triggered from elsewhere like tests.yml or notebooktests.yml. + +name: Internal functionality tests +on: + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + inputs: + from_pypi: + type: boolean + description: "Use plain pip install and ignore branch names below." + required: false + default: false + ScopeSim: + type: string + description: "Branch name to install ScopeSim from." + required: false + default: main + ScopeSim_Templates: + type: string + description: "Branch name to install ScopeSim_Templates from." + required: false + default: main + + # Allow this workflow to be called from other repositories. + workflow_call: + inputs: + from_pypi: + type: boolean + description: "Use plain pip install and ignore branch names below." + required: false + default: false + ScopeSim: + type: string + description: "Branch name to install ScopeSim from." + required: false + default: main + ScopeSim_Templates: + type: string + description: "Branch name to install ScopeSim_Templates from." + required: false + default: main + +jobs: + irdb_test: + name: Run internal tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + # No matrix is used since this is a time-consuming task. + python-version: 3.13 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.github_actions.txt + + - name: Install ScopeSim from repo + if: ${{ inputs.from_pypi == false || inputs.from_pypi == 'false' }} + run: | + echo "Re-installing ScopeSim from source" >> $GITHUB_STEP_SUMMARY + pip uninstall -y scopesim scopesim_templates + pip install git+https://github.com/AstarVienna/ScopeSim.git@${{ inputs.ScopeSim }} + pip install git+https://github.com/AstarVienna/ScopeSim_Templates.git@${{ inputs.ScopeSim_Templates }} + + - name: Run Pytest for internal tests + run: pytest -m "irdb" diff --git a/.github/workflows/tests_dev.yml b/.github/workflows/tests_dev.yml index 651fe683..a216acd0 100644 --- a/.github/workflows/tests_dev.yml +++ b/.github/workflows/tests_dev.yml @@ -13,6 +13,10 @@ jobs: name: Tests uses: ./.github/workflows/tests.yml + tests: + name: Internal Tests + uses: ./.github/workflows/internal_tests.yml + notebook_tests: name: Notebook tests uses: ./.github/workflows/notebooktests.yml diff --git a/.github/workflows/tests_dev_pr.yml b/.github/workflows/tests_dev_pr.yml index 037bd5c0..a08f9bb1 100644 --- a/.github/workflows/tests_dev_pr.yml +++ b/.github/workflows/tests_dev_pr.yml @@ -25,6 +25,15 @@ jobs: ScopeSim: ${{ needs.determine_branches.outputs.ScopeSim }} ScopeSIM_Templates: ${{ needs.determine_branches.outputs.ScopeSIM_Templates }} + internal_tests: + name: Internal Tests + needs: determine_branches + if: contains(github.event.pull_request.labels.*.name, 'irdb functionality') + uses: ./.github/workflows/internal_tests.yml + with: + ScopeSim: ${{ needs.determine_branches.outputs.ScopeSim }} + ScopeSIM_Templates: ${{ needs.determine_branches.outputs.ScopeSIM_Templates }} + notebook_tests: name: Notebook tests needs: determine_branches diff --git a/.github/workflows/tests_main_pr.yml b/.github/workflows/tests_main_pr.yml index a7af7476..9dbc4191 100644 --- a/.github/workflows/tests_main_pr.yml +++ b/.github/workflows/tests_main_pr.yml @@ -11,6 +11,12 @@ jobs: with: from_pypi: true + internal_tests: + name: Internal Tests + uses: ./.github/workflows/internal_tests.yml + with: + from_pypi: true + notebook_tests: name: Notebook tests uses: ./.github/workflows/notebooktests.yml