mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2025-11-25 06:46:25 +00:00
chore: automate development version and release generation (#772)
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
This change introduces a GitHub Action to automate release creation, including proper tagging and automatic addition of a development marker to the version. A hash is also appended to development versions to make their state easier to distinguish. Tests and release documentation have been updated to reflect the revised release workflow. Several files now retrieve the current version dynamically. The test --full-run option has been rename to --finalize to make clear it is to do commit finalization testing. Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
@@ -11,7 +11,7 @@ Key features:
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, Optional, Type
|
||||
|
||||
@@ -154,7 +154,7 @@ class GeneralSettings(SettingsBaseModel):
|
||||
if v not in cls.compatible_versions:
|
||||
error = (
|
||||
f"Incompatible configuration version '{v}'. "
|
||||
f"Expected one of: {', '.join(cls.compatible_versions)}."
|
||||
f"Expected: {', '.join(cls.compatible_versions)}."
|
||||
)
|
||||
logger.error(error)
|
||||
raise ValueError(error)
|
||||
@@ -335,32 +335,44 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
file_secret_settings (pydantic_settings.PydanticBaseSettingsSource): Unused (needed for parent class interface).
|
||||
|
||||
Returns:
|
||||
tuple[pydantic_settings.PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied.
|
||||
tuple[pydantic_settings.PydanticBaseSettingsSource, ...]: A tuple of settings sources in the order they should be applied.
|
||||
|
||||
Behavior:
|
||||
1. Checks for the existence of a JSON configuration file in the expected location.
|
||||
2. If the configuration file does not exist, creates the directory (if needed) and attempts to copy a
|
||||
default configuration file to the location. If the copy fails, uses the default configuration file directly.
|
||||
3. Creates a `pydantic_settings.JsonConfigSettingsSource` for both the configuration file and the default configuration file.
|
||||
2. If the configuration file does not exist, creates the directory (if needed) and
|
||||
attempts to create a default configuration file in the location. If the creation
|
||||
fails, a temporary configuration directory is used.
|
||||
3. Creates a `pydantic_settings.JsonConfigSettingsSource` for the configuration
|
||||
file.
|
||||
4. Updates class attributes `GeneralSettings._config_folder_path` and
|
||||
`GeneralSettings._config_file_path` to reflect the determined paths.
|
||||
5. Returns a tuple containing all provided and newly created settings sources in the desired order.
|
||||
5. Returns a tuple containing all provided and newly created settings sources in
|
||||
the desired order.
|
||||
|
||||
Notes:
|
||||
- This method logs a warning if the default configuration file cannot be copied.
|
||||
- It ensures that a fallback to the default configuration file is always possible.
|
||||
- This method logs an error if the default configuration file in the normal
|
||||
configuration directory cannot be created.
|
||||
- It ensures that a fallback to a default configuration file is always possible.
|
||||
"""
|
||||
# Ensure we know and have the config folder path and the config file
|
||||
config_file, exists = cls._get_config_file_path()
|
||||
config_dir = config_file.parent
|
||||
if not exists:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
# Create minimum config file
|
||||
config_minimum_content = '{ "general": { "version": "' + __version__ + '" } }'
|
||||
try:
|
||||
shutil.copy2(cls.config_default_file_path, config_file)
|
||||
config_file.write_text(config_minimum_content, encoding="utf-8")
|
||||
except Exception as exc:
|
||||
logger.warning(f"Could not copy default config: {exc}. Using default config...")
|
||||
config_file = cls.config_default_file_path
|
||||
config_dir = config_file.parent
|
||||
# Create minimum config in temporary config directory as last resort
|
||||
error_msg = f"Could not create minimum config file in {config_dir}: {exc}"
|
||||
logger.error(error_msg)
|
||||
temp_dir = Path(tempfile.mkdtemp())
|
||||
info_msg = f"Using temporary config directory {temp_dir}"
|
||||
logger.info(info_msg)
|
||||
config_dir = temp_dir
|
||||
config_file = temp_dir / config_file.name
|
||||
config_file.write_text(config_minimum_content, encoding="utf-8")
|
||||
# Remember config_dir and config file
|
||||
GeneralSettings._config_folder_path = config_dir
|
||||
GeneralSettings._config_file_path = config_file
|
||||
@@ -387,19 +399,8 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
f"Error reading config file '{config_file}' (falling back to default config): {ex}"
|
||||
)
|
||||
|
||||
# Append default settings to sources
|
||||
default_settings = pydantic_settings.JsonConfigSettingsSource(
|
||||
settings_cls, json_file=cls.config_default_file_path
|
||||
)
|
||||
setting_sources.append(default_settings)
|
||||
|
||||
return tuple(setting_sources)
|
||||
|
||||
@classproperty
|
||||
def config_default_file_path(cls) -> Path:
|
||||
"""Compute the default config file path."""
|
||||
return cls.package_root_path.joinpath("data/default.config.json")
|
||||
|
||||
@classproperty
|
||||
def package_root_path(cls) -> Path:
|
||||
"""Compute the package root path."""
|
||||
|
||||
@@ -1,5 +1,156 @@
|
||||
"""Version information for akkudoktoreos."""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# For development add `+dev` to previous release
|
||||
# For release omit `+dev`.
|
||||
__version__ = "0.2.0+dev"
|
||||
VERSION_BASE = "0.2.0+dev"
|
||||
|
||||
# Project hash of relevant files
|
||||
HASH_EOS = ""
|
||||
|
||||
|
||||
# ------------------------------
|
||||
# Helpers for version generation
|
||||
# ------------------------------
|
||||
|
||||
|
||||
def is_excluded_dir(path: Path, excluded_dir_patterns: set[str]) -> bool:
|
||||
"""Check whether a directory should be excluded based on name patterns."""
|
||||
return any(fnmatch(path.name, pattern) for pattern in excluded_dir_patterns)
|
||||
|
||||
|
||||
def hash_tree(
|
||||
paths: list[Path],
|
||||
allowed_suffixes: set[str],
|
||||
excluded_dir_patterns: set[str],
|
||||
excluded_files: Optional[set[Path]] = None,
|
||||
) -> str:
|
||||
"""Return SHA256 hash for files under `paths`.
|
||||
|
||||
Restricted by suffix, excluding excluded directory patterns and excluded_files.
|
||||
"""
|
||||
h = hashlib.sha256()
|
||||
excluded_files = excluded_files or set()
|
||||
|
||||
for root in paths:
|
||||
if not root.exists():
|
||||
raise ValueError(f"Root path does not exist: {root}")
|
||||
for p in sorted(root.rglob("*")):
|
||||
# Skip excluded directories
|
||||
if p.is_dir() and is_excluded_dir(p, excluded_dir_patterns):
|
||||
continue
|
||||
|
||||
# Skip files inside excluded directories
|
||||
if any(is_excluded_dir(parent, excluded_dir_patterns) for parent in p.parents):
|
||||
continue
|
||||
|
||||
# Skip excluded files
|
||||
if p.resolve() in excluded_files:
|
||||
continue
|
||||
|
||||
# Hash only allowed file types
|
||||
if p.is_file() and p.suffix.lower() in allowed_suffixes:
|
||||
h.update(p.read_bytes())
|
||||
|
||||
digest = h.hexdigest()
|
||||
|
||||
return digest
|
||||
|
||||
|
||||
def _version_hash() -> str:
|
||||
"""Calculate project hash.
|
||||
|
||||
Only package file ins src/akkudoktoreos can be hashed to make it work also for packages.
|
||||
"""
|
||||
DIR_PACKAGE_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Allowed file suffixes to consider
|
||||
ALLOWED_SUFFIXES: set[str] = {".py", ".md", ".json"}
|
||||
|
||||
# Directory patterns to exclude (glob-like)
|
||||
EXCLUDED_DIR_PATTERNS: set[str] = {"*_autosum", "*__pycache__", "*_generated"}
|
||||
|
||||
# Files to exclude
|
||||
EXCLUDED_FILES: set[Path] = set()
|
||||
|
||||
# Directories whose changes shall be part of the project hash
|
||||
watched_paths = [DIR_PACKAGE_ROOT]
|
||||
|
||||
hash_current = hash_tree(
|
||||
watched_paths, ALLOWED_SUFFIXES, EXCLUDED_DIR_PATTERNS, excluded_files=EXCLUDED_FILES
|
||||
)
|
||||
return hash_current
|
||||
|
||||
|
||||
def _version_calculate() -> str:
|
||||
"""Compute version."""
|
||||
global HASH_EOS
|
||||
HASH_EOS = _version_hash()
|
||||
if VERSION_BASE.endswith("+dev"):
|
||||
return f"{VERSION_BASE}.{HASH_EOS[:6]}"
|
||||
else:
|
||||
return VERSION_BASE
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Project version information
|
||||
# ----------------------------
|
||||
|
||||
# The version
|
||||
__version__ = _version_calculate()
|
||||
|
||||
|
||||
# -------------------
|
||||
# Version info access
|
||||
# -------------------
|
||||
|
||||
|
||||
# Regular expression to split the version string into pieces
|
||||
VERSION_RE = re.compile(
|
||||
r"""
|
||||
^(?P<base>\d+\.\d+\.\d+) # x.y.z
|
||||
(?:\+ # +dev.hash starts here
|
||||
(?:
|
||||
(?P<dev>dev) # literal 'dev'
|
||||
(?:\.(?P<hash>[A-Za-z0-9]+))? # optional .hash
|
||||
)
|
||||
)?
|
||||
$
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def version() -> dict[str, Optional[str]]:
|
||||
"""Parses the version string.
|
||||
|
||||
The version string shall be of the form:
|
||||
x.y.z
|
||||
x.y.z+dev
|
||||
x.y.z+dev.HASH
|
||||
|
||||
Returns:
|
||||
.. code-block:: python
|
||||
|
||||
{
|
||||
"version": "0.2.0+dev.a96a65",
|
||||
"base": "x.y.z",
|
||||
"dev": "dev" or None,
|
||||
"hash": "<hash>" or None,
|
||||
}
|
||||
"""
|
||||
global __version__
|
||||
|
||||
match = VERSION_RE.match(__version__)
|
||||
if not match:
|
||||
raise ValueError(f"Invalid version format: {version}")
|
||||
|
||||
info = match.groupdict()
|
||||
info["version"] = __version__
|
||||
|
||||
return info
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"general": {
|
||||
"version": "0.2.0+dev"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user