mirror of
https://github.com/kevinveenbirkenbach/homepage.veen.world.git
synced 2026-04-07 05:12:19 +00:00
feat: migrate to pyproject.toml, add test suites, split CI workflows
- Replace requirements.txt with pyproject.toml for modern Python packaging - Add unit, integration, lint and security test suites under tests/ - Add utils/export_runtime_requirements.py and utils/check_hadolint_sarif.py - Split monolithic CI into reusable lint.yml, security.yml and tests.yml - Refactor ci.yml to orchestrate reusable workflows; publish on semver tag only - Modernize Dockerfile: pin python:3.12-slim, install via pyproject.toml - Expand Makefile with lint, security, test and CI targets - Add test-e2e via act with portfolio container stop/start around run - Fix navbar_logo_visibility.spec.js: win.fullscreen() → win.enterFullscreen() - Set use_reloader=False in app.run() to prevent double-start in CI - Add app/core.* and build artifacts to .gitignore - Fix apt-get → sudo apt-get in tests.yml e2e job - Fix pip install --ignore-installed to handle stale act cache Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Portfolio UI web application package."""
|
||||
78
app/app.py
78
app/app.py
@@ -1,29 +1,38 @@
|
||||
import os
|
||||
from flask import Flask, render_template
|
||||
import yaml
|
||||
import requests
|
||||
from utils.configuration_resolver import ConfigurationResolver
|
||||
from utils.cache_manager import CacheManager
|
||||
from utils.compute_card_classes import compute_card_classes
|
||||
import logging
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
FLASK_ENV = os.getenv("FLASK_ENV", "production")
|
||||
FLASK_PORT = int(os.getenv("PORT", 5000))
|
||||
print(f"🔧 Starting app on port {FLASK_PORT}, FLASK_ENV={FLASK_ENV}")
|
||||
import os
|
||||
|
||||
from flask import current_app
|
||||
import requests
|
||||
import yaml
|
||||
from flask import Flask, current_app, render_template
|
||||
from markupsafe import Markup
|
||||
|
||||
try:
|
||||
from app.utils.cache_manager import CacheManager
|
||||
from app.utils.compute_card_classes import compute_card_classes
|
||||
from app.utils.configuration_resolver import ConfigurationResolver
|
||||
except ImportError: # pragma: no cover - supports running from the app/ directory.
|
||||
from utils.cache_manager import CacheManager
|
||||
from utils.compute_card_classes import compute_card_classes
|
||||
from utils.configuration_resolver import ConfigurationResolver
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
FLASK_ENV = os.getenv("FLASK_ENV", "production")
|
||||
FLASK_HOST = os.getenv("FLASK_HOST", "127.0.0.1")
|
||||
FLASK_PORT = int(os.getenv("FLASK_PORT", os.getenv("PORT", 5000)))
|
||||
print(f"Starting app on {FLASK_HOST}:{FLASK_PORT}, FLASK_ENV={FLASK_ENV}")
|
||||
|
||||
# Initialize the CacheManager
|
||||
cache_manager = CacheManager()
|
||||
|
||||
# Clear cache on startup
|
||||
cache_manager.clear_cache()
|
||||
|
||||
|
||||
def load_config(app):
|
||||
"""Load and resolve the configuration from config.yaml."""
|
||||
with open("config.yaml", "r") as f:
|
||||
config = yaml.safe_load(f)
|
||||
with open("config.yaml", "r", encoding="utf-8") as handle:
|
||||
config = yaml.safe_load(handle)
|
||||
|
||||
if config.get("nasa_api_key"):
|
||||
app.config["NASA_API_KEY"] = config["nasa_api_key"]
|
||||
@@ -32,26 +41,23 @@ def load_config(app):
|
||||
resolver.resolve_links()
|
||||
app.config.update(resolver.get_config())
|
||||
|
||||
|
||||
def cache_icons_and_logos(app):
|
||||
"""Cache all icons and logos to local files, mit Fallback auf source."""
|
||||
"""Cache all icons and logos to local files, with a source fallback."""
|
||||
for card in app.config["cards"]:
|
||||
icon = card.get("icon", {})
|
||||
if icon.get("source"):
|
||||
cached = cache_manager.cache_file(icon["source"])
|
||||
# Fallback: wenn cache_file None liefert, nutze weiterhin source
|
||||
icon["cache"] = cached or icon["source"]
|
||||
|
||||
# Company-Logo
|
||||
company_logo = app.config["company"]["logo"]
|
||||
cached = cache_manager.cache_file(company_logo["source"])
|
||||
company_logo["cache"] = cached or company_logo["source"]
|
||||
|
||||
# Platform Favicon
|
||||
favicon = app.config["platform"]["favicon"]
|
||||
cached = cache_manager.cache_file(favicon["source"])
|
||||
favicon["cache"] = cached or favicon["source"]
|
||||
|
||||
# Platform Logo
|
||||
platform_logo = app.config["platform"]["logo"]
|
||||
cached = cache_manager.cache_file(platform_logo["source"])
|
||||
platform_logo["cache"] = cached or platform_logo["source"]
|
||||
@@ -64,18 +70,22 @@ app = Flask(__name__)
|
||||
load_config(app)
|
||||
cache_icons_and_logos(app)
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def utility_processor():
|
||||
def include_svg(path):
|
||||
full_path = os.path.join(current_app.root_path, 'static', path)
|
||||
full_path = os.path.join(current_app.root_path, "static", path)
|
||||
try:
|
||||
with open(full_path, 'r', encoding='utf-8') as f:
|
||||
svg = f.read()
|
||||
return Markup(svg)
|
||||
except IOError:
|
||||
return Markup(f'<!-- SVG not found: {path} -->')
|
||||
with open(full_path, "r", encoding="utf-8") as handle:
|
||||
svg = handle.read()
|
||||
# Trusted local SVG asset shipped with the application package.
|
||||
return Markup(svg) # nosec B704
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
return dict(include_svg=include_svg)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def reload_config_in_dev():
|
||||
"""Reload config and recache icons before each request in development mode."""
|
||||
@@ -83,22 +93,22 @@ def reload_config_in_dev():
|
||||
load_config(app)
|
||||
cache_icons_and_logos(app)
|
||||
|
||||
@app.route('/')
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
"""Render the main index page."""
|
||||
cards = app.config["cards"]
|
||||
lg_classes, md_classes = compute_card_classes(cards)
|
||||
# fetch NASA APOD URL only if key present
|
||||
apod_bg = None
|
||||
api_key = app.config.get("NASA_API_KEY")
|
||||
if api_key:
|
||||
resp = requests.get(
|
||||
"https://api.nasa.gov/planetary/apod",
|
||||
params={"api_key": api_key}
|
||||
params={"api_key": api_key},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok:
|
||||
data = resp.json()
|
||||
# only use if it's an image
|
||||
if data.get("media_type") == "image":
|
||||
apod_bg = data.get("url")
|
||||
|
||||
@@ -110,8 +120,14 @@ def index():
|
||||
platform=app.config["platform"],
|
||||
lg_classes=lg_classes,
|
||||
md_classes=md_classes,
|
||||
apod_bg=apod_bg
|
||||
apod_bg=apod_bg,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=(FLASK_ENV == "development"), host="0.0.0.0", port=FLASK_PORT)
|
||||
app.run(
|
||||
debug=(FLASK_ENV == "development"),
|
||||
host=FLASK_HOST,
|
||||
port=FLASK_PORT,
|
||||
use_reloader=False,
|
||||
)
|
||||
|
||||
@@ -15,7 +15,7 @@ describe('Navbar Logo Visibility', () => {
|
||||
|
||||
it('should become visible (opacity 1) after entering fullscreen', () => {
|
||||
cy.window().then(win => {
|
||||
win.fullscreen();
|
||||
win.enterFullscreen();
|
||||
});
|
||||
cy.get('#navbar_logo', { timeout: 4000 })
|
||||
.should('have.css', 'opacity', '1');
|
||||
@@ -23,7 +23,7 @@ describe('Navbar Logo Visibility', () => {
|
||||
|
||||
it('should become invisible again (opacity 0) after exiting fullscreen', () => {
|
||||
cy.window().then(win => {
|
||||
win.fullscreen();
|
||||
win.enterFullscreen();
|
||||
win.exitFullscreen();
|
||||
});
|
||||
cy.get('#navbar_logo', { timeout: 4000 })
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
flask
|
||||
requests
|
||||
pyyaml
|
||||
1
app/utils/__init__.py
Normal file
1
app/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Utilities used by the Portfolio UI web application."""
|
||||
@@ -1,7 +1,9 @@
|
||||
import os
|
||||
import hashlib
|
||||
import requests
|
||||
import mimetypes
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class CacheManager:
|
||||
def __init__(self, cache_dir="static/cache"):
|
||||
@@ -9,8 +11,7 @@ class CacheManager:
|
||||
self._ensure_cache_dir_exists()
|
||||
|
||||
def _ensure_cache_dir_exists(self):
|
||||
if not os.path.exists(self.cache_dir):
|
||||
os.makedirs(self.cache_dir)
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
|
||||
def clear_cache(self):
|
||||
if os.path.exists(self.cache_dir):
|
||||
@@ -20,8 +21,10 @@ class CacheManager:
|
||||
os.remove(path)
|
||||
|
||||
def cache_file(self, file_url):
|
||||
# generate a short hash for filename
|
||||
hash_suffix = hashlib.blake2s(file_url.encode('utf-8'), digest_size=8).hexdigest()
|
||||
hash_suffix = hashlib.blake2s(
|
||||
file_url.encode("utf-8"),
|
||||
digest_size=8,
|
||||
).hexdigest()
|
||||
parts = file_url.rstrip("/").split("/")
|
||||
base = parts[-2] if parts[-1] == "download" else parts[-1]
|
||||
|
||||
@@ -31,7 +34,7 @@ class CacheManager:
|
||||
except requests.RequestException:
|
||||
return None
|
||||
|
||||
content_type = resp.headers.get('Content-Type', '')
|
||||
content_type = resp.headers.get("Content-Type", "")
|
||||
ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ".png"
|
||||
filename = f"{base}_{hash_suffix}{ext}"
|
||||
full_path = os.path.join(self.cache_dir, filename)
|
||||
@@ -41,5 +44,4 @@ class CacheManager:
|
||||
for chunk in resp.iter_content(1024):
|
||||
f.write(chunk)
|
||||
|
||||
# return path relative to /static/
|
||||
return f"cache/{filename}"
|
||||
|
||||
@@ -32,7 +32,7 @@ def compute_card_classes(cards):
|
||||
lg_classes.append("col-lg-6")
|
||||
else:
|
||||
lg_classes.append("col-lg-4")
|
||||
# md classes: If the number of cards is even or if not the last card, otherwise "col-md-12"
|
||||
# Use a full-width last card on medium screens only when the total count is odd.
|
||||
md_classes = []
|
||||
for i in range(num_cards):
|
||||
if num_cards % 2 == 0 or i < num_cards - 1:
|
||||
|
||||
@@ -13,22 +13,9 @@ class ConfigurationResolver:
|
||||
"""
|
||||
self._recursive_resolve(self.config, self.config)
|
||||
|
||||
def __load_children(self,path):
|
||||
"""
|
||||
Check if explicitly children should be loaded and not parent
|
||||
"""
|
||||
return path.split('.').pop() == "children"
|
||||
|
||||
def _replace_in_dict_by_dict(self, dict_origine, old_key, new_dict):
|
||||
if old_key in dict_origine:
|
||||
# Entferne den alten Key
|
||||
old_value = dict_origine.pop(old_key)
|
||||
# Füge die neuen Key-Value-Paare hinzu
|
||||
dict_origine.update(new_dict)
|
||||
|
||||
def _replace_in_list_by_list(self, list_origine, old_element, new_elements):
|
||||
index = list_origine.index(old_element)
|
||||
list_origine[index:index+1] = new_elements
|
||||
list_origine[index : index + 1] = new_elements
|
||||
|
||||
def _replace_element_in_list(self, list_origine, old_element, new_element):
|
||||
index = list_origine.index(old_element)
|
||||
@@ -42,27 +29,43 @@ class ConfigurationResolver:
|
||||
for key, value in list(current_config.items()):
|
||||
if key == "children":
|
||||
if value is None or not isinstance(value, list):
|
||||
raise ValueError(f"Expected 'children' to be a list, but got {type(value).__name__} instead.")
|
||||
raise ValueError(
|
||||
"Expected 'children' to be a list, but got "
|
||||
f"{type(value).__name__} instead."
|
||||
)
|
||||
for item in value:
|
||||
if "link" in item:
|
||||
loaded_link = self._find_entry(root_config, self._mapped_key(item['link']), False)
|
||||
loaded_link = self._find_entry(
|
||||
root_config,
|
||||
self._mapped_key(item["link"]),
|
||||
False,
|
||||
)
|
||||
if isinstance(loaded_link, list):
|
||||
self._replace_in_list_by_list(value,item,loaded_link)
|
||||
self._replace_in_list_by_list(value, item, loaded_link)
|
||||
else:
|
||||
self._replace_element_in_list(value,item,loaded_link)
|
||||
self._replace_element_in_list(value, item, loaded_link)
|
||||
else:
|
||||
self._recursive_resolve(value, root_config)
|
||||
self._recursive_resolve(value, root_config)
|
||||
elif key == "link":
|
||||
try:
|
||||
loaded = self._find_entry(root_config, self._mapped_key(value), False)
|
||||
loaded = self._find_entry(
|
||||
root_config, self._mapped_key(value), False
|
||||
)
|
||||
if isinstance(loaded, list) and len(loaded) > 2:
|
||||
loaded = self._find_entry(root_config, self._mapped_key(value), False)
|
||||
loaded = self._find_entry(
|
||||
root_config, self._mapped_key(value), False
|
||||
)
|
||||
current_config.clear()
|
||||
current_config.update(loaded)
|
||||
except Exception as e:
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Error resolving link '{value}': {str(e)}. "
|
||||
f"Current part: {key}, Current config: {current_config}" + (f", Loaded: {loaded}" if 'loaded' in locals() or 'loaded' in globals() else "")
|
||||
f"Current part: {key}, Current config: {current_config}"
|
||||
+ (
|
||||
f", Loaded: {loaded}"
|
||||
if "loaded" in locals() or "loaded" in globals()
|
||||
else ""
|
||||
)
|
||||
)
|
||||
else:
|
||||
self._recursive_resolve(value, root_config)
|
||||
@@ -70,69 +73,74 @@ class ConfigurationResolver:
|
||||
for item in current_config:
|
||||
self._recursive_resolve(item, root_config)
|
||||
|
||||
def _get_children(self,current):
|
||||
if isinstance(current, dict) and ("children" in current and current["children"]):
|
||||
def _get_children(self, current):
|
||||
if isinstance(current, dict) and (
|
||||
"children" in current and current["children"]
|
||||
):
|
||||
current = current["children"]
|
||||
return current
|
||||
|
||||
def _mapped_key(self,name):
|
||||
def _mapped_key(self, name):
|
||||
return name.replace(" ", "").lower()
|
||||
|
||||
def _find_by_name(self,current, part):
|
||||
|
||||
def _find_by_name(self, current, part):
|
||||
return next(
|
||||
(item for item in current if isinstance(item, dict) and self._mapped_key(item.get("name", "")) == part),
|
||||
None
|
||||
)
|
||||
(
|
||||
item
|
||||
for item in current
|
||||
if isinstance(item, dict)
|
||||
and self._mapped_key(item.get("name", "")) == part
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _find_entry(self, config, path, children):
|
||||
"""
|
||||
Finds an entry in the configuration by a dot-separated path.
|
||||
Supports both dictionaries and lists with `children` navigation.
|
||||
"""
|
||||
parts = path.split('.')
|
||||
parts = path.split(".")
|
||||
current = config
|
||||
for part in parts:
|
||||
if isinstance(current, list):
|
||||
# If children explicit declared just load children
|
||||
if part != "children":
|
||||
# Look for a matching name in the list
|
||||
found = self._find_by_name(current,part)
|
||||
found = self._find_by_name(current, part)
|
||||
if found:
|
||||
current = found
|
||||
print(
|
||||
f"Matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
|
||||
f"Matching entry for '{part}' in list. Path so far: "
|
||||
f"{' > '.join(parts[: parts.index(part) + 1])}. "
|
||||
f"Current list: {current}"
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"No matching entry for '{part}' in list. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
|
||||
f"No matching entry for '{part}' in list. Path so far: "
|
||||
f"{' > '.join(parts[: parts.index(part) + 1])}. "
|
||||
f"Current list: {current}"
|
||||
)
|
||||
elif isinstance(current, dict):
|
||||
# Case-insensitive dictionary lookup
|
||||
key = next((k for k in current if self._mapped_key(k) == part), None)
|
||||
# If no fitting key was found search in the children
|
||||
if key is None:
|
||||
if "children" not in current:
|
||||
raise KeyError(
|
||||
f"No 'children' found in current dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
|
||||
f"Current dictionary: {current}"
|
||||
)
|
||||
# The following line seems buggy; Why is children loaded allways and not just when children is set?
|
||||
current = self._find_by_name(current["children"],part)
|
||||
|
||||
if not current:
|
||||
raise KeyError(
|
||||
f"Key '{part}' not found in dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. "
|
||||
"No 'children' found in current dictionary. Path so far: "
|
||||
f"{' > '.join(parts[: parts.index(part) + 1])}. "
|
||||
f"Current dictionary: {current}"
|
||||
)
|
||||
else:
|
||||
current = self._find_by_name(current["children"], part)
|
||||
|
||||
if not current:
|
||||
raise KeyError(
|
||||
f"Key '{part}' not found in dictionary. Path so far: "
|
||||
f"{' > '.join(parts[: parts.index(part) + 1])}. "
|
||||
f"Current dictionary: {current}"
|
||||
)
|
||||
else:
|
||||
current = current[key]
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid path segment '{part}'. Current type: {type(current)}. "
|
||||
f"Path so far: {' > '.join(parts[:parts.index(part)+1])}"
|
||||
f"Path so far: {' > '.join(parts[: parts.index(part) + 1])}"
|
||||
)
|
||||
if children:
|
||||
current = self._get_children(current)
|
||||
|
||||
Reference in New Issue
Block a user