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:
68
.github/workflows/ci.yml
vendored
68
.github/workflows/ci.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- "**"
|
||||
@@ -9,59 +10,48 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
test-and-publish:
|
||||
security:
|
||||
name: Run security workflow
|
||||
uses: ./.github/workflows/security.yml
|
||||
|
||||
tests:
|
||||
name: Run test workflow
|
||||
uses: ./.github/workflows/tests.yml
|
||||
|
||||
lint:
|
||||
name: Run lint workflow
|
||||
uses: ./.github/workflows/lint.yml
|
||||
|
||||
publish:
|
||||
name: Publish image
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PORT: "5000"
|
||||
needs:
|
||||
- security
|
||||
- tests
|
||||
- lint
|
||||
if: github.event_name == 'push'
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install -r app/requirements.txt
|
||||
|
||||
- name: Prepare app config for CI
|
||||
run: cp app/config.sample.yaml app/config.yaml
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: app/package.json
|
||||
|
||||
- name: Install Node dependencies
|
||||
working-directory: app
|
||||
run: npm install
|
||||
|
||||
- name: Run Cypress tests
|
||||
uses: cypress-io/github-action@v6
|
||||
with:
|
||||
working-directory: app
|
||||
install: false
|
||||
start: python app.py
|
||||
wait-on: http://127.0.0.1:5000
|
||||
wait-on-timeout: 120
|
||||
|
||||
- name: Detect semver tag on current commit
|
||||
id: semver
|
||||
run: |
|
||||
SEMVER_TAG="$(git tag --points-at "$GITHUB_SHA" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)"
|
||||
if [ -n "$SEMVER_TAG" ]; then
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
echo "raw_tag=$SEMVER_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${SEMVER_TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "found=true"
|
||||
echo "raw_tag=$SEMVER_TAG"
|
||||
echo "version=${SEMVER_TAG#v}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
77
.github/workflows/lint.yml
vendored
Normal file
77
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,77 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-actions:
|
||||
name: Lint GitHub Actions
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run actionlint
|
||||
run: docker run --rm -v "$PWD:/repo" -w /repo rhysd/actionlint:latest
|
||||
|
||||
lint-python:
|
||||
name: Lint Python
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install lint dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install ".[dev]"
|
||||
|
||||
- name: Ruff lint
|
||||
run: ruff check .
|
||||
|
||||
- name: Ruff format check
|
||||
run: ruff format --check .
|
||||
|
||||
lint-docker:
|
||||
name: Lint Dockerfile
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Run hadolint
|
||||
id: hadolint
|
||||
continue-on-error: true
|
||||
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
|
||||
with:
|
||||
dockerfile: ./Dockerfile
|
||||
format: sarif
|
||||
output-file: hadolint-results.sarif
|
||||
failure-threshold: warning
|
||||
|
||||
- name: Upload hadolint SARIF
|
||||
if: always() && github.event_name == 'push'
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: hadolint-results.sarif
|
||||
wait-for-processing: true
|
||||
category: hadolint
|
||||
|
||||
- name: Fail on hadolint warnings
|
||||
if: always()
|
||||
run: python3 utils/check_hadolint_sarif.py hadolint-results.sarif
|
||||
48
.github/workflows/security.yml
vendored
Normal file
48
.github/workflows/security.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: Security
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Run security scan
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
security-events: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
- language: python
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
queries: security-extended,security-and-quality
|
||||
|
||||
- name: Run manual build steps
|
||||
if: matrix.build-mode == 'manual'
|
||||
run: |
|
||||
echo "No manual build is configured for this repository."
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
with:
|
||||
category: /language:${{ matrix.language }}
|
||||
194
.github/workflows/tests.yml
vendored
Normal file
194
.github/workflows/tests.yml
vendored
Normal file
@@ -0,0 +1,194 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-lint:
|
||||
name: Run lint tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Run lint test suite
|
||||
run: python -m unittest discover -s tests/lint -t .
|
||||
|
||||
test-integration:
|
||||
name: Run integration tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install integration test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install --ignore-installed .
|
||||
|
||||
- name: Run integration test suite
|
||||
run: python -m unittest discover -s tests/integration -t .
|
||||
|
||||
test-unit:
|
||||
name: Run unit tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install unit test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install --ignore-installed .
|
||||
|
||||
- name: Run unit test suite
|
||||
run: python -m unittest discover -s tests/unit -t .
|
||||
|
||||
security-python:
|
||||
name: Run Python security checks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install security dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install --ignore-installed ".[dev]"
|
||||
|
||||
- name: Run Bandit
|
||||
run: python -m bandit -q -ll -ii -r app main.py
|
||||
|
||||
- name: Export runtime requirements
|
||||
run: python utils/export_runtime_requirements.py > runtime-requirements.txt
|
||||
|
||||
- name: Audit Python runtime dependencies
|
||||
run: python -m pip_audit -r runtime-requirements.txt
|
||||
|
||||
test-security:
|
||||
name: Run security guardrail tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install security test dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install --ignore-installed .
|
||||
|
||||
- name: Run security test suite
|
||||
run: python -m unittest discover -s tests/security -t .
|
||||
|
||||
e2e:
|
||||
name: Run end-to-end tests
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test-lint
|
||||
- test-unit
|
||||
- test-integration
|
||||
- security-python
|
||||
- test-security
|
||||
env:
|
||||
FLASK_HOST: "127.0.0.1"
|
||||
FLASK_PORT: "5001"
|
||||
PORT: "5001"
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install --ignore-installed .
|
||||
|
||||
- name: Prepare app config for CI
|
||||
run: cp app/config.sample.yaml app/config.yaml
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: app/package.json
|
||||
|
||||
- name: Install Node dependencies
|
||||
working-directory: app
|
||||
run: npm install
|
||||
|
||||
- name: Install Cypress system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libasound2t64 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libatspi2.0-0t64 \
|
||||
libcups2t64 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libglib2.0-0t64 \
|
||||
libgtk-3-0t64 \
|
||||
libnotify4 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxkbcommon0 \
|
||||
libxrandr2 \
|
||||
libxss1 \
|
||||
libxtst6 \
|
||||
xauth \
|
||||
xvfb
|
||||
|
||||
- name: Run Cypress tests
|
||||
uses: cypress-io/github-action@v6
|
||||
with:
|
||||
working-directory: app
|
||||
install: false
|
||||
start: python app.py
|
||||
wait-on: http://127.0.0.1:5001
|
||||
wait-on-timeout: 120
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -2,4 +2,10 @@ app/config.yaml
|
||||
*__pycache__*
|
||||
app/static/cache/*
|
||||
.env
|
||||
app/cypress/screenshots/*
|
||||
app/cypress/screenshots/*
|
||||
.ruff_cache/
|
||||
app/node_modules/
|
||||
hadolint-results.sarif
|
||||
build/
|
||||
*.egg-info/
|
||||
app/core.*
|
||||
|
||||
20
Dockerfile
20
Dockerfile
@@ -1,14 +1,16 @@
|
||||
# Base image for Python
|
||||
FROM python:slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
FLASK_HOST=0.0.0.0
|
||||
|
||||
WORKDIR /tmp/build
|
||||
|
||||
COPY pyproject.toml README.md main.py ./
|
||||
COPY app ./app
|
||||
RUN python -m pip install --no-cache-dir .
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy and install dependencies
|
||||
COPY app/requirements.txt requirements.txt
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY app/ .
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
|
||||
2
MIRRORS
Normal file
2
MIRRORS
Normal file
@@ -0,0 +1,2 @@
|
||||
https://pypi.org/project/portfolio-ui/
|
||||
git@github.com:kevinveenbirkenbach/port-ui.git
|
||||
98
Makefile
98
Makefile
@@ -7,6 +7,8 @@ endif
|
||||
|
||||
# Default port (can be overridden with PORT env var)
|
||||
PORT ?= 5000
|
||||
PYTHON ?= python3
|
||||
ACT ?= act
|
||||
|
||||
# Default port (can be overridden with PORT env var)
|
||||
.PHONY: build
|
||||
@@ -14,10 +16,15 @@ build:
|
||||
# Build the Docker image.
|
||||
docker build -t application-portfolio .
|
||||
|
||||
.PHONY: build-no-cache
|
||||
build-no-cache:
|
||||
# Build the Docker image without cache.
|
||||
docker build --no-cache -t application-portfolio .
|
||||
|
||||
.PHONY: up
|
||||
up:
|
||||
# Start the application using docker-compose with build.
|
||||
docker-compose up -d --build
|
||||
docker-compose up -d --build --force-recreate
|
||||
|
||||
.PHONY: down
|
||||
down:
|
||||
@@ -75,8 +82,93 @@ browse:
|
||||
# Open the application in the browser at http://localhost:$(PORT)
|
||||
chromium http://localhost:$(PORT)
|
||||
|
||||
.PHONY: install
|
||||
install:
|
||||
# Install runtime Python dependencies from pyproject.toml.
|
||||
$(PYTHON) -m pip install -e .
|
||||
|
||||
.PHONY: install-dev
|
||||
install-dev:
|
||||
# Install runtime and developer dependencies from pyproject.toml.
|
||||
$(PYTHON) -m pip install -e ".[dev]"
|
||||
|
||||
.PHONY: npm-install
|
||||
npm-install:
|
||||
# Install Node.js dependencies for browser tests.
|
||||
cd app && npm install
|
||||
|
||||
test: npm-install
|
||||
cd app && npx cypress run --spec "cypress/e2e/**/*.spec.js"
|
||||
.PHONY: lint-actions
|
||||
lint-actions:
|
||||
# Lint GitHub Actions workflows.
|
||||
docker run --rm -v "$$PWD:/repo" -w /repo rhysd/actionlint:latest
|
||||
|
||||
.PHONY: lint-python
|
||||
lint-python: install-dev
|
||||
# Run Python lint and format checks.
|
||||
$(PYTHON) -m ruff check .
|
||||
$(PYTHON) -m ruff format --check .
|
||||
|
||||
.PHONY: lint-docker
|
||||
lint-docker:
|
||||
# Lint the Dockerfile.
|
||||
docker run --rm -i hadolint/hadolint < Dockerfile
|
||||
|
||||
.PHONY: test-lint
|
||||
test-lint:
|
||||
# Run lint guardrail tests.
|
||||
$(PYTHON) -m unittest discover -s tests/lint -t .
|
||||
|
||||
.PHONY: test-integration
|
||||
test-integration: install
|
||||
# Run repository integration tests.
|
||||
$(PYTHON) -m unittest discover -s tests/integration -t .
|
||||
|
||||
.PHONY: test-unit
|
||||
test-unit: install
|
||||
# Run repository unit tests.
|
||||
$(PYTHON) -m unittest discover -s tests/unit -t .
|
||||
|
||||
.PHONY: test-security
|
||||
test-security: install
|
||||
# Run repository security guardrail tests.
|
||||
$(PYTHON) -m unittest discover -s tests/security -t .
|
||||
|
||||
.PHONY: lint
|
||||
lint: lint-actions lint-python lint-docker test-lint
|
||||
# Run the full lint suite.
|
||||
|
||||
.PHONY: security
|
||||
security: install-dev test-security
|
||||
# Run security checks.
|
||||
$(PYTHON) -m bandit -q -ll -ii -r app main.py
|
||||
$(PYTHON) utils/export_runtime_requirements.py > /tmp/portfolio-runtime-requirements.txt
|
||||
$(PYTHON) -m pip_audit -r /tmp/portfolio-runtime-requirements.txt
|
||||
|
||||
.PHONY: test-e2e
|
||||
test-e2e:
|
||||
# Run Cypress end-to-end tests via act (stop portfolio container to free port first).
|
||||
-docker stop portfolio 2>/dev/null || true
|
||||
$(ACT) workflow_dispatch -W .github/workflows/tests.yml -j e2e
|
||||
-docker start portfolio 2>/dev/null || true
|
||||
|
||||
.PHONY: test-workflow
|
||||
test-workflow:
|
||||
# Run the GitHub test workflow locally via act.
|
||||
$(ACT) workflow_dispatch -W .github/workflows/tests.yml
|
||||
|
||||
.PHONY: lint-workflow
|
||||
lint-workflow:
|
||||
# Run the GitHub lint workflow locally via act.
|
||||
$(ACT) workflow_dispatch -W .github/workflows/lint.yml
|
||||
|
||||
.PHONY: quality
|
||||
quality: lint-workflow test-workflow
|
||||
# Run the GitHub lint and test workflows locally via act.
|
||||
|
||||
.PHONY: ci
|
||||
ci: lint security test-unit test-integration test-e2e
|
||||
# Run the local CI suite.
|
||||
|
||||
.PHONY: test
|
||||
test: ci
|
||||
# Run the full validation suite.
|
||||
|
||||
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)
|
||||
|
||||
28
main.py
28
main.py
@@ -3,10 +3,11 @@
|
||||
main.py - Proxy to Makefile targets for managing the Portfolio CMS Docker application.
|
||||
Automatically generates CLI commands based on the Makefile definitions.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
MAKEFILE_PATH = Path(__file__).resolve().parent / "Makefile"
|
||||
@@ -20,16 +21,17 @@ def load_targets(makefile_path):
|
||||
"""
|
||||
targets = []
|
||||
pattern = re.compile(r"^([A-Za-z0-9_\-]+):")
|
||||
with open(makefile_path, 'r') as f:
|
||||
lines = f.readlines()
|
||||
with open(makefile_path, "r", encoding="utf-8") as handle:
|
||||
lines = handle.readlines()
|
||||
for idx, line in enumerate(lines):
|
||||
m = pattern.match(line)
|
||||
if m:
|
||||
name = m.group(1)
|
||||
help_text = ''
|
||||
# look for next non-empty line
|
||||
if idx + 1 < len(lines) and lines[idx+1].lstrip().startswith('#'):
|
||||
help_text = lines[idx+1].lstrip('# ').strip()
|
||||
help_text = ""
|
||||
if idx + 1 < len(lines):
|
||||
next_line = lines[idx + 1].lstrip()
|
||||
if next_line.startswith("#"):
|
||||
help_text = next_line.lstrip("# ").strip()
|
||||
targets.append((name, help_text))
|
||||
return targets
|
||||
|
||||
@@ -54,13 +56,13 @@ def main():
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Print the generated Make command without executing it."
|
||||
help="Print the generated Make command without executing it.",
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(
|
||||
title="Available commands",
|
||||
dest="command",
|
||||
required=True
|
||||
required=True,
|
||||
)
|
||||
|
||||
targets = load_targets(MAKEFILE_PATH)
|
||||
@@ -69,15 +71,9 @@ def main():
|
||||
sp.set_defaults(target=name)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
cmd = ["make", args.target]
|
||||
run_command(cmd, dry_run=args.dry_run)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
from pathlib import Path
|
||||
main()
|
||||
main()
|
||||
|
||||
44
pyproject.toml
Normal file
44
pyproject.toml
Normal file
@@ -0,0 +1,44 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=69"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portfolio-ui"
|
||||
version = "0.0.0"
|
||||
description = "A lightweight YAML-driven portfolio and landing-page generator."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"flask",
|
||||
"pyyaml",
|
||||
"requests",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"bandit",
|
||||
"pip-audit",
|
||||
"ruff",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["main"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["app", "app.*"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
app = [
|
||||
"config.sample.yaml",
|
||||
"templates/**/*.j2",
|
||||
"static/css/*.css",
|
||||
"static/js/*.js",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
line-length = 88
|
||||
extend-exclude = ["app/static/cache", "build"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I"]
|
||||
@@ -1 +0,0 @@
|
||||
python-dotenv
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
tests/integration/__init__.py
Normal file
1
tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
54
tests/integration/test_python_packaging.py
Normal file
54
tests/integration/test_python_packaging.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import tomllib
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestPythonPackaging(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.repo_root = Path(__file__).resolve().parents[2]
|
||||
self.pyproject_path = self.repo_root / "pyproject.toml"
|
||||
|
||||
with self.pyproject_path.open("rb") as handle:
|
||||
self.pyproject = tomllib.load(handle)
|
||||
|
||||
def test_pyproject_defines_build_system_and_runtime_dependencies(self):
|
||||
build_system = self.pyproject["build-system"]
|
||||
project = self.pyproject["project"]
|
||||
|
||||
self.assertEqual(build_system["build-backend"], "setuptools.build_meta")
|
||||
self.assertIn("setuptools>=69", build_system["requires"])
|
||||
self.assertGreaterEqual(
|
||||
set(project["dependencies"]),
|
||||
{"flask", "pyyaml", "requests"},
|
||||
)
|
||||
self.assertEqual(project["requires-python"], ">=3.12")
|
||||
|
||||
def test_pyproject_defines_dev_dependencies_and_package_contents(self):
|
||||
project = self.pyproject["project"]
|
||||
setuptools_config = self.pyproject["tool"]["setuptools"]
|
||||
package_find = setuptools_config["packages"]["find"]
|
||||
package_data = setuptools_config["package-data"]["app"]
|
||||
|
||||
self.assertGreaterEqual(
|
||||
set(project["optional-dependencies"]["dev"]),
|
||||
{"bandit", "pip-audit", "ruff"},
|
||||
)
|
||||
self.assertEqual(setuptools_config["py-modules"], ["main"])
|
||||
self.assertEqual(package_find["include"], ["app", "app.*"])
|
||||
self.assertIn("config.sample.yaml", package_data)
|
||||
self.assertIn("templates/**/*.j2", package_data)
|
||||
self.assertIn("static/css/*.css", package_data)
|
||||
self.assertIn("static/js/*.js", package_data)
|
||||
|
||||
def test_legacy_requirements_files_are_removed(self):
|
||||
self.assertFalse((self.repo_root / "requirements.txt").exists())
|
||||
self.assertFalse((self.repo_root / "requirements-dev.txt").exists())
|
||||
self.assertFalse((self.repo_root / "app" / "requirements.txt").exists())
|
||||
|
||||
def test_package_init_files_exist(self):
|
||||
self.assertTrue((self.repo_root / "app" / "__init__.py").is_file())
|
||||
self.assertTrue((self.repo_root / "app" / "utils" / "__init__.py").is_file())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
43
tests/integration/test_yaml_syntax.py
Normal file
43
tests/integration/test_yaml_syntax.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
SKIP_DIR_NAMES = {".git", ".ruff_cache", "__pycache__", "node_modules"}
|
||||
SKIP_FILES = {"app/config.yaml"}
|
||||
YAML_SUFFIXES = {".yml", ".yaml"}
|
||||
|
||||
|
||||
class TestYamlSyntax(unittest.TestCase):
|
||||
def test_all_repository_yaml_files_are_valid(self):
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
invalid_files = []
|
||||
|
||||
for path in repo_root.rglob("*"):
|
||||
if not path.is_file() or path.suffix not in YAML_SUFFIXES:
|
||||
continue
|
||||
|
||||
relative_path = path.relative_to(repo_root).as_posix()
|
||||
if relative_path in SKIP_FILES:
|
||||
continue
|
||||
|
||||
if any(part in SKIP_DIR_NAMES for part in path.parts):
|
||||
continue
|
||||
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
yaml.safe_load(handle)
|
||||
except yaml.YAMLError as error:
|
||||
invalid_files.append((relative_path, str(error).splitlines()[0]))
|
||||
except Exception as error:
|
||||
invalid_files.append((relative_path, f"Unexpected error: {error}"))
|
||||
|
||||
self.assertFalse(
|
||||
invalid_files,
|
||||
"Found invalid YAML files:\n"
|
||||
+ "\n".join(f"- {path}: {error}" for path, error in invalid_files),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1
tests/lint/__init__.py
Normal file
1
tests/lint/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
90
tests/lint/test_all_test_files_have_tests.py
Normal file
90
tests/lint/test_all_test_files_have_tests.py
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
import ast
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestTestFilesContainUnittestTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.repo_root = Path(__file__).resolve().parents[2]
|
||||
self.tests_dir = self.repo_root / "tests"
|
||||
self.assertTrue(
|
||||
self.tests_dir.is_dir(),
|
||||
f"'tests' directory not found at: {self.tests_dir}",
|
||||
)
|
||||
|
||||
def _iter_test_files(self) -> list[Path]:
|
||||
return sorted(self.tests_dir.rglob("test_*.py"))
|
||||
|
||||
def _file_contains_runnable_unittest_test(self, path: Path) -> bool:
|
||||
source = path.read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
tree = ast.parse(source, filename=str(path))
|
||||
except SyntaxError as error:
|
||||
raise AssertionError(f"SyntaxError in {path}: {error}") from error
|
||||
|
||||
testcase_aliases = {"TestCase"}
|
||||
unittest_aliases = {"unittest"}
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Import):
|
||||
for import_name in node.names:
|
||||
if import_name.name == "unittest":
|
||||
unittest_aliases.add(import_name.asname or "unittest")
|
||||
elif isinstance(node, ast.ImportFrom) and node.module == "unittest":
|
||||
for import_name in node.names:
|
||||
if import_name.name == "TestCase":
|
||||
testcase_aliases.add(import_name.asname or "TestCase")
|
||||
|
||||
def is_testcase_base(base: ast.expr) -> bool:
|
||||
if isinstance(base, ast.Name) and base.id in testcase_aliases:
|
||||
return True
|
||||
|
||||
if isinstance(base, ast.Attribute) and base.attr == "TestCase":
|
||||
return (
|
||||
isinstance(base.value, ast.Name)
|
||||
and base.value.id in unittest_aliases
|
||||
)
|
||||
|
||||
return False
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
|
||||
node.name.startswith("test_")
|
||||
):
|
||||
return True
|
||||
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
|
||||
if not any(is_testcase_base(base) for base in node.bases):
|
||||
continue
|
||||
|
||||
for item in node.body:
|
||||
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and (
|
||||
item.name.startswith("test_")
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def test_all_test_py_files_contain_runnable_tests(self) -> None:
|
||||
test_files = self._iter_test_files()
|
||||
self.assertTrue(test_files, "No test_*.py files found under tests/")
|
||||
|
||||
offenders = []
|
||||
for path in test_files:
|
||||
if not self._file_contains_runnable_unittest_test(path):
|
||||
offenders.append(path.relative_to(self.repo_root).as_posix())
|
||||
|
||||
self.assertFalse(
|
||||
offenders,
|
||||
"These test_*.py files do not define any unittest-runnable tests:\n"
|
||||
+ "\n".join(f"- {path}" for path in offenders),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
25
tests/lint/test_test_file_naming.py
Normal file
25
tests/lint/test_test_file_naming.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestTestFileNaming(unittest.TestCase):
|
||||
def test_all_python_files_use_test_prefix(self):
|
||||
tests_root = Path(__file__).resolve().parents[1]
|
||||
invalid_files = []
|
||||
|
||||
for path in tests_root.rglob("*.py"):
|
||||
if path.name == "__init__.py":
|
||||
continue
|
||||
|
||||
if not path.name.startswith("test_"):
|
||||
invalid_files.append(path.relative_to(tests_root).as_posix())
|
||||
|
||||
self.assertFalse(
|
||||
invalid_files,
|
||||
"The following Python files do not start with 'test_':\n"
|
||||
+ "\n".join(f"- {path}" for path in invalid_files),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1
tests/security/__init__.py
Normal file
1
tests/security/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
57
tests/security/test_config_hygiene.py
Normal file
57
tests/security/test_config_hygiene.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class TestConfigHygiene(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.repo_root = Path(__file__).resolve().parents[2]
|
||||
self.sample_config_path = self.repo_root / "app" / "config.sample.yaml"
|
||||
|
||||
def _is_tracked(self, path: str) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "ls-files", "--error-unmatch", path],
|
||||
cwd=self.repo_root,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
def _find_values_for_key(self, data, key_name: str):
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
if key == key_name:
|
||||
yield value
|
||||
yield from self._find_values_for_key(value, key_name)
|
||||
elif isinstance(data, list):
|
||||
for item in data:
|
||||
yield from self._find_values_for_key(item, key_name)
|
||||
|
||||
def test_runtime_only_files_are_ignored_and_untracked(self):
|
||||
gitignore_lines = (
|
||||
(self.repo_root / ".gitignore").read_text(encoding="utf-8").splitlines()
|
||||
)
|
||||
|
||||
self.assertIn("app/config.yaml", gitignore_lines)
|
||||
self.assertIn(".env", gitignore_lines)
|
||||
self.assertFalse(self._is_tracked("app/config.yaml"))
|
||||
self.assertFalse(self._is_tracked(".env"))
|
||||
|
||||
def test_sample_config_keeps_the_nasa_api_key_placeholder(self):
|
||||
with self.sample_config_path.open("r", encoding="utf-8") as handle:
|
||||
sample_config = yaml.safe_load(handle)
|
||||
|
||||
nasa_api_keys = list(self._find_values_for_key(sample_config, "nasa_api_key"))
|
||||
self.assertEqual(
|
||||
nasa_api_keys,
|
||||
["YOUR_REAL_KEY_HERE"],
|
||||
"config.sample.yaml should only contain the documented NASA API key "
|
||||
"placeholder.",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
43
tests/security/test_sample_config_urls.py
Normal file
43
tests/security/test_sample_config_urls.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ALLOWED_URL_PREFIXES = ("https://", "mailto:", "tel:")
|
||||
URL_KEYS = {"url", "imprint", "imprint_url"}
|
||||
|
||||
|
||||
class TestSampleConfigUrls(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
sample_config_path = repo_root / "app" / "config.sample.yaml"
|
||||
with sample_config_path.open("r", encoding="utf-8") as handle:
|
||||
self.sample_config = yaml.safe_load(handle)
|
||||
|
||||
def _iter_urls(self, data, path="root"):
|
||||
if isinstance(data, dict):
|
||||
for key, value in data.items():
|
||||
next_path = f"{path}.{key}"
|
||||
if key in URL_KEYS and isinstance(value, str):
|
||||
yield next_path, value
|
||||
yield from self._iter_urls(value, next_path)
|
||||
elif isinstance(data, list):
|
||||
for index, item in enumerate(data):
|
||||
yield from self._iter_urls(item, f"{path}[{index}]")
|
||||
|
||||
def test_sample_config_urls_use_safe_schemes(self):
|
||||
invalid_urls = [
|
||||
f"{path} -> {url}"
|
||||
for path, url in self._iter_urls(self.sample_config)
|
||||
if not url.startswith(ALLOWED_URL_PREFIXES)
|
||||
]
|
||||
|
||||
self.assertFalse(
|
||||
invalid_urls,
|
||||
"The sample config contains URLs with unsupported schemes:\n"
|
||||
+ "\n".join(f"- {entry}" for entry in invalid_urls),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
1
tests/unit/__init__.py
Normal file
1
tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unit test package for Portfolio UI."""
|
||||
72
tests/unit/test_cache_manager.py
Normal file
72
tests/unit/test_cache_manager.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import requests
|
||||
|
||||
from app.utils.cache_manager import CacheManager
|
||||
|
||||
|
||||
class TestCacheManager(unittest.TestCase):
|
||||
def test_init_creates_cache_directory(self):
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
cache_dir = Path(temp_dir) / "cache"
|
||||
|
||||
self.assertFalse(cache_dir.exists())
|
||||
|
||||
CacheManager(str(cache_dir))
|
||||
|
||||
self.assertTrue(cache_dir.is_dir())
|
||||
|
||||
def test_clear_cache_removes_files_but_keeps_subdirectories(self):
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
cache_dir = Path(temp_dir) / "cache"
|
||||
nested_dir = cache_dir / "nested"
|
||||
nested_dir.mkdir(parents=True)
|
||||
file_path = cache_dir / "icon.png"
|
||||
file_path.write_bytes(b"icon")
|
||||
|
||||
manager = CacheManager(str(cache_dir))
|
||||
manager.clear_cache()
|
||||
|
||||
self.assertFalse(file_path.exists())
|
||||
self.assertTrue(nested_dir.is_dir())
|
||||
|
||||
@patch("app.utils.cache_manager.requests.get")
|
||||
def test_cache_file_downloads_and_stores_response(self, mock_get):
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
manager = CacheManager(str(Path(temp_dir) / "cache"))
|
||||
response = Mock()
|
||||
response.headers = {"Content-Type": "image/svg+xml; charset=utf-8"}
|
||||
response.iter_content.return_value = [b"<svg>ok</svg>"]
|
||||
response.raise_for_status.return_value = None
|
||||
mock_get.return_value = response
|
||||
|
||||
cached_path = manager.cache_file("https://example.com/logo/download")
|
||||
|
||||
self.assertIsNotNone(cached_path)
|
||||
self.assertTrue(cached_path.startswith("cache/logo_"))
|
||||
self.assertTrue(cached_path.endswith(".svg"))
|
||||
|
||||
stored_file = Path(manager.cache_dir) / Path(cached_path).name
|
||||
self.assertEqual(stored_file.read_bytes(), b"<svg>ok</svg>")
|
||||
mock_get.assert_called_once_with(
|
||||
"https://example.com/logo/download",
|
||||
stream=True,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
@patch("app.utils.cache_manager.requests.get")
|
||||
def test_cache_file_returns_none_when_request_fails(self, mock_get):
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
manager = CacheManager(str(Path(temp_dir) / "cache"))
|
||||
mock_get.side_effect = requests.RequestException("network")
|
||||
|
||||
cached_path = manager.cache_file("https://example.com/icon.png")
|
||||
|
||||
self.assertIsNone(cached_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
49
tests/unit/test_check_hadolint_sarif.py
Normal file
49
tests/unit/test_check_hadolint_sarif.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from utils import check_hadolint_sarif
|
||||
|
||||
|
||||
class TestCheckHadolintSarif(unittest.TestCase):
|
||||
def test_main_returns_zero_for_clean_sarif(self):
|
||||
sarif_payload = {
|
||||
"runs": [
|
||||
{
|
||||
"results": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
sarif_path = Path(temp_dir) / "clean.sarif"
|
||||
sarif_path.write_text(json.dumps(sarif_payload), encoding="utf-8")
|
||||
|
||||
exit_code = check_hadolint_sarif.main([str(sarif_path)])
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
|
||||
def test_main_returns_one_for_warnings_or_errors(self):
|
||||
sarif_payload = {
|
||||
"runs": [
|
||||
{
|
||||
"results": [
|
||||
{"level": "warning"},
|
||||
{"level": "error"},
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
sarif_path = Path(temp_dir) / "warnings.sarif"
|
||||
sarif_path.write_text(json.dumps(sarif_payload), encoding="utf-8")
|
||||
|
||||
exit_code = check_hadolint_sarif.main([str(sarif_path)])
|
||||
|
||||
self.assertEqual(exit_code, 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
39
tests/unit/test_compute_card_classes.py
Normal file
39
tests/unit/test_compute_card_classes.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import unittest
|
||||
|
||||
from app.utils.compute_card_classes import compute_card_classes
|
||||
|
||||
|
||||
class TestComputeCardClasses(unittest.TestCase):
|
||||
def test_single_card_uses_full_width_classes(self):
|
||||
lg_classes, md_classes = compute_card_classes([{"title": "One"}])
|
||||
|
||||
self.assertEqual(lg_classes, ["col-lg-12"])
|
||||
self.assertEqual(md_classes, ["col-md-12"])
|
||||
|
||||
def test_two_cards_split_evenly(self):
|
||||
lg_classes, md_classes = compute_card_classes([{}, {}])
|
||||
|
||||
self.assertEqual(lg_classes, ["col-lg-6", "col-lg-6"])
|
||||
self.assertEqual(md_classes, ["col-md-6", "col-md-6"])
|
||||
|
||||
def test_three_cards_use_thirds(self):
|
||||
lg_classes, md_classes = compute_card_classes([{}, {}, {}])
|
||||
|
||||
self.assertEqual(lg_classes, ["col-lg-4", "col-lg-4", "col-lg-4"])
|
||||
self.assertEqual(md_classes, ["col-md-6", "col-md-6", "col-md-12"])
|
||||
|
||||
def test_five_cards_use_balanced_large_layout(self):
|
||||
lg_classes, md_classes = compute_card_classes([{}, {}, {}, {}, {}])
|
||||
|
||||
self.assertEqual(
|
||||
lg_classes,
|
||||
["col-lg-6", "col-lg-6", "col-lg-4", "col-lg-4", "col-lg-4"],
|
||||
)
|
||||
self.assertEqual(
|
||||
md_classes,
|
||||
["col-md-6", "col-md-6", "col-md-6", "col-md-6", "col-md-12"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
74
tests/unit/test_configuration_resolver.py
Normal file
74
tests/unit/test_configuration_resolver.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import unittest
|
||||
|
||||
from app.utils.configuration_resolver import ConfigurationResolver
|
||||
|
||||
|
||||
class TestConfigurationResolver(unittest.TestCase):
|
||||
def test_resolve_links_replaces_mapping_link_with_target_object(self):
|
||||
config = {
|
||||
"profiles": [
|
||||
{"name": "Mastodon", "url": "https://example.com/@user"},
|
||||
],
|
||||
"featured": {"link": "profiles.mastodon"},
|
||||
}
|
||||
|
||||
resolver = ConfigurationResolver(config)
|
||||
resolver.resolve_links()
|
||||
|
||||
self.assertEqual(
|
||||
resolver.get_config()["featured"],
|
||||
{"name": "Mastodon", "url": "https://example.com/@user"},
|
||||
)
|
||||
|
||||
def test_resolve_links_expands_children_link_to_list_entries(self):
|
||||
config = {
|
||||
"accounts": {
|
||||
"children": [
|
||||
{"name": "Matrix", "url": "https://matrix.example"},
|
||||
{"name": "Signal", "url": "https://signal.example"},
|
||||
]
|
||||
},
|
||||
"navigation": {
|
||||
"children": [
|
||||
{"link": "accounts.children"},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
resolver = ConfigurationResolver(config)
|
||||
resolver.resolve_links()
|
||||
|
||||
self.assertEqual(
|
||||
resolver.get_config()["navigation"]["children"],
|
||||
[
|
||||
{"name": "Matrix", "url": "https://matrix.example"},
|
||||
{"name": "Signal", "url": "https://signal.example"},
|
||||
],
|
||||
)
|
||||
|
||||
def test_resolve_links_rejects_non_list_children(self):
|
||||
config = {"navigation": {"children": {"name": "Invalid"}}}
|
||||
|
||||
resolver = ConfigurationResolver(config)
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
resolver.resolve_links()
|
||||
|
||||
def test_find_entry_handles_case_and_space_insensitive_paths(self):
|
||||
config = {
|
||||
"Social Networks": {
|
||||
"children": [
|
||||
{"name": "Friendica", "url": "https://friendica.example"},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
resolver = ConfigurationResolver(config)
|
||||
|
||||
entry = resolver._find_entry(config, "socialnetworks.friendica", False)
|
||||
|
||||
self.assertEqual(entry["url"], "https://friendica.example")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
45
tests/unit/test_export_runtime_requirements.py
Normal file
45
tests/unit/test_export_runtime_requirements.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
from utils import export_runtime_requirements
|
||||
|
||||
|
||||
class TestExportRuntimeRequirements(unittest.TestCase):
|
||||
def test_load_runtime_requirements_reads_project_dependencies(self):
|
||||
pyproject_content = """
|
||||
[project]
|
||||
dependencies = [
|
||||
"flask",
|
||||
"requests>=2",
|
||||
]
|
||||
""".lstrip()
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
pyproject_path = Path(temp_dir) / "pyproject.toml"
|
||||
pyproject_path.write_text(pyproject_content, encoding="utf-8")
|
||||
|
||||
requirements = export_runtime_requirements.load_runtime_requirements(
|
||||
pyproject_path
|
||||
)
|
||||
|
||||
self.assertEqual(requirements, ["flask", "requests>=2"])
|
||||
|
||||
def test_main_prints_requirements_from_selected_pyproject(self):
|
||||
pyproject_content = """
|
||||
[project]
|
||||
dependencies = [
|
||||
"pyyaml",
|
||||
]
|
||||
""".lstrip()
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
pyproject_path = Path(temp_dir) / "pyproject.toml"
|
||||
pyproject_path.write_text(pyproject_content, encoding="utf-8")
|
||||
|
||||
with patch("builtins.print") as mock_print:
|
||||
exit_code = export_runtime_requirements.main([str(pyproject_path)])
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
mock_print.assert_called_once_with("pyyaml")
|
||||
72
tests/unit/test_main.py
Normal file
72
tests/unit/test_main.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
import main as portfolio_main
|
||||
|
||||
|
||||
class TestMainCli(unittest.TestCase):
|
||||
def test_load_targets_parses_help_comments(self):
|
||||
makefile_content = """
|
||||
.PHONY: foo bar
|
||||
foo:
|
||||
\t# Run foo
|
||||
\t@echo foo
|
||||
|
||||
bar:
|
||||
\t@echo bar
|
||||
""".lstrip()
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
makefile_path = Path(temp_dir) / "Makefile"
|
||||
makefile_path.write_text(makefile_content, encoding="utf-8")
|
||||
|
||||
targets = portfolio_main.load_targets(makefile_path)
|
||||
|
||||
self.assertEqual(targets, [("foo", "Run foo"), ("bar", "")])
|
||||
|
||||
@patch("main.subprocess.check_call")
|
||||
def test_run_command_executes_subprocess(self, mock_check_call):
|
||||
portfolio_main.run_command(["make", "lint"])
|
||||
|
||||
mock_check_call.assert_called_once_with(["make", "lint"])
|
||||
|
||||
@patch("main.sys.exit", side_effect=SystemExit(7))
|
||||
@patch(
|
||||
"main.subprocess.check_call",
|
||||
side_effect=subprocess.CalledProcessError(7, ["make", "lint"]),
|
||||
)
|
||||
def test_run_command_exits_with_subprocess_return_code(
|
||||
self,
|
||||
_mock_check_call,
|
||||
mock_sys_exit,
|
||||
):
|
||||
with self.assertRaises(SystemExit) as context:
|
||||
portfolio_main.run_command(["make", "lint"])
|
||||
|
||||
self.assertEqual(context.exception.code, 7)
|
||||
mock_sys_exit.assert_called_once_with(7)
|
||||
|
||||
@patch("main.run_command")
|
||||
@patch("main.load_targets", return_value=[("lint", "Run lint suite")])
|
||||
def test_main_dispatches_selected_target(
|
||||
self, _mock_load_targets, mock_run_command
|
||||
):
|
||||
with patch("sys.argv", ["main.py", "lint"]):
|
||||
portfolio_main.main()
|
||||
|
||||
mock_run_command.assert_called_once_with(["make", "lint"], dry_run=False)
|
||||
|
||||
@patch("main.run_command")
|
||||
@patch("main.load_targets", return_value=[("lint", "Run lint suite")])
|
||||
def test_main_passes_dry_run_flag(self, _mock_load_targets, mock_run_command):
|
||||
with patch("sys.argv", ["main.py", "--dry-run", "lint"]):
|
||||
portfolio_main.main()
|
||||
|
||||
mock_run_command.assert_called_once_with(["make", "lint"], dry_run=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
28
utils/check_hadolint_sarif.py
Normal file
28
utils/check_hadolint_sarif.py
Normal file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail when a hadolint SARIF report contains warnings or errors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = argv if argv is not None else sys.argv[1:]
|
||||
sarif_path = Path(args[0] if args else "hadolint-results.sarif")
|
||||
|
||||
with sarif_path.open("r", encoding="utf-8") as handle:
|
||||
sarif = json.load(handle)
|
||||
|
||||
results = sarif.get("runs", [{}])[0].get("results", [])
|
||||
levels = [result.get("level", "") for result in results]
|
||||
warnings = sum(1 for level in levels if level == "warning")
|
||||
errors = sum(1 for level in levels if level == "error")
|
||||
|
||||
print(f"SARIF results: total={len(results)} warnings={warnings} errors={errors}")
|
||||
return 1 if warnings + errors > 0 else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
30
utils/export_runtime_requirements.py
Normal file
30
utils/export_runtime_requirements.py
Normal file
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Print runtime dependencies from pyproject.toml, one per line."""
|
||||
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_PYPROJECT_PATH = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
||||
|
||||
|
||||
def load_runtime_requirements(
|
||||
pyproject_path: Path = DEFAULT_PYPROJECT_PATH,
|
||||
) -> list[str]:
|
||||
with pyproject_path.open("rb") as handle:
|
||||
pyproject = tomllib.load(handle)
|
||||
return list(pyproject["project"]["dependencies"])
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = argv if argv is not None else sys.argv[1:]
|
||||
pyproject_path = Path(args[0]) if args else DEFAULT_PYPROJECT_PATH
|
||||
|
||||
for requirement in load_runtime_requirements(pyproject_path):
|
||||
print(requirement)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user