Skip to content

Commit 4c6018f

Browse files
authored
✨📄Compatibility matrix - Detect changes between BO4E-versions (#751)
* Add code for compatibility matrix * Include in docs * Generate JSON-Schemas on RTD * 🩹 * 🩹 * 🩹cmon, fcking shell * 🩹 * debug * Maybe single quotes? * Why the fck it doesnt work * 🩹 * really? * sgtkrwn * sg< * As env var? * Now, please do it * 🩹 * more debug * ✨Add field documentation to description of the JSON-schemas * Fix exponential backtracking * Correct version regex * Add Json Schema build for /latest * Bump bost * 🩹 * 🩹 local test * 📄 * Allow testing via workflow_dispatch * 📄 * Test using env vars * Use env vars * Always update local compiled JSONs * More last versions to compare * Always update local compiled JSONs - Delete old folder * Use local links if build locally * Fix local docs building - make it easier * Get all versions since `v202401.0.0` * Add new Code to linting and type checking * 🚨linter + type checker * 📄from code review
1 parent df052b1 commit 4c6018f

File tree

15 files changed

+880
-27
lines changed

15 files changed

+880
-27
lines changed

.github/workflows/docs_latest.yml

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ on:
55
branches: [main]
66

77
# Allows you to run this workflow manually from the Actions tab
8+
# If the workflow is triggered (manually, through workflow_dispatch) on another branch than the main-branch,
9+
# then it will be published not under /latest but under test-XXXXXX where the X's are a 6-digit random number
10+
# (starting with non-zero).
11+
# You should remember to delete the generated test-XXXXXX folder on the gh-pages branch after you are done with them.
812
workflow_dispatch:
913

1014
jobs:
@@ -18,6 +22,12 @@ jobs:
1822
python-version: ["3.12"]
1923
os: [ubuntu-latest]
2024
steps:
25+
- name: Set routing name to latest
26+
if: github.ref == 'refs/heads/main'
27+
run: echo "REF_NAME=latest" >> "$GITHUB_ENV"
28+
- name: Set routing name to test-XXXXXX
29+
if: github.ref != 'refs/heads/main'
30+
run: echo "REF_NAME=test-$(shuf -i 100000-999999 -n 1)" >> "$GITHUB_ENV"
2131
- uses: actions/setup-python@v5
2232
with:
2333
python-version: ${{ matrix.python-version }}
@@ -28,11 +38,13 @@ jobs:
2838
- name: Install dependencies
2939
run: |
3040
python -m pip install --upgrade pip
41+
pip install tox
3142
pip install -r requirements.txt
3243
# Note: The sphinx action below can only install a single requirements file.
33-
- name: Write version to conf.py
34-
run: |
35-
echo -e "version = release = \"latest\"\n" | cat - docs/conf.py > /tmp/conf.py && mv /tmp/conf.py docs/conf.py
44+
- name: Build JSON Schemas
45+
run: tox -e generate_json_schemas
46+
env:
47+
TARGET_VERSION: ${{ env.REF_NAME }}
3648
- name: Run kroki with docker
3749
run: |
3850
docker compose up -d
@@ -43,9 +55,12 @@ jobs:
4355
with:
4456
requirements_path: docs/requirements.txt
4557
documentation_path: docs/
46-
target_path: latest/
58+
target_path: ${{ env.REF_NAME }}
4759
target_branch: gh-pages
4860
sphinx_options: -W -j auto
61+
env:
62+
SPHINX_DOCS_RELEASE: ${{ env.REF_NAME }}
63+
SPHINX_DOCS_VERSION: ${{ env.REF_NAME }}
4964
- name: Push changes
5065
uses: ad-m/github-push-action@master
5166
with:

.github/workflows/python-publish.yml

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,6 @@ jobs:
8989
python -m pip install --upgrade pip
9090
pip install -r requirements.txt
9191
# Note: The sphinx action below can only install a single requirements file.
92-
- name: Write version to conf.py
93-
run: |
94-
echo -e "version = release = \"${{ github.ref_name }}\"\n" | cat - docs/conf.py > /tmp/conf.py
95-
mv /tmp/conf.py docs/conf.py
9692
- name: Run kroki with docker
9793
run: |
9894
docker compose up -d
@@ -106,6 +102,9 @@ jobs:
106102
target_path: ${{ github.ref_name }}
107103
target_branch: gh-pages
108104
sphinx_options: -W -j auto
105+
env:
106+
SPHINX_DOCS_RELEASE: ${{ github.ref_name }}
107+
SPHINX_DOCS_VERSION: ${{ github.ref_name }}
109108
- id: latest_bo4e
110109
name: Get latest BO4E release tag
111110
uses: pozetroninc/github-action-get-latest-release@master

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ Temporary Items
167167
docs/api
168168
docs/plantuml.jar
169169
docs/_static/images
170+
docs/compatibility_matrix.csv
170171

171172
# version number for bo4e-python; gets auto-generated during the command
172173
# python -m build
@@ -175,3 +176,5 @@ src/_bo4e_python_version.py
175176
# the autogenerated JSON schemas will be build and pushed to BO4E-Schemas
176177
# on release
177178
json_schemas/**/*.json
179+
180+
tmp/

docs/changelog.rst

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,37 @@
11
.. _changes:
2+
3+
================
4+
Compatibility
5+
================
6+
7+
The table below shows the compatibility matrix of the last BO4E versions.
8+
9+
Legend:
10+
11+
+------+------------------------------------------------------+
12+
| 🟢 | | Compatible |
13+
| | | No changes in the data model |
14+
+------+------------------------------------------------------+
15+
| 🟡 | | Compatible |
16+
| | | Only non-critical changes in the data model |
17+
| | | e.g. added fields, changed doc strings |
18+
+------+------------------------------------------------------+
19+
| 🔴 | | Incompatible |
20+
| | | Critical changes in the data model |
21+
| | | e.g. removed fields, changed types |
22+
+------+------------------------------------------------------+
23+
|| | Compatible |
24+
| | | Data model was added in this version |
25+
+------+------------------------------------------------------+
26+
|| | Incompatible |
27+
| | | Data model was removed in this version |
28+
+------+------------------------------------------------------+
29+
| \- | | Data model not existent in this version |
30+
| | | was removed before or will be added in future |
31+
+------+------------------------------------------------------+
32+
33+
.. csv-table:: Compatibility matrix
34+
:file: compatibility_matrix.csv
35+
:header-rows: 1
36+
237
.. include:: ../CHANGELOG.rst

docs/compatibility/__init__.py

Whitespace-only changes.

docs/compatibility/__main__.py

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
"""
2+
This module provides functions to compare the BO4E JSON schemas of different versions.
3+
It also contains functions to query GitHub for the latest BO4E versions to compare with the schemas of the current
4+
work tree.
5+
Additionally, it implements a little cache functionality to avoid multiple downloads of the same versions e.g.
6+
if you're testing locally.
7+
"""
8+
9+
import itertools
10+
import logging
11+
import re
12+
import shutil
13+
from pathlib import Path
14+
from typing import Any as _Any
15+
from typing import Iterable
16+
17+
import bost.operations
18+
from bost import main as bost_main
19+
from bost.operations import update_references as bost_update_references
20+
from bost.pull import OWNER, REPO, SchemaMetadata, get_source_repo
21+
22+
from . import change_schemas, diff, loader, matrix
23+
24+
BO4E_BASE_DIR = Path(__file__).parents[2] / "tmp/bo4e_json_schemas"
25+
LOCAL_JSON_SCHEMA_DIR = Path(__file__).parents[2] / "json_schemas"
26+
logger = logging.getLogger(__name__)
27+
28+
29+
def pull_bo4e_version(version: str, output: Path, gh_token: str | None = None) -> None:
30+
"""
31+
Pull the BO4E version from the given version string.
32+
"""
33+
bost_main(
34+
output=output,
35+
target_version=version,
36+
update_refs=True,
37+
set_default_version=False,
38+
clear_output=True,
39+
token=gh_token,
40+
)
41+
42+
43+
def update_references(path: Path, version: str) -> None:
44+
"""
45+
Update the references in the given path. This step is needed for the local build.
46+
"""
47+
schema_namespace = {}
48+
for schema_path in loader.get_namespace(path):
49+
local_path = Path(path, *schema_path).with_suffix(".json")
50+
schema_namespace[schema_path[-1]] = SchemaMetadata(
51+
class_name=schema_path[-1],
52+
download_url="",
53+
module_path=schema_path,
54+
file_path=local_path,
55+
cached_path=local_path,
56+
token=None,
57+
)
58+
for schema_metadata in schema_namespace.values():
59+
bost_update_references(schema_metadata, schema_namespace, version)
60+
schema_metadata.save()
61+
62+
63+
def pull_or_reuse_bo4e_version(version: str, gh_token: str | None = None, from_local: bool = False) -> Path:
64+
"""
65+
Pull the BO4E version from the given version string or reuse the version if it was already pulled before.
66+
If version is None use the BO4E version of the checkout working directory by assuming the compiled json
67+
schemas in /json_schemas.
68+
Returns the path of the bo4e directory.
69+
"""
70+
bo4e_dir = BO4E_BASE_DIR / version
71+
72+
if from_local:
73+
if not any(LOCAL_JSON_SCHEMA_DIR.rglob("*.json")):
74+
raise ValueError(
75+
"No local json schemas found in /json_schemas. "
76+
"Please ensure that the json schemas are build on beforehand."
77+
)
78+
if bo4e_dir.exists():
79+
shutil.rmtree(bo4e_dir)
80+
shutil.copytree(LOCAL_JSON_SCHEMA_DIR, bo4e_dir)
81+
update_references(bo4e_dir, version)
82+
elif any(bo4e_dir.rglob("*.json")):
83+
return bo4e_dir
84+
else:
85+
pull_bo4e_version(version, bo4e_dir, gh_token)
86+
return bo4e_dir
87+
88+
89+
def compare_bo4e_versions(
90+
version_old: str, version_new: str, gh_token: str | None = None, from_local: bool = False
91+
) -> Iterable[change_schemas.Change]:
92+
"""
93+
Compare the old version with the new version.
94+
If version_new is None use the BO4E version of the checkout working directory by assuming the compiled json
95+
schemas in /json_schemas.
96+
"""
97+
dir_old_schemas = pull_or_reuse_bo4e_version(version_old, gh_token)
98+
dir_new_schemas = pull_or_reuse_bo4e_version(version_new, gh_token, from_local=from_local)
99+
print(f"Comparing {version_old} with {version_new}")
100+
yield from diff.diff_schemas(dir_old_schemas, dir_new_schemas)
101+
102+
103+
def compare_bo4e_versions_iteratively(
104+
versions: Iterable[str], cur_version: str | None = None, gh_token: str | None = None
105+
) -> dict[tuple[str, str], Iterable[change_schemas.Change]]:
106+
"""
107+
Compare the versions iteratively. Each version at index i will be compared to the version at index i+1.
108+
Additionally, if cur_version is provided, the last version in the list will be compared to the version
109+
in the checkout working directory. The value of cur_version will be used to set the key in the returned
110+
dict.
111+
Note:
112+
- versions must contain at least one element.
113+
- versions should be sorted in ascending order.
114+
- if using cur_version, ensure that the json schemas of the checkout working directory
115+
were build on beforehand. They should be located in /json_schemas.
116+
"""
117+
print(f"Comparing versions {versions} with cur_version {cur_version}")
118+
changes = {}
119+
last_version: str = "" # This value is never used but makes mypy and pylint happy
120+
for version_old, version_new in itertools.pairwise(versions):
121+
last_version = version_new
122+
changes[version_old, version_new] = compare_bo4e_versions(version_old, version_new, gh_token)
123+
if cur_version is not None:
124+
changes[last_version, cur_version] = compare_bo4e_versions(last_version, cur_version, gh_token, from_local=True)
125+
print("Comparisons finished.")
126+
return changes
127+
128+
129+
REGEX_RELEASE_VERSION = re.compile(r"^v(\d{6}\.\d+\.\d+)$")
130+
REGEX_RELEASE_CANDIDATE_VERSION = re.compile(r"^v(\d{6}\.\d+\.\d+)-rc\d+$")
131+
132+
133+
def get_last_n_release_versions(n: int, include_rc: bool = False, gh_token: str | None = None) -> Iterable[str]:
134+
"""
135+
Get the last n release versions from the BO4E repository.
136+
"""
137+
repo = get_source_repo(gh_token)
138+
releases = repo.get_releases()
139+
counter = 0
140+
141+
for release in releases:
142+
if not REGEX_RELEASE_VERSION.fullmatch(release.tag_name) and (
143+
not include_rc or not REGEX_RELEASE_CANDIDATE_VERSION.fullmatch(release.tag_name)
144+
):
145+
continue
146+
counter += 1
147+
yield release.tag_name
148+
if counter >= n:
149+
return
150+
151+
logger.warning("Only %d matching releases found. Returning all releases.", counter)
152+
153+
154+
def get_all_release_versions_since_20240100(include_rc: bool = False, gh_token: str | None = None) -> Iterable[str]:
155+
"""
156+
Get all release versions since v202401.0.0 from the BO4E repository.
157+
"""
158+
repo = get_source_repo(gh_token)
159+
releases = repo.get_releases()
160+
version_threshold = "v202401.0.0"
161+
162+
for release in releases:
163+
if not REGEX_RELEASE_VERSION.fullmatch(release.tag_name) and (
164+
not include_rc or not REGEX_RELEASE_CANDIDATE_VERSION.fullmatch(release.tag_name)
165+
):
166+
continue
167+
yield release.tag_name
168+
if release.tag_name == version_threshold:
169+
return
170+
171+
logger.warning("Threshold version %s not found. Returned all matching releases.", version_threshold)
172+
173+
174+
def _monkey_patch_bost_regex_if_local_testing(version: str) -> None:
175+
regex_expected_version = re.compile(r"^v\d+\.\d+\.\d+(?:-rc\d+)?$")
176+
if not regex_expected_version.fullmatch(version):
177+
bost.operations.REF_ONLINE_REGEX = re.compile(
178+
rf"^https://raw\.githubusercontent\.com/(?:{OWNER.upper()}|{OWNER.lower()}|Hochfrequenz)/{REPO}/"
179+
rf"(?P<version>[^/]+)/"
180+
r"src/bo4e_schemas/(?P<sub_path>(?:\w+/)*)(?P<model>\w+)\.json#?$"
181+
)
182+
183+
184+
def create_tables_for_doc(
185+
compatibility_matrix_output_file: Path,
186+
gh_version: str,
187+
*,
188+
gh_token: str | None = None,
189+
last_n_versions: int = 2,
190+
) -> None:
191+
"""
192+
Creates the compatibility matrix for the documentation. The output is a csv file. This can be referenced
193+
inside Sphinx documentation. See https://sublime-and-sphinx-guide.readthedocs.io/en/latest/tables.html#csv-files
194+
for more information.
195+
If you have problems with rate limiting, please set gh_token.
196+
The compatibility matrix will be built for last_n_versions + the current version in the checkout working directory.
197+
If you set last_n_versions = 0 all versions since v202401.0.0 will be compared.
198+
Note: The matrix will never contain the first version as column. Each column is a comparison to the version before.
199+
Note: Release candidates are excluded.
200+
"""
201+
_monkey_patch_bost_regex_if_local_testing(gh_version)
202+
logger.info("Retrieving the last %d release versions", last_n_versions)
203+
if last_n_versions > 0:
204+
versions = list(reversed(list(get_last_n_release_versions(last_n_versions, gh_token=gh_token))))
205+
else:
206+
versions = list(reversed(list(get_all_release_versions_since_20240100(gh_token=gh_token))))
207+
logger.info("Comparing versions iteratively: %s", " -> ".join([*versions, gh_version]))
208+
changes_iterables = compare_bo4e_versions_iteratively(versions, gh_version, gh_token=gh_token)
209+
logger.info("Building namespaces")
210+
changes = {key: list(value) for key, value in changes_iterables.items()}
211+
namespaces = {version: list(loader.get_namespace(BO4E_BASE_DIR / version)) for version in versions}
212+
namespaces[gh_version] = list(loader.get_namespace(BO4E_BASE_DIR / gh_version))
213+
logger.info("Creating compatibility matrix")
214+
matrix.create_compatibility_matrix_csv(
215+
compatibility_matrix_output_file, [*versions, gh_version], namespaces, changes
216+
)
217+
218+
219+
def test_create_tables_for_doc() -> None:
220+
"""
221+
Test the create_tables_for_doc function locally without building the entire documentation.
222+
Needs the JSON schemas to be present in /json_schemas with TARGET_VERSION set to "local".
223+
"""
224+
create_tables_for_doc(
225+
Path(__file__).parents[1] / "compatibility_matrix.csv",
226+
"local",
227+
last_n_versions=3,
228+
)

0 commit comments

Comments
 (0)