Compare commits
22
Commits
@@ -0,0 +1,15 @@
|
|||||||
|
# SmileyFace UT4 Server Configuration
|
||||||
|
# Copy to .env and fill in your values
|
||||||
|
|
||||||
|
SMILEYFACE_PROJECT_DIR=
|
||||||
|
SMILEYFACE_CONFIG_DIR=
|
||||||
|
SMILEYFACE_DOWNLOAD_URL=
|
||||||
|
SMILEYFACE_DOWNLOAD_FILENAME=
|
||||||
|
SMILEYFACE_DOWNLOAD_MD5=
|
||||||
|
SMILEYFACE_SKIP_VALIDATE=false
|
||||||
|
SMILEYFACE_REDIRECT_PROTOCOL=
|
||||||
|
SMILEYFACE_REDIRECT_URL=
|
||||||
|
SMILEYFACE_REMOTE_GAME_HOST=
|
||||||
|
SMILEYFACE_REMOTE_GAME_DIR=
|
||||||
|
SMILEYFACE_REMOTE_REDIRECT_HOST=
|
||||||
|
SMILEYFACE_SQLITE_FILENAME=smiles.db
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
name: tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
|
- name: Run smoke tests
|
||||||
|
run: uv run --python ${{ matrix.python-version }} --with . --with pytest pytest -v
|
||||||
+4
-1
@@ -4,4 +4,7 @@ dist/
|
|||||||
__pycache__
|
__pycache__
|
||||||
*.egg-info
|
*.egg-info
|
||||||
idea
|
idea
|
||||||
|
.env
|
||||||
|
.claude/settings.local.json
|
||||||
|
.claude/worktrees/
|
||||||
|
uv.lock
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ repos:
|
|||||||
rev: 24.2.0
|
rev: 24.2.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
language_version: python3.8
|
language_version: python3.13
|
||||||
|
|
||||||
# Poetry
|
# Poetry
|
||||||
- repo: https://github.com/python-poetry/poetry
|
- repo: https://github.com/python-poetry/poetry
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.8.19
|
3.13
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
SmileyFace is a Python CLI tool for automating Unreal Tournament 4 (UT4) server administration. It handles building local server instances from custom maps/mutators/configs, deploying them to remote game and redirect servers via rsync/SSH, and managing server lifecycle (start/stop/restart).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
poetry install
|
||||||
|
|
||||||
|
# Run the tool
|
||||||
|
python smileyface.py --help
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
black smileyface/
|
||||||
|
isort --profile black --filter-files smileyface/
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
flake8 smileyface/
|
||||||
|
|
||||||
|
# Run pre-commit hooks manually
|
||||||
|
pre-commit run --all-files
|
||||||
|
|
||||||
|
# Run the smoke-test suite on the current interpreter
|
||||||
|
uv run --with . --with pytest pytest -v
|
||||||
|
|
||||||
|
# Run the full Python version matrix (3.8-3.13)
|
||||||
|
uv run --with nox nox -s tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Smoke tests live in `tests/` (import, CLI `--help`, and settings checks) and run across the supported Python 3.8-3.13 matrix via `nox`.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
- **Black** formatter: 120 char line length, targets Python 3.8–3.13
|
||||||
|
- **isort**: profile black, multi_line_output=3, trailing commas, force_grid_wrap=3
|
||||||
|
- **flake8**: 120 char max, ignores E121/E123/E126/E226/E24/E704/W605
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The app uses **Click** for CLI dispatch, **pydantic-settings** for configuration, and stdlib `logging` for log management.
|
||||||
|
|
||||||
|
**Entry point**: `smileyface.py` (project root) imports `smileyface.start_app()` which invokes the Click CLI.
|
||||||
|
|
||||||
|
**Configuration**: `AppSettings` (`settings.py`) is a pydantic `BaseSettings` model. Config is loaded from environment variables (prefix `SMILEYFACE_`) or a `.env` file. See `.env.example` for all available settings.
|
||||||
|
|
||||||
|
**Context**: `AppContext` (`context.py`) holds the `settings` instance and a dict-like `log` accessor. Passed to all command classes as `ctx`.
|
||||||
|
|
||||||
|
**CLI**: `cli.py` defines Click commands that instantiate the context and delegate to class methods.
|
||||||
|
|
||||||
|
**Key classes**:
|
||||||
|
- `UT4ServerMachine` (`hub_machine.py`) - Core logic. Each CLI subcommand (generate_instance, upload_server, upload_redirects, etc.) is a method here. This is the largest and most important file.
|
||||||
|
- `DataLayer` (`datalayer/datalayer.py`) - Lazy SQLite3 connection wrapper.
|
||||||
|
- `DbFuncs` (`datalayer/db_ops.py`) - Database operations for tracking pak file state (MD5 sums, validation).
|
||||||
|
- `GameIniSpecial` (`gameconfig_edit.py`) - Generates redirect references in Game.ini for custom pak files.
|
||||||
|
|
||||||
|
**Deployment flow**: `oneclickdeploy` chains `generate_instance` -> `upload_redirects` -> `upload_server`. The generate step builds a local instance directory with the server binary, configs, maps, and mutators. Upload steps rsync to remote hosts.
|
||||||
|
|
||||||
|
**Scraping module** (`scrape_latest/`) uses Selenium to scrape pak file listings from ut4pugs.us and utcc.unrealpugs.com for MD5 validation against local files.
|
||||||
@@ -3,45 +3,63 @@
|
|||||||
See official website, [https://zavage-software.com/portfolio/smileyface](https://zavage-software.com/portfolio/smileyface) for instructions.
|
See official website, [https://zavage-software.com/portfolio/smileyface](https://zavage-software.com/portfolio/smileyface) for instructions.
|
||||||
|
|
||||||
# Dependencies
|
# Dependencies
|
||||||
* [app_skellington](https;//zavage-software.com/projects/app_skellington)
|
* [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
|
||||||
* [appdirs](https://pypi.org/project/appdirs)
|
* [click](https://click.palletsprojects.com)
|
||||||
* [colorlog](https://pypi.org/project/colorlog)
|
* [platformdirs](https://pypi.org/project/platformdirs)
|
||||||
* [configobj](https://pypi.org/project/configob)
|
|
||||||
* [selenium](https://selenium-python.readthedocs.io)
|
* [selenium](https://selenium-python.readthedocs.io)
|
||||||
* [sqlparse](https://pypi.org/project/sqlparse)
|
* [sqlparse](https://pypi.org/project/sqlparse)
|
||||||
|
|
||||||
Installation
|
Installation
|
||||||
============
|
============
|
||||||
Activate your desired python environment or install system-wide with admin privileges.
|
Activate your desired python environment, then:
|
||||||
|
|
||||||
python setup.py install
|
poetry install
|
||||||
|
|
||||||
|
Supported Python Versions
|
||||||
|
==========================
|
||||||
|
SmileyFace is tested against CPython 3.8 – 3.13. The supported range is
|
||||||
|
enforced by a smoke-test matrix.
|
||||||
|
|
||||||
|
To run the matrix locally (requires [uv](https://docs.astral.sh/uv/)):
|
||||||
|
|
||||||
|
uv python install 3.8 3.9 3.10 3.11 3.12 3.13
|
||||||
|
uv run --with nox nox -s tests
|
||||||
|
|
||||||
Usage
|
Usage
|
||||||
======
|
======
|
||||||
|
SmileyFace exposes a [Click](https://click.palletsprojects.com) CLI. List all
|
||||||
|
commands and options with:
|
||||||
|
|
||||||
$ ./smileyface.py -h
|
python smileyface.py --help
|
||||||
usage: smileyface.py [-h] command ...
|
|
||||||
|
|
||||||
positional arguments:
|
Show help for a specific command:
|
||||||
command
|
|
||||||
clean_instance Deletes the generated instance on the local machine.
|
|
||||||
create_directories Create required directories which the user installs maps, mutators, and config to.
|
|
||||||
download_linux_server
|
|
||||||
Download the latest Linux Unreal Tournament 4 Server from Epic
|
|
||||||
download_logs Download the logs from the target hub.
|
|
||||||
generate_instance Takes the current coniguration and outputs the application files which can be copied
|
|
||||||
to the server.
|
|
||||||
oneclickdeploy
|
|
||||||
restart_server
|
|
||||||
start_server Flip on the target hub on for Fragging.
|
|
||||||
stop_server Stop UT4 Hub processes on the server.
|
|
||||||
upload_redirects Upload paks to redirect server.
|
|
||||||
upload_server Upload all required game files to the hub server.
|
|
||||||
scrape sub-submenu help
|
|
||||||
|
|
||||||
optional arguments:
|
python smileyface.py generate-instance --help
|
||||||
-h, --help show this help message and exit
|
|
||||||
Invalid command. Try -h for usage
|
Server build & deploy commands:
|
||||||
|
|
||||||
|
oneclickdeploy Generate instance, upload redirects, and upload server.
|
||||||
|
generate-instance Build local server instance from current configuration.
|
||||||
|
upload-redirects Upload paks to the redirect server.
|
||||||
|
upload-server Upload game files to the hub server.
|
||||||
|
clean-instance Delete the generated instance on the local machine.
|
||||||
|
create-directories Create required directories for maps, mutators, and config.
|
||||||
|
download-linux-server Download the latest Linux UT4 Server from Epic.
|
||||||
|
download-logs Download the logs from the target hub.
|
||||||
|
start-server Start the UT4 server.
|
||||||
|
stop-server Stop the UT4 server.
|
||||||
|
restart-server Restart the UT4 server.
|
||||||
|
|
||||||
|
Content-scraping commands (under the `scrape` subgroup):
|
||||||
|
|
||||||
|
scrape ut4pugs Check ut4pugs.us for latest content.
|
||||||
|
scrape utcc Check utcc.unrealpugs.com for latest content.
|
||||||
|
scrape create-db-table Create database tables.
|
||||||
|
scrape load-md5s Load MD5 checksums from local pak files.
|
||||||
|
scrape print-invalid Print pak files that failed validation.
|
||||||
|
|
||||||
|
Configuration is read from `SMILEYFACE_`-prefixed environment variables or a
|
||||||
|
`.env` file; see `.env.example` for the full list.
|
||||||
|
|
||||||
# Contact
|
# Contact
|
||||||
* mat@zavage.net
|
* mat@zavage.net
|
||||||
|
|||||||
@@ -0,0 +1,633 @@
|
|||||||
|
# Python Version Support Matrix Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Empirically determine and lock in the widest possible range of supported CPython versions (newest → oldest), enforced by a smoke-test suite run across every interpreter via `uv` + `nox`, and guarded in CI by a Gitea Actions matrix.
|
||||||
|
|
||||||
|
**Architecture:** `uv` provides every CPython interpreter on demand. A small `pytest` smoke suite (import every module, run every CLI `--help`, load settings) is the pass/fail oracle for "does this version work." `nox` runs that suite against each interpreter in an isolated venv built via `uv`, installing the project through its PEP 517 backend so `pip` resolves the *best dependency versions that work on that interpreter* — which is exactly how we discover the floor. Once discovered, the floor is encoded in `pyproject.toml` (python constraint, classifiers, black targets) and a Gitea Actions workflow mirrors the matrix.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.9–3.13 (range to be confirmed empirically), Poetry (existing), `uv` (interpreter + venv provisioning), `nox` (matrix runner), `pytest` + `click.testing.CliRunner` (smoke tests), Gitea Actions (CI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background facts (verified against the codebase)
|
||||||
|
|
||||||
|
These were confirmed during planning — they justify the task ordering. Re-verify if the code has changed.
|
||||||
|
|
||||||
|
- **No version-gated syntax** anywhere in `smileyface/`: no `match`/`case`, no runtime `X | Y` unions, no walrus, no 3.11+ stdlib. The code is syntactically 3.8-clean.
|
||||||
|
- **One version-gated stdlib import:** `smileyface/datalayer/db_ops.py:4` → `from importlib.resources import files` (added in 3.9; needs the `importlib_resources` backport on 3.8).
|
||||||
|
- **Undeclared runtime dependency (latent bug):** `selenium` is imported at module top level (`import selenium` / `import selenium.webdriver`) in `smileyface/scrape_latest/scrape_ut4pugs.py:1-2` and `smileyface/scrape_latest/scrape_utcc.py:1-2`, but `pyproject.toml` only declares `pydantic-settings`, `click`, `platformdirs`, `sqlparse`. Importing `smileyface.scrape_latest` fails without it. (`requests` is **not** used anywhere — do not add it.)
|
||||||
|
- **Dependency floors of currently-locked versions:** `pydantic-settings`, `click` (8.2+), `platformdirs` (4.10) all require **3.10+**. Older releases of each support 3.8/3.9, and `pip`/`nox` will select those automatically on older interpreters — which is why per-interpreter resolution (not the shared `poetry.lock`) is used for discovery.
|
||||||
|
- **No `tests/`, no CI** currently exist. Entry point: `smileyface.py` → `import smileyface; smileyface.start_app()` → `smileyface.cli.cli` (a Click group).
|
||||||
|
- **CLI `--help` is side-effect-free:** `_make_ctx()` (which builds settings/DB) is only called *inside* command bodies, so invoking any `--help` neither reads a DB nor needs real config. Smoke-testing `--help` is safe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | New/Modify | Responsibility |
|
||||||
|
|------|-----------|----------------|
|
||||||
|
| `noxfile.py` | Create | Defines the `tests` session parametrized over the Python matrix; uses the `uv` venv backend. |
|
||||||
|
| `tests/test_imports.py` | Create | Imports every `smileyface.*` submodule; catches syntax/import/missing-dep breakage per interpreter. |
|
||||||
|
| `tests/test_cli.py` | Create | Invokes `--help` on the root group and every command via `CliRunner`. |
|
||||||
|
| `tests/test_settings.py` | Create | Verifies `AppSettings` defaults and `SMILEYFACE_*` env parsing. |
|
||||||
|
| `pyproject.toml` | Modify | Add `selenium` runtime dep; add `pytest`/`nox` dev deps; relax then finalize the python constraint; add classifiers; widen black targets; add pytest config. |
|
||||||
|
| `smileyface/datalayer/db_ops.py` | Modify (stretch) | `importlib.resources` backport shim, only if pursuing 3.8. |
|
||||||
|
| `.gitea/workflows/test.yml` | Create | CI matrix mirroring the discovered supported versions. |
|
||||||
|
| `README.md` | Modify | Document supported versions + how to run the matrix. |
|
||||||
|
| `CLAUDE.md` | Modify | Add `nox` commands and supported-version note to project guidance. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Bootstrap `uv` and install the candidate interpreters
|
||||||
|
|
||||||
|
**Files:** none (environment setup)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm `uv` is installed**
|
||||||
|
|
||||||
|
Run: `uv --version`
|
||||||
|
Expected: prints a version (e.g. `uv 0.5.x`). If "command not found", install it:
|
||||||
|
Run: `curl -LsSf https://astral.sh/uv/install.sh | sh` then restart the shell (or `export PATH="$HOME/.local/bin:$PATH"`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Install every candidate interpreter, newest → oldest**
|
||||||
|
|
||||||
|
Run: `uv python install 3.13 3.12 3.11 3.10 3.9 3.8`
|
||||||
|
Expected: each version downloads/installs (already-present ones are skipped).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify they are visible to uv**
|
||||||
|
|
||||||
|
Run: `uv python list --only-installed`
|
||||||
|
Expected: lines for cpython-3.13, 3.12, 3.11, 3.10, 3.9, 3.8.
|
||||||
|
|
||||||
|
(No commit — this is local environment state.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Add pytest scaffolding and the import smoke test (expected RED)
|
||||||
|
|
||||||
|
This test is intentionally expected to **fail first** — it surfaces the undeclared `selenium` dependency before we fix it in Task 3.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/test_imports.py`
|
||||||
|
- Modify: `pyproject.toml` (add `pytest` dev dep + pytest config)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `pytest` to dev dependencies and configure test discovery**
|
||||||
|
|
||||||
|
In `pyproject.toml`, under `[tool.poetry.group.dev.dependencies]`, add `pytest`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
black = "*"
|
||||||
|
pre-commit = "*"
|
||||||
|
isort = "*"
|
||||||
|
flake8 = "*"
|
||||||
|
pytest = "*"
|
||||||
|
```
|
||||||
|
|
||||||
|
Then append a pytest config block at the end of the file:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-ra"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the import smoke test**
|
||||||
|
|
||||||
|
Create `tests/test_imports.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
import smileyface
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_level_package_imports():
|
||||||
|
importlib.import_module("smileyface")
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_submodules_import():
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def _on_walk_error(name):
|
||||||
|
errors.append(f"{name}: failed during package walk")
|
||||||
|
|
||||||
|
module_names = [
|
||||||
|
info.name
|
||||||
|
for info in pkgutil.walk_packages(
|
||||||
|
smileyface.__path__,
|
||||||
|
prefix="smileyface.",
|
||||||
|
onerror=_on_walk_error,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
for name in module_names:
|
||||||
|
try:
|
||||||
|
importlib.import_module(name)
|
||||||
|
except Exception as exc: # noqa: BLE001 - we want every failure, not the first
|
||||||
|
errors.append(f"{name}: {exc!r}")
|
||||||
|
|
||||||
|
assert not errors, "Modules failed to import:\n" + "\n".join(errors)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the import test on the newest interpreter and watch it fail**
|
||||||
|
|
||||||
|
Run: `uv run --python 3.13 --with . --with pytest pytest tests/test_imports.py -v`
|
||||||
|
Expected: FAIL — `ModuleNotFoundError: No module named 'selenium'` raised while importing `smileyface.scrape_latest.*`. This confirms the undeclared-dependency bug.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the test (red state is intentional and documented in the message)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/test_imports.py pyproject.toml
|
||||||
|
git commit -m "test: add import smoke test (reveals undeclared selenium dep)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Declare the missing runtime dependency (import test goes GREEN)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pyproject.toml` (add `selenium`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the undeclared runtime dependency**
|
||||||
|
|
||||||
|
In `pyproject.toml`, under `[tool.poetry.dependencies]`, add `selenium`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.13"
|
||||||
|
pydantic-settings = ">=2.0"
|
||||||
|
click = ">=8.0"
|
||||||
|
platformdirs = ">=3.0"
|
||||||
|
sqlparse = "*"
|
||||||
|
selenium = ">=4.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Re-run the import test on 3.13 and watch it pass**
|
||||||
|
|
||||||
|
Run: `uv run --python 3.13 --with . --with pytest pytest tests/test_imports.py -v`
|
||||||
|
Expected: PASS — every `smileyface.*` module imports.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pyproject.toml
|
||||||
|
git commit -m "fix: declare selenium as a runtime dependency"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: CLI `--help` smoke test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/test_cli.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the CLI smoke test**
|
||||||
|
|
||||||
|
Create `tests/test_cli.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from smileyface.cli import cli
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_help():
|
||||||
|
result = CliRunner().invoke(cli, ["--help"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "SmileyFace" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_command_help():
|
||||||
|
runner = CliRunner()
|
||||||
|
for name in cli.commands:
|
||||||
|
result = runner.invoke(cli, [name, "--help"])
|
||||||
|
assert result.exit_code == 0, f"`{name} --help` failed:\n{result.output}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrape_subcommands_help():
|
||||||
|
runner = CliRunner()
|
||||||
|
scrape_group = cli.commands["scrape"]
|
||||||
|
for name in scrape_group.commands:
|
||||||
|
result = runner.invoke(cli, ["scrape", name, "--help"])
|
||||||
|
assert result.exit_code == 0, f"`scrape {name} --help` failed:\n{result.output}"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it on 3.13**
|
||||||
|
|
||||||
|
Run: `uv run --python 3.13 --with . --with pytest pytest tests/test_cli.py -v`
|
||||||
|
Expected: PASS — root help, all top-level commands, and all `scrape` subcommands return exit code 0.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/test_cli.py
|
||||||
|
git commit -m "test: add CLI --help smoke tests for every command"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Settings smoke test
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/test_settings.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the settings test**
|
||||||
|
|
||||||
|
Create `tests/test_settings.py`. Passing `_env_file=None` prevents a stray local `.env` from leaking into the assertions:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from smileyface.settings import AppSettings
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults():
|
||||||
|
settings = AppSettings(_env_file=None)
|
||||||
|
assert settings.sqlite_filename == "smiles.db"
|
||||||
|
assert settings.skip_validate is False
|
||||||
|
assert settings.project_dir == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_overrides(monkeypatch):
|
||||||
|
monkeypatch.setenv("SMILEYFACE_PROJECT_DIR", "/srv/ut4")
|
||||||
|
monkeypatch.setenv("SMILEYFACE_SKIP_VALIDATE", "true")
|
||||||
|
settings = AppSettings(_env_file=None)
|
||||||
|
assert settings.project_dir == "/srv/ut4"
|
||||||
|
assert settings.skip_validate is True
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run it on 3.13**
|
||||||
|
|
||||||
|
Run: `uv run --python 3.13 --with . --with pytest pytest tests/test_settings.py -v`
|
||||||
|
Expected: PASS — both tests pass (env var override + bool coercion).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the full suite on 3.13 to confirm everything is green before matrixing**
|
||||||
|
|
||||||
|
Run: `uv run --python 3.13 --with . --with pytest pytest -v`
|
||||||
|
Expected: PASS — all tests across the three files.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/test_settings.py
|
||||||
|
git commit -m "test: add AppSettings defaults and env-parsing smoke tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Relax the python constraint to enable discovery on older interpreters
|
||||||
|
|
||||||
|
`pip` refuses to install a project on an interpreter that violates its `requires-python`. The current `^3.13` blocks installs on 3.8–3.12, so we temporarily widen it. It will be tightened to the discovered floor in Task 10.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pyproject.toml` (python constraint)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Widen the python constraint**
|
||||||
|
|
||||||
|
In `pyproject.toml`, change the `python` line under `[tool.poetry.dependencies]`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
python = ">=3.8,<4.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Sanity-check the project still builds/installs on 3.13**
|
||||||
|
|
||||||
|
Run: `uv run --python 3.13 --with . --with pytest pytest -v`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pyproject.toml
|
||||||
|
git commit -m "build: temporarily widen python constraint to >=3.8 for version discovery"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Create the `nox` matrix runner
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `noxfile.py`
|
||||||
|
- Modify: `pyproject.toml` (add `nox` dev dep)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `nox` to dev dependencies**
|
||||||
|
|
||||||
|
In `pyproject.toml`, under `[tool.poetry.group.dev.dependencies]`, add `nox`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
nox = "*"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write `noxfile.py`**
|
||||||
|
|
||||||
|
Create `noxfile.py` at the repo root. The `uv` venv backend makes each session build via `uv`, and `session.install(".")` triggers per-interpreter dependency resolution:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import nox
|
||||||
|
|
||||||
|
nox.options.default_venv_backend = "uv"
|
||||||
|
nox.options.reuse_existing_virtualenvs = False
|
||||||
|
|
||||||
|
# Newest first. 3.8 is a stretch goal (see Task 9 / Task 8 backport).
|
||||||
|
PYTHON_VERSIONS = ["3.13", "3.12", "3.11", "3.10", "3.9", "3.8"]
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(python=PYTHON_VERSIONS)
|
||||||
|
def tests(session):
|
||||||
|
"""Install the project + pytest and run the smoke suite on each interpreter."""
|
||||||
|
session.install(".")
|
||||||
|
session.install("pytest")
|
||||||
|
session.run("pytest", "-v")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify nox sees every session**
|
||||||
|
|
||||||
|
Run: `uv run --with nox nox --list`
|
||||||
|
Expected: lists `tests-3.13` through `tests-3.8`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add noxfile.py pyproject.toml
|
||||||
|
git commit -m "build: add nox matrix runner for the Python version smoke suite"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: (Stretch) Add the `importlib.resources` backport for 3.8
|
||||||
|
|
||||||
|
Do this task **only if** you intend to attempt 3.8 (and only if Task 9 shows dependencies can resolve on 3.8). On 3.9+ the shim is a transparent no-op, so it is safe to land regardless.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `smileyface/datalayer/db_ops.py:4`
|
||||||
|
- Modify: `pyproject.toml` (conditional backport dep)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the hard import with a version-tolerant shim**
|
||||||
|
|
||||||
|
In `smileyface/datalayer/db_ops.py`, replace line 4:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from importlib.resources import files
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
from importlib.resources import files # Python 3.9+
|
||||||
|
except ImportError: # Python 3.8
|
||||||
|
from importlib_resources import files
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Declare the backport for 3.8 only**
|
||||||
|
|
||||||
|
In `pyproject.toml`, under `[tool.poetry.dependencies]`, add an environment-marked dependency:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
importlib-resources = { version = ">=5.0", python = "<3.9" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify 3.9+ is unaffected**
|
||||||
|
|
||||||
|
Run: `uv run --python 3.9 --with . --with pytest pytest tests/test_imports.py -v`
|
||||||
|
Expected: PASS (uses the stdlib branch; backport not installed).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add smileyface/datalayer/db_ops.py pyproject.toml
|
||||||
|
git commit -m "compat: fall back to importlib_resources backport on Python 3.8"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: Run the discovery matrix (newest → oldest) and record results
|
||||||
|
|
||||||
|
**Files:** none (data collection)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run the full matrix**
|
||||||
|
|
||||||
|
Run: `uv run --with nox nox -s tests`
|
||||||
|
Expected: nox runs `tests-3.13` … `tests-3.8` in turn. Some older sessions may fail at **install** (a dependency such as `pydantic-core` has no wheel / drops support for that interpreter) or at **import/test**. That is the signal we want.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Re-run any failed session in isolation to capture the precise cause**
|
||||||
|
|
||||||
|
For each version `X` that failed, run: `uv run --with nox nox -s tests-X`
|
||||||
|
Expected: a clear error — distinguish **resolution/install failure** (dependency floor) from **test failure** (our code). Record the first line of the error.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Fill in the results table**
|
||||||
|
|
||||||
|
Record outcomes here (replace each `?`):
|
||||||
|
|
||||||
|
| Version | Install | Tests | First failure (if any) |
|
||||||
|
|---------|---------|-------|------------------------|
|
||||||
|
| 3.13 | ? | ? | |
|
||||||
|
| 3.12 | ? | ? | |
|
||||||
|
| 3.11 | ? | ? | |
|
||||||
|
| 3.10 | ? | ? | |
|
||||||
|
| 3.9 | ? | ? | |
|
||||||
|
| 3.8 | ? | ? | |
|
||||||
|
|
||||||
|
**The supported floor = the lowest version where both Install and Tests pass.** Note it here: `FLOOR = 3.__`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit the recorded results**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/plans/2026-05-30-python-version-support-matrix.md
|
||||||
|
git commit -m "docs: record Python version support discovery results"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: Lock in the discovered floor
|
||||||
|
|
||||||
|
Use the `FLOOR` value from Task 9. The examples below assume `FLOOR = 3.9`; **substitute the actual floor** in every spot (constraint, classifiers, black targets, and the version list).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `pyproject.toml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Set the final python constraint to the floor**
|
||||||
|
|
||||||
|
In `pyproject.toml`, set (example for floor 3.9):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
python = ">=3.9,<4.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Trim `noxfile.py` to only the supported versions**
|
||||||
|
|
||||||
|
In `noxfile.py`, set `PYTHON_VERSIONS` to exactly the green versions (example for floor 3.9):
|
||||||
|
|
||||||
|
```python
|
||||||
|
PYTHON_VERSIONS = ["3.13", "3.12", "3.11", "3.10", "3.9"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Widen black target-version and add classifiers**
|
||||||
|
|
||||||
|
In `pyproject.toml`, change the black target to span the supported range (example for floor 3.9):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.black]
|
||||||
|
line-length = 120
|
||||||
|
target-version = ['py39', 'py310', 'py311', 'py312', 'py313']
|
||||||
|
```
|
||||||
|
|
||||||
|
And add a `classifiers` list under `[tool.poetry]` (example for floor 3.9):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Refresh the lock file for the new range**
|
||||||
|
|
||||||
|
Run: `uv run poetry lock`
|
||||||
|
Expected: succeeds. Note: broadening the range can downgrade shared dev/runtime deps to versions compatible with the floor (e.g. `click` capped below 8.2 because 8.2 requires 3.10). This is the expected cost of wider support.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-run the trimmed matrix to confirm all-green**
|
||||||
|
|
||||||
|
Run: `uv run --with nox nox -s tests`
|
||||||
|
Expected: every remaining session PASSES.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add pyproject.toml noxfile.py poetry.lock
|
||||||
|
git commit -m "build: set supported Python floor to 3.9 and widen tooling targets"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 11: Add the Gitea Actions CI matrix
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.gitea/workflows/test.yml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the workflow**
|
||||||
|
|
||||||
|
Create `.gitea/workflows/test.yml`. Set the `python-version` list to exactly the supported versions from Task 10 (example for floor 3.9):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
|
- name: Install Python ${{ matrix.python-version }}
|
||||||
|
run: uv python install ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Create venv
|
||||||
|
run: uv venv --python ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install project and pytest
|
||||||
|
run: uv pip install . pytest
|
||||||
|
|
||||||
|
- name: Run smoke tests
|
||||||
|
run: uv run pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate the YAML locally**
|
||||||
|
|
||||||
|
Run: `uv run python -c "import yaml, pathlib; yaml.safe_load(pathlib.Path('.gitea/workflows/test.yml').read_text()); print('yaml ok')"`
|
||||||
|
Expected: prints `yaml ok`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit and push so the runner picks it up**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .gitea/workflows/test.yml
|
||||||
|
git commit -m "ci: add Gitea Actions Python version matrix"
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: this requires a registered Gitea Actions runner on `git.zavage.net`. If none is registered yet, the workflow is still valid and version-controlled; it will execute once a runner is available. After pushing, confirm the run under the repo's **Actions** tab.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 12: Update documentation and final verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Modify: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Document supported versions and the matrix workflow in README**
|
||||||
|
|
||||||
|
In `README.md`, under the `Installation` section, add (example for floor 3.9):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Supported Python Versions
|
||||||
|
|
||||||
|
SmileyFace is tested against CPython 3.9 – 3.13. The supported range is
|
||||||
|
enforced by a smoke-test matrix.
|
||||||
|
|
||||||
|
To run the matrix locally (requires [uv](https://docs.astral.sh/uv/)):
|
||||||
|
|
||||||
|
uv python install 3.9 3.10 3.11 3.12 3.13
|
||||||
|
uv run --with nox nox -s tests
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the matrix commands to CLAUDE.md**
|
||||||
|
|
||||||
|
In `CLAUDE.md`, under the `## Commands` section, add before the closing fence of the code block:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the smoke-test suite on the current interpreter
|
||||||
|
uv run --with . --with pytest pytest -v
|
||||||
|
|
||||||
|
# Run the full Python version matrix
|
||||||
|
uv run --with nox nox -s tests
|
||||||
|
```
|
||||||
|
|
||||||
|
And change the line `There is no test suite.` to:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
Smoke tests live in `tests/` (import, CLI `--help`, and settings checks) and run across the supported Python matrix via `nox`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Final full verification**
|
||||||
|
|
||||||
|
Run: `uv run --with nox nox -s tests`
|
||||||
|
Expected: every supported session PASSES.
|
||||||
|
|
||||||
|
Run: `uv run --python 3.13 --with . --with pytest pytest -v`
|
||||||
|
Expected: PASS (quick single-interpreter confirmation).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md CLAUDE.md
|
||||||
|
git commit -m "docs: document supported Python versions and the test matrix"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage:**
|
||||||
|
- "Test each version of Python for support" → Tasks 1, 7, 9 (install interpreters, nox matrix, discovery run).
|
||||||
|
- "From the newest backwards" → version lists are ordered newest→oldest; discovery records the floor (Task 9).
|
||||||
|
- "Support as many versions as I can" → Task 6 widens the constraint for discovery; per-interpreter `pip` resolution (Task 7) auto-selects older compatible deps; Task 8 reaches for 3.8 via backport; Task 10 locks the lowest green version.
|
||||||
|
- "Public-facing distributable app" → classifiers + finalized constraint (Task 10), CI guard (Task 11), user-facing docs (Task 12).
|
||||||
|
|
||||||
|
**Placeholder scan:** The only intentional fill-ins are Task 9's results table and the floor value, which are *data to be collected by running the matrix* (not undefined code). Every code/config step contains complete content. Example-floor values (3.9) are explicitly flagged "substitute the actual floor."
|
||||||
|
|
||||||
|
**Type/identifier consistency:** `cli` (Click group) and its `.commands` mapping are used consistently in `tests/test_cli.py`; `AppSettings(_env_file=None)` matches the pydantic-settings constructor; `files` import name in the Task 8 shim matches its existing usage in `db_ops.py`; the nox session name `tests` is referenced identically in Tasks 7, 9, 10, 12. `PYTHON_VERSIONS` is the single source of truth trimmed in Task 10.
|
||||||
|
|
||||||
|
**Known risk:** If Task 9 shows even 3.9 cannot resolve `pydantic-core` (no compatible wheel), the floor is 3.10 — set every example `3.9` to `3.10` and drop Task 8. This is expected behavior, not a plan defect.
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
import nox
|
||||||
|
|
||||||
|
nox.options.default_venv_backend = "uv"
|
||||||
|
nox.options.reuse_existing_virtualenvs = False
|
||||||
|
|
||||||
|
# Newest first. 3.8 is a stretch goal (see the importlib_resources backport).
|
||||||
|
PYTHON_VERSIONS = ["3.13", "3.12", "3.11", "3.10", "3.9", "3.8"]
|
||||||
|
|
||||||
|
|
||||||
|
@nox.session(python=PYTHON_VERSIONS)
|
||||||
|
def tests(session):
|
||||||
|
"""Install the project + pytest and run the smoke suite on each interpreter."""
|
||||||
|
session.install(".")
|
||||||
|
session.install("pytest")
|
||||||
|
session.run("pytest", "-v")
|
||||||
Generated
+1822
-162
File diff suppressed because it is too large
Load Diff
+22
-6
@@ -11,6 +11,14 @@ homepage = "https://zavage-software.com/portfolio/smileyface"
|
|||||||
repository = "https://git-mirror.zavage.net/zavage-software/smileyface"
|
repository = "https://git-mirror.zavage.net/zavage-software/smileyface"
|
||||||
documentation = "https://git-mirror.zavage.net/zavage-software/smileyface"
|
documentation = "https://git-mirror.zavage.net/zavage-software/smileyface"
|
||||||
keywords = ["cas"]
|
keywords = ["cas"]
|
||||||
|
classifiers = [
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
]
|
||||||
|
|
||||||
packages = [{ include = "smileyface" }]
|
packages = [{ include = "smileyface" }]
|
||||||
include = [
|
include = [
|
||||||
@@ -21,17 +29,21 @@ include = [
|
|||||||
|
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.8"
|
python = ">=3.8,<4.0"
|
||||||
app_skellington = "*"
|
pydantic-settings = ">=2.0"
|
||||||
configobj = "*"
|
click = ">=8.0"
|
||||||
colorlog = "*"
|
platformdirs = ">=3.0"
|
||||||
appdirs = "*"
|
sqlparse = "*"
|
||||||
|
selenium = ">=4.0"
|
||||||
|
importlib-resources = { version = ">=5.0", python = "<3.9" }
|
||||||
|
|
||||||
[tool.poetry.group.dev.dependencies]
|
[tool.poetry.group.dev.dependencies]
|
||||||
black = "*"
|
black = "*"
|
||||||
pre-commit = "*"
|
pre-commit = "*"
|
||||||
isort = "*"
|
isort = "*"
|
||||||
flake8 = "*"
|
flake8 = "*"
|
||||||
|
pytest = "*"
|
||||||
|
nox = "*"
|
||||||
#Sphinx = "^5.3.0"
|
#Sphinx = "^5.3.0"
|
||||||
#sphinx-rtd-theme = "^1.3.0"
|
#sphinx-rtd-theme = "^1.3.0"
|
||||||
|
|
||||||
@@ -41,7 +53,7 @@ build-backend = "poetry.core.masonry.api"
|
|||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 120
|
line-length = 120
|
||||||
target-version = ['py38']
|
target-version = ['py38', 'py39', 'py310', 'py311', 'py312', 'py313']
|
||||||
|
|
||||||
[tool.isort]
|
[tool.isort]
|
||||||
multi_line_output = 3
|
multi_line_output = 3
|
||||||
@@ -49,3 +61,7 @@ combine_as_imports = true
|
|||||||
include_trailing_comma = true
|
include_trailing_comma = true
|
||||||
force_grid_wrap = 3
|
force_grid_wrap = 3
|
||||||
ensure_newline_before_comments = true
|
ensure_newline_before_comments = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-ra"
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
|
|
||||||
from setuptools import find_packages, setup
|
|
||||||
|
|
||||||
__project__ = "SmileyFace UT4 Hub Automator"
|
|
||||||
__version__ = "0.1.0"
|
|
||||||
|
|
||||||
app_skellington_requirements = (
|
|
||||||
"appdirs",
|
|
||||||
"colorlog",
|
|
||||||
"configobj",
|
|
||||||
)
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name=__project__,
|
|
||||||
version=__version__,
|
|
||||||
description="Unreal Tournament 4 Server Admin and Control Panel",
|
|
||||||
author="Mathew Guest",
|
|
||||||
author_email="t3h.zavage@gmail.com",
|
|
||||||
url="https://git-mirror.zavage-software.com",
|
|
||||||
# Third-party dependencies; will be automatically installed
|
|
||||||
install_requires=("rdiff-backup", "app_skellington", "appdirs", "sqlparse") + app_skellington_requirements,
|
|
||||||
packages=find_packages(),
|
|
||||||
package_dir={"app_skellington": "lib/app_skellington"},
|
|
||||||
)
|
|
||||||
@@ -1,42 +1,4 @@
|
|||||||
import logging
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Module parameters and constants
|
|
||||||
APP_NAME = "SmileyFace Unreal Tournament 4 Server Panel"
|
APP_NAME = "SmileyFace Unreal Tournament 4 Server Panel"
|
||||||
APP_AUTHOR = "Mathew Guest"
|
|
||||||
APP_VERSION = "0.1.0"
|
APP_VERSION = "0.1.0"
|
||||||
|
|
||||||
APP_CONFIG_FILENAME = "config.ini"
|
|
||||||
|
|
||||||
# config.spec is relative to the module src directory and is the
|
|
||||||
# config specification (structure, names, and types of config file)
|
|
||||||
APP_CONFIGSPEC_FILENAME = "config.spec"
|
|
||||||
|
|
||||||
# Check and gracefully fail if the user needs to install a 3rd-party dep.
|
|
||||||
required_lib_names = ["appdirs", "configobj", "colorlog"]
|
|
||||||
|
|
||||||
|
|
||||||
def check_env_has_dependencies(required_lib_names):
|
|
||||||
"""
|
|
||||||
Attempts to import each module and gracefully fails if it doesn't
|
|
||||||
exist.
|
|
||||||
"""
|
|
||||||
rc = True
|
|
||||||
for libname in required_lib_names:
|
|
||||||
try:
|
|
||||||
__import__(libname)
|
|
||||||
except ImportError as ex:
|
|
||||||
print("missing third-part library: ", ex, file=sys.stderr)
|
|
||||||
rc = False
|
|
||||||
except Exception as ex:
|
|
||||||
print(ex, type(ex))
|
|
||||||
rc = False
|
|
||||||
return rc
|
|
||||||
|
|
||||||
|
|
||||||
if not check_env_has_dependencies(required_lib_names):
|
|
||||||
print("refusing to load program without installed dependencies", file=sys.stderr)
|
|
||||||
raise ImportError("python environment needs third-party dependencies installed")
|
|
||||||
|
|
||||||
# Exposed from sub-modules:
|
|
||||||
from .app import start_app
|
from .app import start_app
|
||||||
|
|||||||
+2
-127
@@ -1,130 +1,5 @@
|
|||||||
import app_skellington
|
from .cli import cli
|
||||||
from app_skellington import _util
|
|
||||||
|
|
||||||
from . import (
|
|
||||||
datalayer,
|
|
||||||
hub_machine,
|
|
||||||
scrape_latest,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SmileyFace(app_skellington.ApplicationContainer):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
filename = "config.spec"
|
|
||||||
self.configspec_filepath = _util.get_asset(__name__, filename)
|
|
||||||
|
|
||||||
config_filepath = self._get_config_filepath("smileyface-ut4", "", "hub-config.ini")
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
configspec_filepath=self.configspec_filepath,
|
|
||||||
configini_filepath=config_filepath,
|
|
||||||
app_name="SmileyFace UT4 Server Panel",
|
|
||||||
app_author="Mathew Guest",
|
|
||||||
app_version="0.1",
|
|
||||||
*args,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _cli_options(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _command_menu(self):
|
|
||||||
sm_root = self.cli.init_submenu("command")
|
|
||||||
_util.register_class_as_commands(self, sm_root, hub_machine.UT4ServerMachine)
|
|
||||||
|
|
||||||
sm_scrape = sm_root.create_submenu("scrape")
|
|
||||||
_util.register_class_as_commands(self, sm_scrape, scrape_latest.ScrapeUt4Pugs)
|
|
||||||
|
|
||||||
_util.register_class_as_commands(self, sm_scrape, scrape_latest.ScrapeUtcc)
|
|
||||||
|
|
||||||
_util.register_class_as_commands(self, sm_scrape, scrape_latest.LocalFs)
|
|
||||||
|
|
||||||
def _services(self):
|
|
||||||
self["model"] = lambda: hub_machine.UTServerMachine(self.ctx)
|
|
||||||
self.dal = datalayer.DataLayer(self.ctx)
|
|
||||||
self["dal"] = lambda: self.dal
|
|
||||||
self["datalayer"] = lambda: datalayer.DbFuncs(self.ctx, self.dal)
|
|
||||||
|
|
||||||
# self['localfs'] = lambda: datalayer.LocalFs(self.ctx, datalayer)
|
|
||||||
|
|
||||||
def interactive_shell(self):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def invoke_from_cli(self):
|
|
||||||
rc = self.load_command()
|
|
||||||
if not rc:
|
|
||||||
print("Invalid command. Try -h for usage")
|
|
||||||
return
|
|
||||||
# load config
|
|
||||||
self.invoke_command()
|
|
||||||
|
|
||||||
def usage(self):
|
|
||||||
s = """
|
|
||||||
Unreal Tournament 4 Server Build and Deploy Script
|
|
||||||
|
|
||||||
A list of commands is shown below.
|
|
||||||
|
|
||||||
List commands and usage:
|
|
||||||
./ut4-server-ctl.sh
|
|
||||||
|
|
||||||
Show help for a specific command:
|
|
||||||
./ut4-server-ctl.sh --help <COMMAND>
|
|
||||||
|
|
||||||
Here is the list of sub-commands with short syntax reminder:
|
|
||||||
./ut4-server-ctl.sh 1click-deploy
|
|
||||||
./ut4-server-ctl.sh clean-instance
|
|
||||||
./ut4-server-ctl.sh create-directories
|
|
||||||
./ut4-server-ctl.sh download-linux-server
|
|
||||||
./ut4-server-ctl.sh download-logs
|
|
||||||
./ut4-server-ctl.sh generate-instance
|
|
||||||
./ut4-server-ctl.sh start-server
|
|
||||||
./ut4-server-ctl.sh stop-server
|
|
||||||
./ut4-server-ctl.sh upload-redirects
|
|
||||||
./ut4-server-ctl.sh upload-server
|
|
||||||
|
|
||||||
Typical Usage:
|
|
||||||
1) You need to either configure the remote server hostnames (both game server and remote redirect server)
|
|
||||||
in the vars files. Edit vars with a text editor, or edit the defaults in this file, or manually type
|
|
||||||
them interactively when prompted* (coming soon).
|
|
||||||
|
|
||||||
e.g.:
|
|
||||||
PROJECT_DIR="/path/to/this/script/directory/on/local/machine"
|
|
||||||
REMOTE_GAME_HOST="54.123.456.10"
|
|
||||||
REMOTE_GAME_DIR="/home/ut4/hub-instance"
|
|
||||||
|
|
||||||
REMOTE_REDIRECT_HOST="45.321.654.10"
|
|
||||||
REMOTE_REDIRECT_DIR="/srv/ut4-redirect/"
|
|
||||||
|
|
||||||
2) You need to download the latest Linux Server release from Epic. Do
|
|
||||||
this with the 'download-server' command.
|
|
||||||
|
|
||||||
e.g.:
|
|
||||||
./ut4-server-ctl.sh download-server
|
|
||||||
|
|
||||||
3) Add and configure custom maps, mutators, rulesets, and hub configuration to your *local* project folder.
|
|
||||||
This is done by modifying the files in the project directory. With the current environment variables,
|
|
||||||
this is set to be:
|
|
||||||
|
|
||||||
"$PROJECT_DIR"
|
|
||||||
|
|
||||||
This is the fun part! Connect with UTCC or UTZONE.DE (unaffiliated) to find custom content to put on
|
|
||||||
your hub.
|
|
||||||
|
|
||||||
4) 1click-deploy to update the remote hub and redirect with your latest content. You're done! Rinse and repeat.
|
|
||||||
|
|
||||||
e.g.:
|
|
||||||
./ut4-server-ctl.sh 1click-deploy
|
|
||||||
|
|
||||||
Alternatively, this command is equivalent to running the separate commands. If you'd like more fine-grained
|
|
||||||
control, you can run them individually.
|
|
||||||
./ut4-server-ctl.sh generate-instance
|
|
||||||
./ut4-server-ctl.sh upload-redirects
|
|
||||||
./ut4-server-ctl.sh upload-server
|
|
||||||
./ut4-server-ctl.sh restart-server
|
|
||||||
"""
|
|
||||||
print(s)
|
|
||||||
|
|
||||||
|
|
||||||
def start_app():
|
def start_app():
|
||||||
app = SmileyFace()
|
cli()
|
||||||
app.invoke_from_cli()
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import click
|
||||||
|
|
||||||
|
from smileyface.context import AppContext
|
||||||
|
from smileyface.logging_setup import setup_logging
|
||||||
|
from smileyface.settings import AppSettings
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ctx():
|
||||||
|
"""Build the application context, datalayer, and command instances."""
|
||||||
|
from smileyface import datalayer, hub_machine, scrape_latest
|
||||||
|
|
||||||
|
settings = AppSettings()
|
||||||
|
setup_logging()
|
||||||
|
ctx = AppContext(settings)
|
||||||
|
dal = datalayer.DataLayer(ctx)
|
||||||
|
db = datalayer.DbFuncs(ctx, dal)
|
||||||
|
machine = hub_machine.UT4ServerMachine(ctx, db)
|
||||||
|
scrape_pugs = scrape_latest.ScrapeUt4Pugs(ctx, db)
|
||||||
|
scrape_utcc = scrape_latest.ScrapeUtcc(ctx, db)
|
||||||
|
local_fs = scrape_latest.LocalFs(ctx, db)
|
||||||
|
return machine, scrape_pugs, scrape_utcc, local_fs
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli():
|
||||||
|
"""SmileyFace UT4 Server Panel"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("oneclickdeploy")
|
||||||
|
def oneclickdeploy():
|
||||||
|
"""Generate instance, upload redirects, and upload server."""
|
||||||
|
machine, *_ = _make_ctx()
|
||||||
|
machine.oneclickdeploy()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("clean-instance")
|
||||||
|
def clean_instance():
|
||||||
|
"""Deletes the generated instance on the local machine."""
|
||||||
|
machine, *_ = _make_ctx()
|
||||||
|
machine.clean_instance()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("create-directories")
|
||||||
|
def create_directories():
|
||||||
|
"""Create required directories for maps, mutators, and config."""
|
||||||
|
machine, *_ = _make_ctx()
|
||||||
|
machine.create_directories()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("download-linux-server")
|
||||||
|
def download_linux_server():
|
||||||
|
"""Download the latest Linux UT4 Server from Epic."""
|
||||||
|
machine, *_ = _make_ctx()
|
||||||
|
machine.download_linux_server()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("download-logs")
|
||||||
|
def download_logs():
|
||||||
|
"""Download the logs from the target hub."""
|
||||||
|
machine, *_ = _make_ctx()
|
||||||
|
machine.download_logs()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("generate-instance")
|
||||||
|
def generate_instance():
|
||||||
|
"""Build local server instance from current configuration."""
|
||||||
|
machine, *_ = _make_ctx()
|
||||||
|
machine.generate_instance()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("restart-server")
|
||||||
|
def restart_server():
|
||||||
|
"""Restart the UT4 server."""
|
||||||
|
machine, *_ = _make_ctx()
|
||||||
|
machine.restart_server()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("start-server")
|
||||||
|
def start_server():
|
||||||
|
"""Start the UT4 server."""
|
||||||
|
machine, *_ = _make_ctx()
|
||||||
|
machine.start_server()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("stop-server")
|
||||||
|
def stop_server():
|
||||||
|
"""Stop the UT4 server."""
|
||||||
|
machine, *_ = _make_ctx()
|
||||||
|
machine.stop_server()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("upload-redirects")
|
||||||
|
def upload_redirects():
|
||||||
|
"""Upload paks to redirect server."""
|
||||||
|
machine, *_ = _make_ctx()
|
||||||
|
machine.upload_redirects()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command("upload-server")
|
||||||
|
def upload_server():
|
||||||
|
"""Upload game files to the hub server."""
|
||||||
|
machine, *_ = _make_ctx()
|
||||||
|
machine.upload_server()
|
||||||
|
|
||||||
|
|
||||||
|
@cli.group("scrape")
|
||||||
|
def scrape():
|
||||||
|
"""Web scraping subcommands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@scrape.command("ut4pugs")
|
||||||
|
def scrape_ut4pugs():
|
||||||
|
"""Check ut4pugs.us for latest content."""
|
||||||
|
_, pugs, *_ = _make_ctx()
|
||||||
|
pugs.check_ut4pugs_for_latest()
|
||||||
|
|
||||||
|
|
||||||
|
@scrape.command("utcc")
|
||||||
|
def scrape_utcc_cmd():
|
||||||
|
"""Check utcc.unrealpugs.com for latest content."""
|
||||||
|
*_, utcc, _ = _make_ctx()
|
||||||
|
utcc.check_ut4cc_for_latest()
|
||||||
|
|
||||||
|
|
||||||
|
@scrape.command("create-db-table")
|
||||||
|
def create_db_table():
|
||||||
|
"""Create database tables."""
|
||||||
|
*_, local_fs = _make_ctx()
|
||||||
|
local_fs.create_db_table()
|
||||||
|
|
||||||
|
|
||||||
|
@scrape.command("load-md5s")
|
||||||
|
def load_md5s():
|
||||||
|
"""Load MD5 checksums from local pak files."""
|
||||||
|
*_, local_fs = _make_ctx()
|
||||||
|
local_fs.load_md5s()
|
||||||
|
|
||||||
|
|
||||||
|
@scrape.command("print-invalid")
|
||||||
|
def print_invalid():
|
||||||
|
"""Print pak files that failed validation."""
|
||||||
|
*_, local_fs = _make_ctx()
|
||||||
|
local_fs.print_invalid_filepaks()
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
[app]
|
|
||||||
project_dir = string(max=255, default='')
|
|
||||||
config_dir = string(max=255, default='')
|
|
||||||
download_url = string(max=255, default='https://s3.amazonaws.com/unrealtournament/ShippedBuilds/%2B%2BUT%2BRelease-Next-CL-3525360/UnrealTournament-Server-XAN-3525360-Linux.zip')
|
|
||||||
download_filename = string(max=255, default='UnrealTournament-Server-XAN-3525360-Linux.zip')
|
|
||||||
download_md5 = string(max=255, default='cad730ad6793ba6261f9a341ad7396eb')
|
|
||||||
skip_validate = boolean(default=False)
|
|
||||||
redirect_protocol = string(max=255, default='')
|
|
||||||
redirect_url = string(max=255, default='')
|
|
||||||
remote_game_host = string(max=255, default='')
|
|
||||||
remote_game_dir = string(max=255, default='')
|
|
||||||
remote_redirect_host = string(max=255, default='')
|
|
||||||
|
|
||||||
sqlite_filename = string(max=255, default='smiles.db')
|
|
||||||
|
|
||||||
[logging]
|
|
||||||
log_file = string(max=255, default='')
|
|
||||||
log_level = option('critical', 'error', 'warning', 'info', 'debug', default='info')
|
|
||||||
log_fmt = string(max=255, default='')
|
|
||||||
disable_existing_loggers = boolean(default=False)
|
|
||||||
|
|
||||||
[[formatters]]
|
|
||||||
[[[colored]]]
|
|
||||||
() = string(default='colorlog.ColoredFormatter')
|
|
||||||
format = string(max=255, default='%(log_color)s%(levelname)-8s%(reset)s:%(log_color)s%(name)-5s%(reset)s:%(white)s%(message)s')
|
|
||||||
|
|
||||||
[[[basic]]]
|
|
||||||
() = string(max=255, default='logging.Formatter')
|
|
||||||
format = string(max=255, default='%(levelname)s:%(name)s:%(asctime)s:%(message)s')
|
|
||||||
|
|
||||||
[[[forstorage]]]
|
|
||||||
() = string(max=255, default='logging.Formatter')
|
|
||||||
format = string(max=255, default='%(levelname)s:%(name)s:%(asctime)s:%(message)s')
|
|
||||||
|
|
||||||
[[handlers]]
|
|
||||||
[[[stderr]]]
|
|
||||||
class = string(max=255, default='logging.StreamHandler')
|
|
||||||
level = option('critical', 'error', 'warning', 'info', 'debug', default='debug')
|
|
||||||
formatter = string(max=255, default='colored')
|
|
||||||
|
|
||||||
[[[file]]]
|
|
||||||
class = string(max=255, default='logging.handlers.RotatingFileHandler')
|
|
||||||
level = option('critical', 'error', 'warning', 'info', 'debug', default='warning')
|
|
||||||
formatter = string(max=255, default='forstorage')
|
|
||||||
filename = string(max=255, default='cas_admin.log')
|
|
||||||
maxBytes = integer(min=0, max=33554432, default=33554432)
|
|
||||||
backupCount = integer(min=0, max=3, default=1)
|
|
||||||
|
|
||||||
[[loggers]]
|
|
||||||
[[[root]]]
|
|
||||||
level = option('critical', 'error', 'warning', 'info', 'debug', default='debug')
|
|
||||||
handlers = string_list(max=8, default=list('file',))
|
|
||||||
|
|
||||||
[[[ut4]]]
|
|
||||||
level = option('critical', 'error', 'warning', 'info', 'debug', default='debug')
|
|
||||||
handlers = string_list(max=8, default=list('stderr',))
|
|
||||||
propagate = boolean(default=True)
|
|
||||||
|
|
||||||
[[[db]]]
|
|
||||||
level = option('critical', 'error', 'warning', 'info', 'debug', default='debug')
|
|
||||||
handlers = string_list(max=8, default=list('stderr',))
|
|
||||||
propagate = boolean(default=True)
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from smileyface.settings import AppSettings
|
||||||
|
|
||||||
|
|
||||||
|
class AppContext:
|
||||||
|
def __init__(self, settings: AppSettings):
|
||||||
|
self.settings = settings
|
||||||
|
self.log = _LoggerDict()
|
||||||
|
|
||||||
|
|
||||||
|
class _LoggerDict:
|
||||||
|
"""Dict-like logger access: ctx.log["ut4"] -> logging.getLogger("ut4")"""
|
||||||
|
|
||||||
|
def __getitem__(self, name: str) -> logging.Logger:
|
||||||
|
return logging.getLogger(name)
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
import appdirs
|
import platformdirs
|
||||||
|
|
||||||
from smileyface import myutil
|
from smileyface import myutil
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@ class DataLayer:
|
|||||||
return self._db_conn
|
return self._db_conn
|
||||||
|
|
||||||
def _create_db_connection(self):
|
def _create_db_connection(self):
|
||||||
local_db_filename = self.ctx.config["app"]["sqlite_filename"]
|
local_db_filename = self.ctx.settings.sqlite_filename
|
||||||
appdir = appdirs.user_data_dir("smileyface")
|
appdir = platformdirs.user_data_dir("smileyface")
|
||||||
fullpath = os.path.join(appdir, local_db_filename)
|
fullpath = os.path.join(appdir, local_db_filename)
|
||||||
self.ctx.log["ut4"].info("sqlite3 filename: %s", fullpath)
|
self.ctx.log["ut4"].info("sqlite3 filename: %s", fullpath)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import app_skellington._util as apputil
|
try:
|
||||||
import appdirs
|
from importlib.resources import files # Python 3.9+
|
||||||
|
except ImportError: # Python 3.8
|
||||||
|
from importlib_resources import files
|
||||||
|
|
||||||
import sqlparse
|
import sqlparse
|
||||||
|
|
||||||
from smileyface import myutil, structs
|
from smileyface import myutil, structs
|
||||||
@@ -14,8 +17,8 @@ class DbFuncs:
|
|||||||
self.dal = dal
|
self.dal = dal
|
||||||
|
|
||||||
def create_tables(self):
|
def create_tables(self):
|
||||||
sql_filename = apputil.get_asset(__name__, "create_schema.sql")
|
sql_ref = files("smileyface.datalayer").joinpath("create_schema.sql")
|
||||||
with open(sql_filename) as fp:
|
with sql_ref.open("r") as fp:
|
||||||
contents_sql = fp.read()
|
contents_sql = fp.read()
|
||||||
stmts = sqlparse.split(contents_sql)
|
stmts = sqlparse.split(contents_sql)
|
||||||
|
|
||||||
|
|||||||
+27
-29
@@ -8,8 +8,6 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import configobj
|
|
||||||
|
|
||||||
from . import myutil, structs
|
from . import myutil, structs
|
||||||
from ._util import md5sum_file
|
from ._util import md5sum_file
|
||||||
from .gameconfig_edit import GameIniSpecial, UnrealIniFile
|
from .gameconfig_edit import GameIniSpecial, UnrealIniFile
|
||||||
@@ -28,7 +26,7 @@ class UT4ServerMachine:
|
|||||||
self.upload_redirects()
|
self.upload_redirects()
|
||||||
self.upload_server()
|
self.upload_server()
|
||||||
|
|
||||||
def clean_instance(self, x):
|
def clean_instance(self):
|
||||||
"""
|
"""
|
||||||
Deletes the generated instance on the local machine.
|
Deletes the generated instance on the local machine.
|
||||||
"""
|
"""
|
||||||
@@ -41,7 +39,7 @@ class UT4ServerMachine:
|
|||||||
Create required directories which the user installs maps, mutators, and config to.
|
Create required directories which the user installs maps, mutators, and config to.
|
||||||
"""
|
"""
|
||||||
dirs = ("base", "files/config", "files/maps", "files/mutators", "files/rulesets", "files/unused")
|
dirs = ("base", "files/config", "files/maps", "files/mutators", "files/rulesets", "files/unused")
|
||||||
project_dir = self.ctx.config["app"]["project_dir"]
|
project_dir = self.ctx.settings.project_dir
|
||||||
if len(project_dir.strip()) == 0:
|
if len(project_dir.strip()) == 0:
|
||||||
project_dir = "."
|
project_dir = "."
|
||||||
print("project_dir:", project_dir)
|
print("project_dir:", project_dir)
|
||||||
@@ -51,7 +49,7 @@ class UT4ServerMachine:
|
|||||||
cmd = "mkdir -p {}".format(fp)
|
cmd = "mkdir -p {}".format(fp)
|
||||||
self._invoke_command(cmd)
|
self._invoke_command(cmd)
|
||||||
|
|
||||||
def download_linux_server(self, x):
|
def download_linux_server(self):
|
||||||
"""
|
"""
|
||||||
Download the latest Linux Unreal Tournament 4 Server from Epic
|
Download the latest Linux Unreal Tournament 4 Server from Epic
|
||||||
"""
|
"""
|
||||||
@@ -61,9 +59,9 @@ class UT4ServerMachine:
|
|||||||
"""
|
"""
|
||||||
Download the logs from the target hub.
|
Download the logs from the target hub.
|
||||||
"""
|
"""
|
||||||
config_dir = self.ctx.config["app"]["config_dir"]
|
config_dir = self.ctx.settings.config_dir
|
||||||
remote_game_host = self.ctx.config["app"]["remote_game_host"]
|
remote_game_host = self.ctx.settings.remote_game_host
|
||||||
remote_game_dir = self.ctx.config["app"]["remote_game_dir"]
|
remote_game_dir = self.ctx.settings.remote_game_dir
|
||||||
|
|
||||||
self.ctx.log["ut4"].info("Downloading instance logs from target hub.")
|
self.ctx.log["ut4"].info("Downloading instance logs from target hub.")
|
||||||
cmd = """
|
cmd = """
|
||||||
@@ -88,7 +86,7 @@ ssh {remote_game_host} rm {remote_game_dir}/LinuxServer/UnrealTournament/Saved/L
|
|||||||
can be copied to the server.
|
can be copied to the server.
|
||||||
"""
|
"""
|
||||||
self.ctx.log["ut4"].info("Generating server instance from custom files...")
|
self.ctx.log["ut4"].info("Generating server instance from custom files...")
|
||||||
project_dir = self.ctx.config["app"]["project_dir"]
|
project_dir = self.ctx.settings.project_dir
|
||||||
|
|
||||||
# rsync
|
# rsync
|
||||||
src = "/".join([project_dir, "base/LinuxServer"])
|
src = "/".join([project_dir, "base/LinuxServer"])
|
||||||
@@ -146,10 +144,10 @@ ssh {remote_game_host} {remote_game_dir}/stop-server.sh
|
|||||||
"""
|
"""
|
||||||
self.ctx.log["ut4"].info("Uploading redirects (maps, mutators, etc.) to target hub.")
|
self.ctx.log["ut4"].info("Uploading redirects (maps, mutators, etc.) to target hub.")
|
||||||
|
|
||||||
project_dir = self.ctx.config["app"]["project_dir"]
|
project_dir = self.ctx.settings.project_dir
|
||||||
# paks_dir = os.path.join(project_dir, 'instance/LinuxServer/UnrealTournament/Content/Paks/')
|
# paks_dir = os.path.join(project_dir, 'instance/LinuxServer/UnrealTournament/Content/Paks/')
|
||||||
paks_dir = os.path.join(project_dir, "files/") # trailing slash required
|
paks_dir = os.path.join(project_dir, "files/") # trailing slash required
|
||||||
remote_redirect_host = self.ctx.config["app"]["remote_redirect_host"]
|
remote_redirect_host = self.ctx.settings.remote_redirect_host
|
||||||
cwd = project_dir
|
cwd = project_dir
|
||||||
cmd = """
|
cmd = """
|
||||||
rsync -rivz \
|
rsync -rivz \
|
||||||
@@ -174,8 +172,8 @@ rsync -rivz \
|
|||||||
self._redirect_chown()
|
self._redirect_chown()
|
||||||
|
|
||||||
def _redirect_hide_passwords(self):
|
def _redirect_hide_passwords(self):
|
||||||
project_dir = self.ctx.config["app"]["project_dir"]
|
project_dir = self.ctx.settings.project_dir
|
||||||
remote_redirect_host = self.ctx.config["app"]["remote_redirect_host"]
|
remote_redirect_host = self.ctx.settings.remote_redirect_host
|
||||||
# (on the server):
|
# (on the server):
|
||||||
gameini = "/srv/ut4-redirect.zavage.net/config/Game.ini"
|
gameini = "/srv/ut4-redirect.zavage.net/config/Game.ini"
|
||||||
engineini = "/srv/ut4-redirect.zavage.net/config/Engine.ini"
|
engineini = "/srv/ut4-redirect.zavage.net/config/Engine.ini"
|
||||||
@@ -197,8 +195,8 @@ ssh mathewguest.com \
|
|||||||
self._invoke_command(cmd)
|
self._invoke_command(cmd)
|
||||||
|
|
||||||
def _redirect_upload_script(self):
|
def _redirect_upload_script(self):
|
||||||
project_dir = self.ctx.config["app"]["project_dir"]
|
project_dir = self.ctx.settings.project_dir
|
||||||
remote_redirect_host = self.ctx.config["app"]["remote_redirect_host"]
|
remote_redirect_host = self.ctx.settings.remote_redirect_host
|
||||||
cmd = """
|
cmd = """
|
||||||
rsync -vz \
|
rsync -vz \
|
||||||
{project_dir}/ut4-server-ctl.sh \
|
{project_dir}/ut4-server-ctl.sh \
|
||||||
@@ -213,8 +211,8 @@ rsync -vz \
|
|||||||
self._invoke_command(cmd)
|
self._invoke_command(cmd)
|
||||||
|
|
||||||
def _redirect_chown(self):
|
def _redirect_chown(self):
|
||||||
project_dir = self.ctx.config["app"]["project_dir"]
|
project_dir = self.ctx.settings.project_dir
|
||||||
remote_redirect_host = self.ctx.config["app"]["remote_redirect_host"]
|
remote_redirect_host = self.ctx.settings.remote_redirect_host
|
||||||
|
|
||||||
cmd = """
|
cmd = """
|
||||||
ssh mathewguest.com \
|
ssh mathewguest.com \
|
||||||
@@ -227,9 +225,9 @@ ssh mathewguest.com \
|
|||||||
Upload all required game files to the hub server.
|
Upload all required game files to the hub server.
|
||||||
"""
|
"""
|
||||||
self.ctx.log["ut4"].info("Uploading customized server")
|
self.ctx.log["ut4"].info("Uploading customized server")
|
||||||
project_dir = self.ctx.config["app"]["project_dir"]
|
project_dir = self.ctx.settings.project_dir
|
||||||
remote_game_host = self.ctx.config["app"]["remote_game_host"]
|
remote_game_host = self.ctx.settings.remote_game_host
|
||||||
remote_game_dir = self.ctx.config["app"]["remote_game_dir"]
|
remote_game_dir = self.ctx.settings.remote_game_dir
|
||||||
cwd = None
|
cwd = None
|
||||||
|
|
||||||
# transfer #1
|
# transfer #1
|
||||||
@@ -304,7 +302,7 @@ ssh {remote_game_host} \
|
|||||||
|
|
||||||
# Make binary executable:
|
# Make binary executable:
|
||||||
bin_name = "UE4Server-Linux-Shipping"
|
bin_name = "UE4Server-Linux-Shipping"
|
||||||
project_dir = self.ctx.config["app"]["project_dir"]
|
project_dir = self.ctx.settings.project_dir
|
||||||
|
|
||||||
cwd = "{project_dir}/instance/LinuxServer/Engine/Binaries/Linux".format(project_dir=project_dir)
|
cwd = "{project_dir}/instance/LinuxServer/Engine/Binaries/Linux".format(project_dir=project_dir)
|
||||||
target_file = "{cwd}/{bin_name}".format(cwd=cwd, bin_name=bin_name)
|
target_file = "{cwd}/{bin_name}".format(cwd=cwd, bin_name=bin_name)
|
||||||
@@ -325,8 +323,8 @@ ssh {remote_game_host} \
|
|||||||
|
|
||||||
def _install_config(self):
|
def _install_config(self):
|
||||||
files = ("Game.ini", "Engine.ini")
|
files = ("Game.ini", "Engine.ini")
|
||||||
project_dir = self.ctx.config["app"]["project_dir"]
|
project_dir = self.ctx.settings.project_dir
|
||||||
config_dir = self.ctx.config["app"]["config_dir"]
|
config_dir = self.ctx.settings.config_dir
|
||||||
for fn in files:
|
for fn in files:
|
||||||
self.ctx.log["ut4"].info("Installing file: %s", fn)
|
self.ctx.log["ut4"].info("Installing file: %s", fn)
|
||||||
src = os.path.join(config_dir, fn)
|
src = os.path.join(config_dir, fn)
|
||||||
@@ -347,7 +345,7 @@ ssh {remote_game_host} \
|
|||||||
ini._config.write(fp)
|
ini._config.write(fp)
|
||||||
|
|
||||||
def _install_paks(self):
|
def _install_paks(self):
|
||||||
project_dir = self.ctx.config["app"]["project_dir"]
|
project_dir = self.ctx.settings.project_dir
|
||||||
|
|
||||||
self.ctx.log["ut4"].info("Installing maps...")
|
self.ctx.log["ut4"].info("Installing maps...")
|
||||||
cmd = "rsync -ravzp {src} {dst}".format(
|
cmd = "rsync -ravzp {src} {dst}".format(
|
||||||
@@ -370,9 +368,9 @@ ssh {remote_game_host} \
|
|||||||
def _install_redirect_lines(self):
|
def _install_redirect_lines(self):
|
||||||
self.ctx.log["ut4"].info("Generating redirect references...")
|
self.ctx.log["ut4"].info("Generating redirect references...")
|
||||||
|
|
||||||
redirect_protocol = self.ctx.config["app"]["redirect_protocol"]
|
redirect_protocol = self.ctx.settings.redirect_protocol
|
||||||
redirect_url = self.ctx.config["app"]["redirect_url"]
|
redirect_url = self.ctx.settings.redirect_url
|
||||||
project_dir = self.ctx.config["app"]["project_dir"]
|
project_dir = self.ctx.settings.project_dir
|
||||||
mod_dir = "/".join([project_dir, "files"])
|
mod_dir = "/".join([project_dir, "files"])
|
||||||
|
|
||||||
game_ini_filepath = "/".join(
|
game_ini_filepath = "/".join(
|
||||||
@@ -415,7 +413,7 @@ ssh {remote_game_host} \
|
|||||||
|
|
||||||
def _install_rulesets(self):
|
def _install_rulesets(self):
|
||||||
self.ctx.log["ut4"].info("Concatenating rulesets for game modes...")
|
self.ctx.log["ut4"].info("Concatenating rulesets for game modes...")
|
||||||
project_dir = self.ctx.config["app"]["project_dir"]
|
project_dir = self.ctx.settings.project_dir
|
||||||
|
|
||||||
src_dir = "/".join([project_dir, "files/rulesets"])
|
src_dir = "/".join([project_dir, "files/rulesets"])
|
||||||
out_dir = "/".join([project_dir, "/instance/LinuxServer/UnrealTournament/Saved/Config/Rulesets"])
|
out_dir = "/".join([project_dir, "/instance/LinuxServer/UnrealTournament/Saved/Config/Rulesets"])
|
||||||
@@ -464,7 +462,7 @@ ssh {remote_game_host} \
|
|||||||
"remote_redirect_host",
|
"remote_redirect_host",
|
||||||
)
|
)
|
||||||
for name in variable_names:
|
for name in variable_names:
|
||||||
value = self.ctx.config["app"][name]
|
value = getattr(self.ctx.settings, name)
|
||||||
self.ctx.log["ut4"].info("%s: %s", name, value)
|
self.ctx.log["ut4"].info("%s: %s", name, value)
|
||||||
|
|
||||||
i = input("Continue with above configuration? (y/N):")
|
i = input("Continue with above configuration? (y/N):")
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(log_file: str = ""):
|
||||||
|
config = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"colored": {
|
||||||
|
"format": "%(levelname)-8s:%(name)-5s:%(message)s",
|
||||||
|
},
|
||||||
|
"basic": {
|
||||||
|
"format": "%(levelname)s:%(name)s:%(asctime)s:%(message)s",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"stderr": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"level": "DEBUG",
|
||||||
|
"formatter": "colored",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"loggers": {
|
||||||
|
"ut4": {"level": "DEBUG", "handlers": ["stderr"], "propagate": True},
|
||||||
|
"db": {"level": "DEBUG", "handlers": ["stderr"], "propagate": True},
|
||||||
|
},
|
||||||
|
"root": {"level": "DEBUG", "handlers": []},
|
||||||
|
}
|
||||||
|
if log_file:
|
||||||
|
config["handlers"]["file"] = {
|
||||||
|
"class": "logging.handlers.RotatingFileHandler",
|
||||||
|
"level": "WARNING",
|
||||||
|
"formatter": "basic",
|
||||||
|
"filename": log_file,
|
||||||
|
"maxBytes": 33554432,
|
||||||
|
"backupCount": 1,
|
||||||
|
}
|
||||||
|
config["root"]["handlers"].append("file")
|
||||||
|
|
||||||
|
logging.config.dictConfig(config)
|
||||||
@@ -13,7 +13,7 @@ class LocalFs:
|
|||||||
self.datalayer.create_tables()
|
self.datalayer.create_tables()
|
||||||
|
|
||||||
def load_md5s(self):
|
def load_md5s(self):
|
||||||
paks_dir = self.ctx.config["app"]["project_dir"]
|
paks_dir = self.ctx.settings.project_dir
|
||||||
maps_dir = os.path.join(paks_dir, "files", "maps")
|
maps_dir = os.path.join(paks_dir, "files", "maps")
|
||||||
print(maps_dir)
|
print(maps_dir)
|
||||||
self._load_md5_one_dir(maps_dir)
|
self._load_md5_one_dir(maps_dir)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import selenium
|
import selenium
|
||||||
import selenium.webdriver
|
import selenium.webdriver
|
||||||
|
from selenium.webdriver.common.by import By
|
||||||
|
|
||||||
URL_MUTATORS = "https://ut4pugs.us/redirect-mutators"
|
URL_MUTATORS = "https://ut4pugs.us/redirect-mutators"
|
||||||
URL_MAPS = "https://ut4pugs.us/redirect-mutators"
|
URL_MAPS = "https://ut4pugs.us/redirect-mutators"
|
||||||
@@ -25,12 +26,12 @@ class ScrapeUt4Pugs:
|
|||||||
self._check_pak_md5sums()
|
self._check_pak_md5sums()
|
||||||
|
|
||||||
def _check_pak_md5sums(self):
|
def _check_pak_md5sums(self):
|
||||||
tbl_of_mutators = self.browser.find_element_by_id("myTable")
|
tbl_of_mutators = self.browser.find_element(By.ID, "myTable")
|
||||||
print(tbl_of_mutators)
|
print(tbl_of_mutators)
|
||||||
|
|
||||||
mut_rows = tbl_of_mutators.find_elements_by_xpath("tbody/tr")
|
mut_rows = tbl_of_mutators.find_elements(By.XPATH, "tbody/tr")
|
||||||
for r in mut_rows:
|
for r in mut_rows:
|
||||||
mut_cols = r.find_elements_by_xpath("td")
|
mut_cols = r.find_elements(By.XPATH, "td")
|
||||||
if len(mut_cols) != 3:
|
if len(mut_cols) != 3:
|
||||||
input("<breakpoint> at unexpected columns for mutator. received {}".format(len(mut_cols)))
|
input("<breakpoint> at unexpected columns for mutator. received {}".format(len(mut_cols)))
|
||||||
mut_file = mut_cols[0]
|
mut_file = mut_cols[0]
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class AppSettings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="SMILEYFACE_",
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
project_dir: str = ""
|
||||||
|
config_dir: str = ""
|
||||||
|
download_url: str = ""
|
||||||
|
download_filename: str = ""
|
||||||
|
download_md5: str = ""
|
||||||
|
skip_validate: bool = False
|
||||||
|
redirect_protocol: str = ""
|
||||||
|
redirect_url: str = ""
|
||||||
|
remote_game_host: str = ""
|
||||||
|
remote_game_dir: str = ""
|
||||||
|
remote_redirect_host: str = ""
|
||||||
|
sqlite_filename: str = "smiles.db"
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from smileyface.cli import cli
|
||||||
|
|
||||||
|
|
||||||
|
def test_root_help():
|
||||||
|
result = CliRunner().invoke(cli, ["--help"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "SmileyFace" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_command_help():
|
||||||
|
runner = CliRunner()
|
||||||
|
for name in cli.commands:
|
||||||
|
result = runner.invoke(cli, [name, "--help"])
|
||||||
|
assert result.exit_code == 0, f"`{name} --help` failed:\n{result.output}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrape_subcommands_help():
|
||||||
|
runner = CliRunner()
|
||||||
|
scrape_group = cli.commands["scrape"]
|
||||||
|
for name in scrape_group.commands:
|
||||||
|
result = runner.invoke(cli, ["scrape", name, "--help"])
|
||||||
|
assert result.exit_code == 0, f"`scrape {name} --help` failed:\n{result.output}"
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import importlib
|
||||||
|
import pkgutil
|
||||||
|
|
||||||
|
import smileyface
|
||||||
|
|
||||||
|
|
||||||
|
def test_top_level_package_imports():
|
||||||
|
importlib.import_module("smileyface")
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_submodules_import():
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def _on_walk_error(name):
|
||||||
|
errors.append(f"{name}: failed during package walk")
|
||||||
|
|
||||||
|
module_names = [
|
||||||
|
info.name
|
||||||
|
for info in pkgutil.walk_packages(
|
||||||
|
smileyface.__path__,
|
||||||
|
prefix="smileyface.",
|
||||||
|
onerror=_on_walk_error,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
assert module_names, "walk_packages found no submodules - check smileyface.__path__"
|
||||||
|
|
||||||
|
for name in module_names:
|
||||||
|
try:
|
||||||
|
importlib.import_module(name)
|
||||||
|
except Exception as exc: # noqa: BLE001 - we want every failure, not the first
|
||||||
|
errors.append(f"{name}: {exc!r}")
|
||||||
|
|
||||||
|
assert not errors, "Modules failed to import:\n" + "\n".join(errors)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from smileyface.settings import AppSettings
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_smileyface_env(monkeypatch):
|
||||||
|
for key in list(os.environ):
|
||||||
|
if key.startswith("SMILEYFACE_"):
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_defaults(monkeypatch):
|
||||||
|
_clear_smileyface_env(monkeypatch)
|
||||||
|
settings = AppSettings(_env_file=None)
|
||||||
|
assert settings.sqlite_filename == "smiles.db"
|
||||||
|
assert settings.skip_validate is False
|
||||||
|
assert settings.project_dir == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_overrides(monkeypatch):
|
||||||
|
_clear_smileyface_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("SMILEYFACE_PROJECT_DIR", "/srv/ut4")
|
||||||
|
monkeypatch.setenv("SMILEYFACE_SKIP_VALIDATE", "true")
|
||||||
|
settings = AppSettings(_env_file=None)
|
||||||
|
assert settings.project_dir == "/srv/ut4"
|
||||||
|
assert settings.skip_validate is True
|
||||||
Reference in New Issue
Block a user