Author SHA1 Message Date
Mathew Sir Guest the best 0c8fa30145 build: cleanup for new selenium and guards
tests / test (3.8) (push) Canceled after 0s
tests / test (3.9) (push) Canceled after 0s
tests / test (3.10) (push) Canceled after 0s
tests / test (3.11) (push) Canceled after 0s
tests / test (3.12) (push) Canceled after 0s
tests / test (3.13) (push) Canceled after 0s
2026-05-31 01:56:37 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 bb7325008b docs: replace stale argparse usage dump with Click command reference
The Usage section showed the old argparse `./smileyface.py -h` output
(underscored command names, 'optional arguments', a stray 'Invalid
command' line) from before the Click rewrite. Replace it with the
actual Click command list - server build/deploy commands and the
scrape subgroup - plus a note on env/.env configuration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:19:30 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 a2d4b334d7 Merge branch 'python-version-support'
Add Python 3.8-3.13 multi-version support: pytest smoke-test suite,
uv+nox version matrix, importlib_resources backport for 3.8, declared
the previously-undeclared selenium dependency, Gitea Actions CI matrix,
and supporting docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:05:14 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 84e7639bf1 ci: run pytest via uv run so the matrix interpreter is honored
Address code review: the previous uv venv + uv pip + uv run sequence could run pytest in a different environment than the matrix Python. Use a single uv run --python ... --with . --with pytest invocation (mirrors noxfile). Also correct the stale Black target-version note in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 00:04:34 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 6931e91391 test: guard import smoke test against vacuous pass on empty walk
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 23:36:52 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 1cf9a5e6c4 docs: document supported Python versions and the test matrix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:49:30 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 d1420dccda ci: add Gitea Actions Python version matrix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:48:51 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 e91c7344d8 build: lock in Python 3.8+ support (classifiers, black targets)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:48:26 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 bcd5f41f4a chore: ignore spurious uv.lock (project uses poetry.lock)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:42:37 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 1a5530f1db compat: fall back to importlib_resources backport on Python 3.8
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:37:08 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 141eb6f09e build: add nox matrix runner for the Python version smoke suite
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:36:41 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 9d02a06620 build: temporarily widen python constraint to >=3.8 for version discovery
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:36:20 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 a63935e17d test: add AppSettings defaults and env-parsing smoke tests
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:32:53 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 f6bbb550d8 test: add CLI --help smoke tests for every command
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:32:34 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 42b7a329c9 fix: declare selenium as a runtime dependency
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:32:13 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 f021f1c1b0 test: add import smoke test (reveals undeclared selenium dep)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:31:56 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 ccd7f2793f chore: ignore .claude/worktrees and local settings
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:29:41 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 dc52886b0e docs: add Python version support matrix implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 22:29:41 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 4b3e4d1e52 docs: add CLAUDE.md guidance and refresh README
Document the project overview, commands, code style, and architecture
in CLAUDE.md, update the README for the Poetry/Click workflow, and add
local Claude Code permission settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 21:25:09 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 709e6f25fa refactor: replace configobj spec with pydantic-settings and Click CLI
Drop the config.spec/configobj configuration in favour of an
AppSettings pydantic-settings model (settings.py) loaded from
SMILEYFACE_-prefixed env vars or a .env file. Introduce AppContext
(context.py) to carry settings plus logging, dispatch commands through
a Click CLI (cli.py), and centralise log setup (logging_setup.py).
Update hub_machine, datalayer, and scraping modules to consume the new
context. Add .env.example and ignore .env.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:43:38 -06:00
Mathew Sir Guest the bestandClaude Opus 4.8 183fa960c3 build: migrate to Poetry and Python 3.13
Replace setuptools/setup.py with Poetry as the build backend and
dependency manager. Bump the target runtime from Python 3.8 to 3.13
across .python-version, pre-commit, and the black/isort config.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 19:43:32 -06:00
zavage 6251066df9 Merge pull request 'doc-1/landing-page' (#1) from doc-1/landing-page into master
Reviewed-on: https://git.zavage.net/Zavage-Software/smileyface/pulls/1
2024-11-15 05:25:44 -07:00
27 changed files with 2994 additions and 490 deletions
+15
View File
@@ -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
+21
View File
@@ -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
View File
@@ -4,4 +4,7 @@ dist/
__pycache__
*.egg-info
idea
.env
.claude/settings.local.json
.claude/worktrees/
uv.lock
+1 -1
View File
@@ -28,7 +28,7 @@ repos:
rev: 24.2.0
hooks:
- id: black
language_version: python3.8
language_version: python3.13
# Poetry
- repo: https://github.com/python-poetry/poetry
+1 -1
View File
@@ -1 +1 @@
3.8.19
3.13
+63
View File
@@ -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.83.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.
+45 -27
View File
@@ -3,45 +3,63 @@
See official website, [https://zavage-software.com/portfolio/smileyface](https://zavage-software.com/portfolio/smileyface) for instructions.
# Dependencies
* [app_skellington](https;//zavage-software.com/projects/app_skellington)
* [appdirs](https://pypi.org/project/appdirs)
* [colorlog](https://pypi.org/project/colorlog)
* [configobj](https://pypi.org/project/configob)
* [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)
* [click](https://click.palletsprojects.com)
* [platformdirs](https://pypi.org/project/platformdirs)
* [selenium](https://selenium-python.readthedocs.io)
* [sqlparse](https://pypi.org/project/sqlparse)
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
======
SmileyFace exposes a [Click](https://click.palletsprojects.com) CLI. List all
commands and options with:
$ ./smileyface.py -h
usage: smileyface.py [-h] command ...
python smileyface.py --help
positional arguments:
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
Show help for a specific command:
optional arguments:
-h, --help show this help message and exit
Invalid command. Try -h for usage
python smileyface.py generate-instance --help
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
* 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.93.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.83.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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+22 -6
View File
@@ -11,6 +11,14 @@ homepage = "https://zavage-software.com/portfolio/smileyface"
repository = "https://git-mirror.zavage.net/zavage-software/smileyface"
documentation = "https://git-mirror.zavage.net/zavage-software/smileyface"
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" }]
include = [
@@ -21,17 +29,21 @@ include = [
[tool.poetry.dependencies]
python = "^3.8"
app_skellington = "*"
configobj = "*"
colorlog = "*"
appdirs = "*"
python = ">=3.8,<4.0"
pydantic-settings = ">=2.0"
click = ">=8.0"
platformdirs = ">=3.0"
sqlparse = "*"
selenium = ">=4.0"
importlib-resources = { version = ">=5.0", python = "<3.9" }
[tool.poetry.group.dev.dependencies]
black = "*"
pre-commit = "*"
isort = "*"
flake8 = "*"
pytest = "*"
nox = "*"
#Sphinx = "^5.3.0"
#sphinx-rtd-theme = "^1.3.0"
@@ -41,7 +53,7 @@ build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 120
target-version = ['py38']
target-version = ['py38', 'py39', 'py310', 'py311', 'py312', 'py313']
[tool.isort]
multi_line_output = 3
@@ -49,3 +61,7 @@ combine_as_imports = true
include_trailing_comma = true
force_grid_wrap = 3
ensure_newline_before_comments = true
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-ra"
-25
View File
@@ -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"},
)
-38
View File
@@ -1,42 +1,4 @@
import logging
import sys
# Module parameters and constants
APP_NAME = "SmileyFace Unreal Tournament 4 Server Panel"
APP_AUTHOR = "Mathew Guest"
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
+2 -127
View File
@@ -1,130 +1,5 @@
import app_skellington
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)
from .cli import cli
def start_app():
app = SmileyFace()
app.invoke_from_cli()
cli()
+145
View File
@@ -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()
-62
View File
@@ -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)
+16
View File
@@ -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)
+3 -3
View File
@@ -1,7 +1,7 @@
import os
import sqlite3
import appdirs
import platformdirs
from smileyface import myutil
@@ -18,8 +18,8 @@ class DataLayer:
return self._db_conn
def _create_db_connection(self):
local_db_filename = self.ctx.config["app"]["sqlite_filename"]
appdir = appdirs.user_data_dir("smileyface")
local_db_filename = self.ctx.settings.sqlite_filename
appdir = platformdirs.user_data_dir("smileyface")
fullpath = os.path.join(appdir, local_db_filename)
self.ctx.log["ut4"].info("sqlite3 filename: %s", fullpath)
+7 -4
View File
@@ -1,8 +1,11 @@
import datetime
import os
import app_skellington._util as apputil
import appdirs
try:
from importlib.resources import files # Python 3.9+
except ImportError: # Python 3.8
from importlib_resources import files
import sqlparse
from smileyface import myutil, structs
@@ -14,8 +17,8 @@ class DbFuncs:
self.dal = dal
def create_tables(self):
sql_filename = apputil.get_asset(__name__, "create_schema.sql")
with open(sql_filename) as fp:
sql_ref = files("smileyface.datalayer").joinpath("create_schema.sql")
with sql_ref.open("r") as fp:
contents_sql = fp.read()
stmts = sqlparse.split(contents_sql)
+27 -29
View File
@@ -8,8 +8,6 @@ import subprocess
import sys
import time
import configobj
from . import myutil, structs
from ._util import md5sum_file
from .gameconfig_edit import GameIniSpecial, UnrealIniFile
@@ -28,7 +26,7 @@ class UT4ServerMachine:
self.upload_redirects()
self.upload_server()
def clean_instance(self, x):
def clean_instance(self):
"""
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.
"""
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:
project_dir = "."
print("project_dir:", project_dir)
@@ -51,7 +49,7 @@ class UT4ServerMachine:
cmd = "mkdir -p {}".format(fp)
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
"""
@@ -61,9 +59,9 @@ class UT4ServerMachine:
"""
Download the logs from the target hub.
"""
config_dir = self.ctx.config["app"]["config_dir"]
remote_game_host = self.ctx.config["app"]["remote_game_host"]
remote_game_dir = self.ctx.config["app"]["remote_game_dir"]
config_dir = self.ctx.settings.config_dir
remote_game_host = self.ctx.settings.remote_game_host
remote_game_dir = self.ctx.settings.remote_game_dir
self.ctx.log["ut4"].info("Downloading instance logs from target hub.")
cmd = """
@@ -88,7 +86,7 @@ ssh {remote_game_host} rm {remote_game_dir}/LinuxServer/UnrealTournament/Saved/L
can be copied to the server.
"""
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
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.")
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, "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
cmd = """
rsync -rivz \
@@ -174,8 +172,8 @@ rsync -rivz \
self._redirect_chown()
def _redirect_hide_passwords(self):
project_dir = self.ctx.config["app"]["project_dir"]
remote_redirect_host = self.ctx.config["app"]["remote_redirect_host"]
project_dir = self.ctx.settings.project_dir
remote_redirect_host = self.ctx.settings.remote_redirect_host
# (on the server):
gameini = "/srv/ut4-redirect.zavage.net/config/Game.ini"
engineini = "/srv/ut4-redirect.zavage.net/config/Engine.ini"
@@ -197,8 +195,8 @@ ssh mathewguest.com \
self._invoke_command(cmd)
def _redirect_upload_script(self):
project_dir = self.ctx.config["app"]["project_dir"]
remote_redirect_host = self.ctx.config["app"]["remote_redirect_host"]
project_dir = self.ctx.settings.project_dir
remote_redirect_host = self.ctx.settings.remote_redirect_host
cmd = """
rsync -vz \
{project_dir}/ut4-server-ctl.sh \
@@ -213,8 +211,8 @@ rsync -vz \
self._invoke_command(cmd)
def _redirect_chown(self):
project_dir = self.ctx.config["app"]["project_dir"]
remote_redirect_host = self.ctx.config["app"]["remote_redirect_host"]
project_dir = self.ctx.settings.project_dir
remote_redirect_host = self.ctx.settings.remote_redirect_host
cmd = """
ssh mathewguest.com \
@@ -227,9 +225,9 @@ ssh mathewguest.com \
Upload all required game files to the hub server.
"""
self.ctx.log["ut4"].info("Uploading customized server")
project_dir = self.ctx.config["app"]["project_dir"]
remote_game_host = self.ctx.config["app"]["remote_game_host"]
remote_game_dir = self.ctx.config["app"]["remote_game_dir"]
project_dir = self.ctx.settings.project_dir
remote_game_host = self.ctx.settings.remote_game_host
remote_game_dir = self.ctx.settings.remote_game_dir
cwd = None
# transfer #1
@@ -304,7 +302,7 @@ ssh {remote_game_host} \
# Make binary executable:
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)
target_file = "{cwd}/{bin_name}".format(cwd=cwd, bin_name=bin_name)
@@ -325,8 +323,8 @@ ssh {remote_game_host} \
def _install_config(self):
files = ("Game.ini", "Engine.ini")
project_dir = self.ctx.config["app"]["project_dir"]
config_dir = self.ctx.config["app"]["config_dir"]
project_dir = self.ctx.settings.project_dir
config_dir = self.ctx.settings.config_dir
for fn in files:
self.ctx.log["ut4"].info("Installing file: %s", fn)
src = os.path.join(config_dir, fn)
@@ -347,7 +345,7 @@ ssh {remote_game_host} \
ini._config.write(fp)
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...")
cmd = "rsync -ravzp {src} {dst}".format(
@@ -370,9 +368,9 @@ ssh {remote_game_host} \
def _install_redirect_lines(self):
self.ctx.log["ut4"].info("Generating redirect references...")
redirect_protocol = self.ctx.config["app"]["redirect_protocol"]
redirect_url = self.ctx.config["app"]["redirect_url"]
project_dir = self.ctx.config["app"]["project_dir"]
redirect_protocol = self.ctx.settings.redirect_protocol
redirect_url = self.ctx.settings.redirect_url
project_dir = self.ctx.settings.project_dir
mod_dir = "/".join([project_dir, "files"])
game_ini_filepath = "/".join(
@@ -415,7 +413,7 @@ ssh {remote_game_host} \
def _install_rulesets(self):
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"])
out_dir = "/".join([project_dir, "/instance/LinuxServer/UnrealTournament/Saved/Config/Rulesets"])
@@ -464,7 +462,7 @@ ssh {remote_game_host} \
"remote_redirect_host",
)
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)
i = input("Continue with above configuration? (y/N):")
+41
View File
@@ -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)
+1 -1
View File
@@ -13,7 +13,7 @@ class LocalFs:
self.datalayer.create_tables()
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")
print(maps_dir)
self._load_md5_one_dir(maps_dir)
+4 -3
View File
@@ -1,5 +1,6 @@
import selenium
import selenium.webdriver
from selenium.webdriver.common.by import By
URL_MUTATORS = "https://ut4pugs.us/redirect-mutators"
URL_MAPS = "https://ut4pugs.us/redirect-mutators"
@@ -25,12 +26,12 @@ class ScrapeUt4Pugs:
self._check_pak_md5sums()
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)
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:
mut_cols = r.find_elements_by_xpath("td")
mut_cols = r.find_elements(By.XPATH, "td")
if len(mut_cols) != 3:
input("<breakpoint> at unexpected columns for mutator. received {}".format(len(mut_cols)))
mut_file = mut_cols[0]
+22
View File
@@ -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"
+24
View File
@@ -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}"
+34
View File
@@ -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)
+26
View File
@@ -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