12 Commits

Author SHA1 Message Date
3132aab2a5 Release version 1.1.0 2026-03-30 10:47:32 +02:00
3d1db1f8ba Updated mirrors 2026-03-30 10:46:39 +02:00
58872ced81 fix(ci): grant security-events: write to lint job
The lint-docker job in lint.yml requires security-events: write
for SARIF upload; must be explicitly granted to the caller job.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:18:14 +02:00
13b3af3330 Entered and removed whitespaces in README.md 2026-03-30 10:16:53 +02:00
eca7084f4e fix(ci): grant security-events and packages permissions to security job
Reusable workflow calls inherit only explicitly granted permissions.
The nested security job requires packages: read and security-events: write
for CodeQL analysis.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:16:30 +02:00
6861b2c0eb fix(nav): prevent navbar items from wrapping to second line
- Set flex-wrap: nowrap on navbar-nav to keep all items in one row
- Add hidden overflow-x scroll (no visible scrollbar) as fallback
- Fix #navbar_logo taking up space when invisible via max-width transition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:10:55 +02:00
66b1f0d029 feat(agents): add AGENTS.md and CLAUDE.md with pre-commit rules
- Add AGENTS.md: require make test before every non-doc commit and
  document the npm vendor asset workflow
- Add CLAUDE.md: instruct agents to read AGENTS.md at conversation start
- Add npm-install dependency to test-e2e Makefile target

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:59:09 +02:00
a29a0b1862 feat(vendor): replace CDN dependencies with local npm packages
Introduces a vendor build pipeline so all third-party browser assets
(Bootstrap, Bootstrap Icons, Font Awesome, marked, jQuery) are served
from local static files instead of external CDNs.

- Add app/package.json with vendor deps and postinstall/build scripts
- Add app/scripts/copy-vendor.js to copy assets to static/vendor/
- Update base.html.j2 to use url_for('static', ...) for all vendor assets
- Update Dockerfile to install Node.js/npm and run npm install
- Update .gitignore to exclude app/node_modules/ and app/static/vendor/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 09:29:28 +02:00
252b50d2a7 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>
2026-03-29 23:03:09 +02:00
2c61da9fc3 chore: remove comments from settings.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:37:10 +01:00
2d8185b747 chore: add Claude Code project permissions settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 23:16:29 +01:00
a47a5babce chore: remove unused imports 2026-03-28 19:08:57 +01:00
47 changed files with 1605 additions and 182 deletions

89
.claude/settings.json Normal file
View File

@@ -0,0 +1,89 @@
{
"permissions": {
"allow": [
"Read",
"Edit",
"Write",
"Bash(git status*)",
"Bash(git log*)",
"Bash(git diff*)",
"Bash(git add*)",
"Bash(git commit*)",
"Bash(git checkout*)",
"Bash(git branch*)",
"Bash(git fetch*)",
"Bash(git stash*)",
"Bash(git -C:*)",
"Bash(make*)",
"Bash(python3*)",
"Bash(python*)",
"Bash(pip show*)",
"Bash(pip list*)",
"Bash(pip install*)",
"Bash(npm install*)",
"Bash(npm run*)",
"Bash(npx*)",
"Bash(docker pull*)",
"Bash(docker build*)",
"Bash(docker images*)",
"Bash(docker ps*)",
"Bash(docker inspect*)",
"Bash(docker logs*)",
"Bash(docker create*)",
"Bash(docker export*)",
"Bash(docker rm*)",
"Bash(docker rmi*)",
"Bash(docker stop*)",
"Bash(docker compose*)",
"Bash(docker-compose*)",
"Bash(docker container prune*)",
"Bash(grep*)",
"Bash(find*)",
"Bash(ls*)",
"Bash(cat*)",
"Bash(head*)",
"Bash(tail*)",
"Bash(wc*)",
"Bash(sort*)",
"Bash(tar*)",
"Bash(mkdir*)",
"Bash(cp*)",
"Bash(mv*)",
"Bash(jq*)",
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"WebFetch(domain:api.github.com)",
"WebFetch(domain:docs.docker.com)",
"WebFetch(domain:pypi.org)",
"WebFetch(domain:docs.cypress.io)",
"WebFetch(domain:flask.palletsprojects.com)"
],
"ask": [
"Bash(git push*)",
"Bash(docker run*)",
"Bash(curl*)"
],
"deny": [
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"Bash(rm -rf*)",
"Bash(sudo*)"
]
},
"sandbox": {
"filesystem": {
"allowWrite": [
".",
"/tmp"
],
"denyRead": [
"~/.ssh",
"~/.gnupg",
"~/.kube",
"~/.aws",
"~/.config/gcloud"
]
}
}
}

View File

@@ -1,6 +1,7 @@
name: CI name: CI
on: on:
pull_request:
push: push:
branches: branches:
- "**" - "**"
@@ -9,59 +10,55 @@ on:
permissions: permissions:
contents: read contents: read
packages: write
jobs: jobs:
test-and-publish: security:
name: Run security workflow
uses: ./.github/workflows/security.yml
permissions:
contents: read
packages: read
security-events: write
tests:
name: Run test workflow
uses: ./.github/workflows/tests.yml
lint:
name: Run lint workflow
uses: ./.github/workflows/lint.yml
permissions:
contents: read
security-events: write
publish:
name: Publish image
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: needs:
PORT: "5000" - security
- tests
- lint
if: github.event_name == 'push'
permissions:
contents: read
packages: write
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v6
with: with:
fetch-depth: 0 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 - name: Detect semver tag on current commit
id: semver id: semver
run: | run: |
SEMVER_TAG="$(git tag --points-at "$GITHUB_SHA" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)" 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 if [ -n "$SEMVER_TAG" ]; then
echo "found=true" >> "$GITHUB_OUTPUT" {
echo "raw_tag=$SEMVER_TAG" >> "$GITHUB_OUTPUT" echo "found=true"
echo "version=${SEMVER_TAG#v}" >> "$GITHUB_OUTPUT" echo "raw_tag=$SEMVER_TAG"
echo "version=${SEMVER_TAG#v}"
} >> "$GITHUB_OUTPUT"
else else
echo "found=false" >> "$GITHUB_OUTPUT" echo "found=false" >> "$GITHUB_OUTPUT"
fi fi

77
.github/workflows/lint.yml vendored Normal file
View 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
View 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
View 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

7
.gitignore vendored
View File

@@ -3,3 +3,10 @@ app/config.yaml
app/static/cache/* app/static/cache/*
.env .env
app/cypress/screenshots/* app/cypress/screenshots/*
.ruff_cache/
app/node_modules/
app/static/vendor/
hadolint-results.sarif
build/
*.egg-info/
app/core.*

13
AGENTS.md Normal file
View File

@@ -0,0 +1,13 @@
# Agent Instructions
## Pre-Commit Validation
- You MUST run `make test` before every commit whenever the staged change includes at least one file that is not `.md` or `.rst`, unless explicitly instructed otherwise.
- You MUST commit only after all tests pass.
- You MUST NOT commit automatically without explicit confirmation from the user.
## Vendor Assets
- Browser vendor assets (Bootstrap, Font Awesome, etc.) are managed via npm.
- Run `npm install` inside `app/` to populate `app/static/vendor/` before starting the dev server or running e2e tests.
- Never commit `app/node_modules/` or `app/static/vendor/` — both are gitignored and generated at build time.

View File

@@ -1,3 +1,14 @@
## [1.1.0] - 2026-03-30
* *CI stabilization and modularization*: Split into reusable workflows (lint, security, tests) with correct permissions for CodeQL and SARIF uploads
* *Modern Python packaging*: Migration to pyproject.toml and updated Dockerfile using Python 3.12
* *Improved test coverage*: Added unit, integration, lint, security, and E2E tests using act
* *Local vendor assets*: Replaced external CDNs with npm-based local asset pipeline
* *Enhanced build workflow*: Extended Makefile with targets for test, lint, security, and CI plus vendor build process
* *Frontend fix*: Prevented navbar wrapping and improved layout behavior
* *Developer guidelines*: Introduced AGENTS.md and CLAUDE.md with enforced pre-commit rules
## [1.0.0] - 2026-02-19 ## [1.0.0] - 2026-02-19
* Official Release🥳 * Official Release🥳

5
CLAUDE.md Normal file
View File

@@ -0,0 +1,5 @@
# CLAUDE.md
## Startup
You MUST read `AGENTS.md` and follow all instructions in it at the start of every conversation before doing anything else.

View File

@@ -1,14 +1,20 @@
# Base image for Python FROM python:3.12-slim
FROM python:slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
FLASK_HOST=0.0.0.0
# hadolint ignore=DL3008
RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm && rm -rf /var/lib/apt/lists/*
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 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/ . COPY app/ .
RUN npm install --prefix /app
CMD ["python", "app.py"] CMD ["python", "app.py"]

4
MIRRORS Normal file
View File

@@ -0,0 +1,4 @@
git@github.com:kevinveenbirkenbach/port-ui.git
ssh://git@code.infinito.nexus:2201/kevinveenbirkenbach/port-ui.git
ssh://git@git.veen.world:2201/kevinveenbirkenbach/port-ui.git

View File

@@ -7,6 +7,8 @@ endif
# Default port (can be overridden with PORT env var) # Default port (can be overridden with PORT env var)
PORT ?= 5000 PORT ?= 5000
PYTHON ?= python3
ACT ?= act
# Default port (can be overridden with PORT env var) # Default port (can be overridden with PORT env var)
.PHONY: build .PHONY: build
@@ -14,10 +16,15 @@ build:
# Build the Docker image. # Build the Docker image.
docker build -t application-portfolio . 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 .PHONY: up
up: up:
# Start the application using docker-compose with build. # Start the application using docker-compose with build.
docker-compose up -d --build docker-compose up -d --build --force-recreate
.PHONY: down .PHONY: down
down: down:
@@ -75,8 +82,93 @@ browse:
# Open the application in the browser at http://localhost:$(PORT) # Open the application in the browser at http://localhost:$(PORT)
chromium 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: npm-install:
# Install Node.js dependencies for browser tests.
cd app && npm install cd app && npm install
test: npm-install .PHONY: lint-actions
cd app && npx cypress run --spec "cypress/e2e/**/*.spec.js" 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: npm-install
# 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.

View File

@@ -1,9 +1,6 @@
# PortUI 🖥️✨ # PortUI 🖥️✨
[![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![GitHub Sponsors](https://img.shields.io/badge/Sponsor-GitHub%20Sponsors-blue?logo=github)](https://github.com/sponsors/kevinveenbirkenbach) [![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach) [![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach) [![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
[![Patreon](https://img.shields.io/badge/Support-Patreon-orange?logo=patreon)](https://www.patreon.com/c/kevinveenbirkenbach)
[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20me%20a%20Coffee-Funding-yellow?logo=buymeacoffee)](https://buymeacoffee.com/kevinveenbirkenbach)
[![PayPal](https://img.shields.io/badge/Donate-PayPal-blue?logo=paypal)](https://s.veen.world/paypaldonate)
A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes. A lightweight, Docker-powered portfolio/landing-page generator—fully customizable via YAML! Showcase your projects, skills, and online presence in minutes.

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Portfolio UI web application package."""

View File

@@ -1,18 +1,26 @@
import logging
import os import os
from flask import Flask, render_template
import yaml
import requests import requests
from utils.configuration_resolver import ConfigurationResolver 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.cache_manager import CacheManager
from utils.compute_card_classes import compute_card_classes from utils.compute_card_classes import compute_card_classes
import logging from utils.configuration_resolver import ConfigurationResolver
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}")
from flask import Flask, render_template, current_app logging.basicConfig(level=logging.DEBUG)
from markupsafe import Markup
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 # Initialize the CacheManager
cache_manager = CacheManager() cache_manager = CacheManager()
@@ -20,10 +28,11 @@ cache_manager = CacheManager()
# Clear cache on startup # Clear cache on startup
cache_manager.clear_cache() cache_manager.clear_cache()
def load_config(app): def load_config(app):
"""Load and resolve the configuration from config.yaml.""" """Load and resolve the configuration from config.yaml."""
with open("config.yaml", "r") as f: with open("config.yaml", "r", encoding="utf-8") as handle:
config = yaml.safe_load(f) config = yaml.safe_load(handle)
if config.get("nasa_api_key"): if config.get("nasa_api_key"):
app.config["NASA_API_KEY"] = config["nasa_api_key"] app.config["NASA_API_KEY"] = config["nasa_api_key"]
@@ -32,26 +41,23 @@ def load_config(app):
resolver.resolve_links() resolver.resolve_links()
app.config.update(resolver.get_config()) app.config.update(resolver.get_config())
def cache_icons_and_logos(app): 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"]: for card in app.config["cards"]:
icon = card.get("icon", {}) icon = card.get("icon", {})
if icon.get("source"): if icon.get("source"):
cached = cache_manager.cache_file(icon["source"]) cached = cache_manager.cache_file(icon["source"])
# Fallback: wenn cache_file None liefert, nutze weiterhin source
icon["cache"] = cached or icon["source"] icon["cache"] = cached or icon["source"]
# Company-Logo
company_logo = app.config["company"]["logo"] company_logo = app.config["company"]["logo"]
cached = cache_manager.cache_file(company_logo["source"]) cached = cache_manager.cache_file(company_logo["source"])
company_logo["cache"] = cached or company_logo["source"] company_logo["cache"] = cached or company_logo["source"]
# Platform Favicon
favicon = app.config["platform"]["favicon"] favicon = app.config["platform"]["favicon"]
cached = cache_manager.cache_file(favicon["source"]) cached = cache_manager.cache_file(favicon["source"])
favicon["cache"] = cached or favicon["source"] favicon["cache"] = cached or favicon["source"]
# Platform Logo
platform_logo = app.config["platform"]["logo"] platform_logo = app.config["platform"]["logo"]
cached = cache_manager.cache_file(platform_logo["source"]) cached = cache_manager.cache_file(platform_logo["source"])
platform_logo["cache"] = cached or platform_logo["source"] platform_logo["cache"] = cached or platform_logo["source"]
@@ -64,18 +70,22 @@ app = Flask(__name__)
load_config(app) load_config(app)
cache_icons_and_logos(app) cache_icons_and_logos(app)
@app.context_processor @app.context_processor
def utility_processor(): def utility_processor():
def include_svg(path): 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: try:
with open(full_path, 'r', encoding='utf-8') as f: with open(full_path, "r", encoding="utf-8") as handle:
svg = f.read() svg = handle.read()
return Markup(svg) # Trusted local SVG asset shipped with the application package.
except IOError: return Markup(svg) # nosec B704
return Markup(f'<!-- SVG not found: {path} -->') except OSError:
return ""
return dict(include_svg=include_svg) return dict(include_svg=include_svg)
@app.before_request @app.before_request
def reload_config_in_dev(): def reload_config_in_dev():
"""Reload config and recache icons before each request in development mode.""" """Reload config and recache icons before each request in development mode."""
@@ -83,22 +93,22 @@ def reload_config_in_dev():
load_config(app) load_config(app)
cache_icons_and_logos(app) cache_icons_and_logos(app)
@app.route('/')
@app.route("/")
def index(): def index():
"""Render the main index page.""" """Render the main index page."""
cards = app.config["cards"] cards = app.config["cards"]
lg_classes, md_classes = compute_card_classes(cards) lg_classes, md_classes = compute_card_classes(cards)
# fetch NASA APOD URL only if key present
apod_bg = None apod_bg = None
api_key = app.config.get("NASA_API_KEY") api_key = app.config.get("NASA_API_KEY")
if api_key: if api_key:
resp = requests.get( resp = requests.get(
"https://api.nasa.gov/planetary/apod", "https://api.nasa.gov/planetary/apod",
params={"api_key": api_key} params={"api_key": api_key},
timeout=10,
) )
if resp.ok: if resp.ok:
data = resp.json() data = resp.json()
# only use if it's an image
if data.get("media_type") == "image": if data.get("media_type") == "image":
apod_bg = data.get("url") apod_bg = data.get("url")
@@ -110,8 +120,14 @@ def index():
platform=app.config["platform"], platform=app.config["platform"],
lg_classes=lg_classes, lg_classes=lg_classes,
md_classes=md_classes, md_classes=md_classes,
apod_bg=apod_bg apod_bg=apod_bg,
) )
if __name__ == "__main__": 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,
)

View File

@@ -15,7 +15,7 @@ describe('Navbar Logo Visibility', () => {
it('should become visible (opacity 1) after entering fullscreen', () => { it('should become visible (opacity 1) after entering fullscreen', () => {
cy.window().then(win => { cy.window().then(win => {
win.fullscreen(); win.enterFullscreen();
}); });
cy.get('#navbar_logo', { timeout: 4000 }) cy.get('#navbar_logo', { timeout: 4000 })
.should('have.css', 'opacity', '1'); .should('have.css', 'opacity', '1');
@@ -23,7 +23,7 @@ describe('Navbar Logo Visibility', () => {
it('should become invisible again (opacity 0) after exiting fullscreen', () => { it('should become invisible again (opacity 0) after exiting fullscreen', () => {
cy.window().then(win => { cy.window().then(win => {
win.fullscreen(); win.enterFullscreen();
win.exitFullscreen(); win.exitFullscreen();
}); });
cy.get('#navbar_logo', { timeout: 4000 }) cy.get('#navbar_logo', { timeout: 4000 })

View File

@@ -1,5 +1,16 @@
{ {
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"bootstrap": "5.2.2",
"bootstrap-icons": "1.9.1",
"jquery": "3.6.0",
"marked": "^4.3.0"
},
"devDependencies": { "devDependencies": {
"cypress": "^14.5.1" "cypress": "^14.5.1"
},
"scripts": {
"build": "node scripts/copy-vendor.js",
"postinstall": "node scripts/copy-vendor.js"
} }
} }

View File

@@ -1,3 +0,0 @@
flask
requests
pyyaml

View File

@@ -0,0 +1,71 @@
'use strict';
/**
* Copies third-party browser assets from node_modules into static/vendor/
* so Flask can serve them without any CDN dependency.
* Runs automatically via the "postinstall" npm hook.
*/
const fs = require('fs');
const path = require('path');
const NM = path.join(__dirname, '..', 'node_modules');
const VENDOR = path.join(__dirname, '..', 'static', 'vendor');
function copyFile(src, dest) {
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.copyFileSync(src, dest);
}
function copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
entry.isDirectory() ? copyDir(s, d) : fs.copyFileSync(s, d);
}
}
// Bootstrap CSS + JS bundle
copyFile(
path.join(NM, 'bootstrap', 'dist', 'css', 'bootstrap.min.css'),
path.join(VENDOR, 'bootstrap', 'css', 'bootstrap.min.css')
);
copyFile(
path.join(NM, 'bootstrap', 'dist', 'js', 'bootstrap.bundle.min.js'),
path.join(VENDOR, 'bootstrap', 'js', 'bootstrap.bundle.min.js')
);
// Bootstrap Icons CSS + embedded fonts
copyFile(
path.join(NM, 'bootstrap-icons', 'font', 'bootstrap-icons.css'),
path.join(VENDOR, 'bootstrap-icons', 'font', 'bootstrap-icons.css')
);
copyDir(
path.join(NM, 'bootstrap-icons', 'font', 'fonts'),
path.join(VENDOR, 'bootstrap-icons', 'font', 'fonts')
);
// Font Awesome Free CSS + webfonts
copyFile(
path.join(NM, '@fortawesome', 'fontawesome-free', 'css', 'all.min.css'),
path.join(VENDOR, 'fontawesome', 'css', 'all.min.css')
);
copyDir(
path.join(NM, '@fortawesome', 'fontawesome-free', 'webfonts'),
path.join(VENDOR, 'fontawesome', 'webfonts')
);
// marked browser UMD build (path varies by version)
const markedCandidates = [
path.join(NM, 'marked', 'marked.min.js'), // v4.x
path.join(NM, 'marked', 'lib', 'marked.umd.min.js'), // v5.x
path.join(NM, 'marked', 'dist', 'marked.min.js'), // v9+
];
const markedSrc = markedCandidates.find(p => fs.existsSync(p));
if (!markedSrc) throw new Error('marked: no browser UMD build found in node_modules');
copyFile(markedSrc, path.join(VENDOR, 'marked', 'marked.min.js'));
// jQuery
copyFile(
path.join(NM, 'jquery', 'dist', 'jquery.min.js'),
path.join(VENDOR, 'jquery', 'jquery.min.js')
);

View File

@@ -111,6 +111,19 @@ div#navbarNavfooter li.nav-item {
margin-right: 6px; margin-right: 6px;
} }
/* Prevent nav items from wrapping to a second line */
div#navbarNavheader .navbar-nav,
div#navbarNavfooter .navbar-nav {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none; /* Firefox */
}
div#navbarNavheader .navbar-nav::-webkit-scrollbar,
div#navbarNavfooter .navbar-nav::-webkit-scrollbar {
display: none; /* Chrome/Safari */
}
main, footer, header, nav { main, footer, header, nav {
position: relative; position: relative;
box-shadow: box-shadow:
@@ -168,13 +181,16 @@ iframe{
} }
#navbar_logo { #navbar_logo {
/* start invisible but in the layout (d-none will actually hide it) */
opacity: 0; opacity: 0;
transition: opacity var(--anim-duration) ease-in-out; max-width: 0;
overflow: hidden;
transition: opacity var(--anim-duration) ease-in-out,
max-width var(--anim-duration) ease-in-out;
} }
#navbar_logo.visible { #navbar_logo.visible {
opacity: 1 !important; opacity: 1 !important;
max-width: 300px;
} }

View File

@@ -9,22 +9,19 @@
href="{% if platform.favicon.cache %}{{ url_for('static', filename=platform.favicon.cache) }}{% endif %}" href="{% if platform.favicon.cache %}{{ url_for('static', filename=platform.favicon.cache) }}{% endif %}"
> >
<!-- Bootstrap CSS only --> <!-- Bootstrap CSS only -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-Zenh87qX5JnK2Jl0vWa8Ck2rdkQ2Bzep5IDxbcnCeuOxjzrPF/et3URy9Bv1WTRi" crossorigin="anonymous"> <link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
<!-- Bootstrap JavaScript Bundle with Popper --> <!-- Bootstrap JavaScript Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-OERcA2EqjJCMA+/3y+gxIOqMEjwtxJY7qPCqsdltbNJuaOe923+mo//f6V8Qbsw3" crossorigin="anonymous"></script> <script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
<!-- Bootstrap Icons --> <!-- Bootstrap Icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/font/bootstrap-icons.css') }}">
<!-- Fontawesome --> <!-- Fontawesome -->
<script src="https://kit.fontawesome.com/56f96da298.js" crossorigin="anonymous"></script> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/fontawesome/css/all.min.css') }}">
<!-- Markdown --> <!-- Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="{{ url_for('static', filename='vendor/marked/marked.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('static', filename='css/default.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/default.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/custom_scrollbar.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/custom_scrollbar.css') }}">
<!-- JQuery --> <!-- JQuery -->
<script <script src="{{ url_for('static', filename='vendor/jquery/jquery.min.js') }}"></script>
src="https://code.jquery.com/jquery-3.6.0.min.js"
crossorigin="anonymous">
</script>
</head> </head>
<body <body
{% if apod_bg %} {% if apod_bg %}

1
app/utils/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Utilities used by the Portfolio UI web application."""

View File

@@ -1,7 +1,9 @@
import os
import hashlib import hashlib
import requests
import mimetypes import mimetypes
import os
import requests
class CacheManager: class CacheManager:
def __init__(self, cache_dir="static/cache"): def __init__(self, cache_dir="static/cache"):
@@ -9,8 +11,7 @@ class CacheManager:
self._ensure_cache_dir_exists() self._ensure_cache_dir_exists()
def _ensure_cache_dir_exists(self): def _ensure_cache_dir_exists(self):
if not os.path.exists(self.cache_dir): os.makedirs(self.cache_dir, exist_ok=True)
os.makedirs(self.cache_dir)
def clear_cache(self): def clear_cache(self):
if os.path.exists(self.cache_dir): if os.path.exists(self.cache_dir):
@@ -20,8 +21,10 @@ class CacheManager:
os.remove(path) os.remove(path)
def cache_file(self, file_url): def cache_file(self, file_url):
# generate a short hash for filename hash_suffix = hashlib.blake2s(
hash_suffix = hashlib.blake2s(file_url.encode('utf-8'), digest_size=8).hexdigest() file_url.encode("utf-8"),
digest_size=8,
).hexdigest()
parts = file_url.rstrip("/").split("/") parts = file_url.rstrip("/").split("/")
base = parts[-2] if parts[-1] == "download" else parts[-1] base = parts[-2] if parts[-1] == "download" else parts[-1]
@@ -31,7 +34,7 @@ class CacheManager:
except requests.RequestException: except requests.RequestException:
return None 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" ext = mimetypes.guess_extension(content_type.split(";")[0].strip()) or ".png"
filename = f"{base}_{hash_suffix}{ext}" filename = f"{base}_{hash_suffix}{ext}"
full_path = os.path.join(self.cache_dir, filename) full_path = os.path.join(self.cache_dir, filename)
@@ -41,5 +44,4 @@ class CacheManager:
for chunk in resp.iter_content(1024): for chunk in resp.iter_content(1024):
f.write(chunk) f.write(chunk)
# return path relative to /static/
return f"cache/{filename}" return f"cache/{filename}"

View File

@@ -32,7 +32,7 @@ def compute_card_classes(cards):
lg_classes.append("col-lg-6") lg_classes.append("col-lg-6")
else: else:
lg_classes.append("col-lg-4") 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 = [] md_classes = []
for i in range(num_cards): for i in range(num_cards):
if num_cards % 2 == 0 or i < num_cards - 1: if num_cards % 2 == 0 or i < num_cards - 1:

View File

@@ -1,4 +1,3 @@
from pprint import pprint
class ConfigurationResolver: class ConfigurationResolver:
""" """
A class to resolve `link` entries in a nested configuration structure. A class to resolve `link` entries in a nested configuration structure.
@@ -14,19 +13,6 @@ class ConfigurationResolver:
""" """
self._recursive_resolve(self.config, self.config) 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): def _replace_in_list_by_list(self, list_origine, old_element, new_elements):
index = list_origine.index(old_element) index = list_origine.index(old_element)
list_origine[index : index + 1] = new_elements list_origine[index : index + 1] = new_elements
@@ -43,10 +29,17 @@ class ConfigurationResolver:
for key, value in list(current_config.items()): for key, value in list(current_config.items()):
if key == "children": if key == "children":
if value is None or not isinstance(value, list): 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: for item in value:
if "link" in item: 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): 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: else:
@@ -55,15 +48,24 @@ class ConfigurationResolver:
self._recursive_resolve(value, root_config) self._recursive_resolve(value, root_config)
elif key == "link": elif key == "link":
try: 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: 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.clear()
current_config.update(loaded) current_config.update(loaded)
except Exception as e: except Exception as e:
raise ValueError( raise ValueError(
f"Error resolving link '{value}': {str(e)}. " 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: else:
self._recursive_resolve(value, root_config) self._recursive_resolve(value, root_config)
@@ -72,7 +74,9 @@ class ConfigurationResolver:
self._recursive_resolve(item, root_config) self._recursive_resolve(item, root_config)
def _get_children(self, current): def _get_children(self, current):
if isinstance(current, dict) and ("children" in current and current["children"]): if isinstance(current, dict) and (
"children" in current and current["children"]
):
current = current["children"] current = current["children"]
return current return current
@@ -81,8 +85,13 @@ class ConfigurationResolver:
def _find_by_name(self, current, part): def _find_by_name(self, current, part):
return next( 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): def _find_entry(self, config, path, children):
@@ -90,46 +99,44 @@ class ConfigurationResolver:
Finds an entry in the configuration by a dot-separated path. Finds an entry in the configuration by a dot-separated path.
Supports both dictionaries and lists with `children` navigation. Supports both dictionaries and lists with `children` navigation.
""" """
parts = path.split('.') parts = path.split(".")
current = config current = config
for part in parts: for part in parts:
if isinstance(current, list): if isinstance(current, list):
# If children explicit declared just load children
if part != "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: if found:
current = found current = found
print( 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}" f"Current list: {current}"
) )
else: else:
raise ValueError( 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}" f"Current list: {current}"
) )
elif isinstance(current, dict): elif isinstance(current, dict):
# Case-insensitive dictionary lookup
key = next((k for k in current if self._mapped_key(k) == part), None) 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 key is None:
if "children" not in current: if "children" not in current:
raise KeyError( raise KeyError(
f"No 'children' found in current 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}" 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) current = self._find_by_name(current["children"], part)
if not current: if not current:
raise KeyError( raise KeyError(
f"Key '{part}' not found in dictionary. Path so far: {' > '.join(parts[:parts.index(part)+1])}. " f"Key '{part}' not found in dictionary. Path so far: "
f"{' > '.join(parts[: parts.index(part) + 1])}. "
f"Current dictionary: {current}" f"Current dictionary: {current}"
) )
else: else:
current = current[key] current = current[key]
else: else:
raise ValueError( raise ValueError(
f"Invalid path segment '{part}'. Current type: {type(current)}. " f"Invalid path segment '{part}'. Current type: {type(current)}. "

27
main.py
View File

@@ -3,11 +3,11 @@
main.py - Proxy to Makefile targets for managing the Portfolio CMS Docker application. main.py - Proxy to Makefile targets for managing the Portfolio CMS Docker application.
Automatically generates CLI commands based on the Makefile definitions. Automatically generates CLI commands based on the Makefile definitions.
""" """
import argparse import argparse
import re
import subprocess import subprocess
import sys import sys
import os
import re
from pathlib import Path from pathlib import Path
MAKEFILE_PATH = Path(__file__).resolve().parent / "Makefile" MAKEFILE_PATH = Path(__file__).resolve().parent / "Makefile"
@@ -21,16 +21,17 @@ def load_targets(makefile_path):
""" """
targets = [] targets = []
pattern = re.compile(r"^([A-Za-z0-9_\-]+):") pattern = re.compile(r"^([A-Za-z0-9_\-]+):")
with open(makefile_path, 'r') as f: with open(makefile_path, "r", encoding="utf-8") as handle:
lines = f.readlines() lines = handle.readlines()
for idx, line in enumerate(lines): for idx, line in enumerate(lines):
m = pattern.match(line) m = pattern.match(line)
if m: if m:
name = m.group(1) name = m.group(1)
help_text = '' help_text = ""
# look for next non-empty line if idx + 1 < len(lines):
if idx + 1 < len(lines) and lines[idx+1].lstrip().startswith('#'): next_line = lines[idx + 1].lstrip()
help_text = lines[idx+1].lstrip('# ').strip() if next_line.startswith("#"):
help_text = next_line.lstrip("# ").strip()
targets.append((name, help_text)) targets.append((name, help_text))
return targets return targets
@@ -55,13 +56,13 @@ def main():
parser.add_argument( parser.add_argument(
"--dry-run", "--dry-run",
action="store_true", 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( subparsers = parser.add_subparsers(
title="Available commands", title="Available commands",
dest="command", dest="command",
required=True required=True,
) )
targets = load_targets(MAKEFILE_PATH) targets = load_targets(MAKEFILE_PATH)
@@ -70,15 +71,9 @@ def main():
sp.set_defaults(target=name) sp.set_defaults(target=name)
args = parser.parse_args() args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
cmd = ["make", args.target] cmd = ["make", args.target]
run_command(cmd, dry_run=args.dry_run) run_command(cmd, dry_run=args.dry_run)
if __name__ == "__main__": if __name__ == "__main__":
from pathlib import Path
main() main()

44
pyproject.toml Normal file
View File

@@ -0,0 +1,44 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
name = "portfolio-ui"
version = "1.1.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"]

View File

@@ -1 +0,0 @@
python-dotenv

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View 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()

View 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
View File

@@ -0,0 +1 @@

View 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()

View 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()

View File

@@ -0,0 +1 @@

View 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()

View 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
View File

@@ -0,0 +1 @@
"""Unit test package for Portfolio UI."""

View 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()

View 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()

View 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()

View 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()

View 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
View 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()

View 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())

View 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())