Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Release

on:
push:
tags:
- "v*"

permissions:
contents: write

jobs:
github-release:
runs-on: ubuntu-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python 3.13
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: pip

- name: Install project and test dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"

- name: Run test suite
run: pytest -q

- name: Validate tag matches project version
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
run: |
python - <<'PY'
import os
import pathlib
import tomllib

version = tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"]
expected_tag = f"v{version}"
actual_tag = os.environ["GITHUB_REF_NAME"]

if actual_tag != expected_tag:
raise SystemExit(f"Tag {actual_tag!r} does not match pyproject version {expected_tag!r}.")
PY

- name: Extract release notes from changelog
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
run: |
python scripts/extract_release_notes.py "${GITHUB_REF_NAME#v}" > RELEASE_NOTES.md

- name: Publish GitHub release
uses: softprops/action-gh-release@v2
with:
body_path: RELEASE_NOTES.md
31 changes: 31 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Tests

on:
pull_request:
push:

jobs:
pytest:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.11", "3.12", "3.13"]

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip

- name: Install project and test dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"

- name: Run test suite
run: pytest -q
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
__pycache__
*.pyc
.coverage
.pytest_cache/
*.egg-info/
build/
dist/
TODO.md
docs
docs
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.2.0] - 2026-03-22

### Added

- Packaging metadata and editable-install support via `pyproject.toml`.
- A reproducible pytest setup for local development and CI.
- GitHub Actions workflows for automated tests and tag-driven GitHub releases.
- A release-notes extractor so GitHub releases publish the matching changelog section.

### Changed

- Added the `image-ranker` console entry point for launching the Flask app.
- Standardized path handling around `BASE_DIR` so app configuration and tests behave consistently.
54 changes: 43 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
<div align="center">
<h1>Image Ranker</h1>

Maintained by [Quentin Wach](https://www.x.com/QuentinWach).

<p>
<a href="https://github.com/QuentinWach/image-ranker/actions/workflows/test.yml">
<img src="https://img.shields.io/github/actions/workflow/status/QuentinWach/image-ranker/test.yml?branch=main&label=tests" alt="Tests">
</a>
<a href="https://opensource.org/licenses/MIT">
<img src="https://img.shields.io/github/license/QuentinWach/image-ranker" alt="License">
</a>
<a href="https://github.com/QuentinWach/1v1-image-ranker/stargazers">
<a href="https://github.com/QuentinWach/image-ranker/stargazers">
<img src="https://img.shields.io/github/stars/QuentinWach/image-ranker" alt="GitHub stars">
</a>
<a href="https://github.com/QuentinWach/1v1-image-ranker/commits/main">
<a href="https://github.com/QuentinWach/image-ranker/commits/main">
<img src="https://img.shields.io/github/last-commit/QuentinWach/image-ranker" alt="Last update">
</a>
</p>
Expand All @@ -34,21 +37,50 @@
git clone https://github.com/QuentinWach/image-ranker.git
cd image-ranker
```
2. Create a virtual environment (optional but recommended):
2. Create and activate a virtual environment:
```
python -m venv venv
source venv/bin/activate # On Windows, use `venv\Scripts\activate`
```
3. Install the required dependencies:
3. Install the project:
```
pip install -e .
```
pip install flask trueskill
4. Run the Flask app:
```
4. Run the Flask app: `python app.py`.
image-ranker
```
You can also use `python app.py` if you prefer.
- On startup, the app loads the bundled demo images from `static/images` so you can try it immediately.
- When you open the directory picker, browsing starts in your home folder and you can also paste any absolute path directly in the UI.
- If you want to intentionally restrict browsing to a specific subtree, set `BASE_DIR` (e.g. `BASE_DIR=/your/path python app.py`).
- If you want to intentionally restrict browsing to a specific subtree, set `BASE_DIR` (for example `BASE_DIR=/your/path image-ranker`).
5. Open a web browser and navigate to `http://localhost:5000`.

### Testing

Install the development dependencies and run the test suite:

```bash
pip install -e ".[dev]"
pytest
```

The repository includes a GitHub Actions workflow that runs the pytest suite on every push and pull request across Python 3.11, 3.12, and 3.13.

### Versioning, Changelog, and Releases

- The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The current version is defined in `pyproject.toml`.
- User-facing changes are tracked in [`CHANGELOG.md`](CHANGELOG.md).
- Releases are created from Git tags in the format `vX.Y.Z`.
- The release workflow verifies that the Git tag matches the version in `pyproject.toml`, runs the test suite, and publishes a GitHub Release using the matching changelog entry.

Typical release flow:

1. Update `pyproject.toml` and `CHANGELOG.md`.
2. Merge the release commit to `main`.
3. Create and push the tag, for example `git tag v0.2.0 && git push origin v0.2.0`.

### Configuration

You can configure the application using the following environment variables:
Expand All @@ -60,7 +92,7 @@ You can configure the application using the following environment variables:
Example:

```
AUTOSAVE_FREQUENCY=20 SOUND_ENABLED=false python app.py
AUTOSAVE_FREQUENCY=20 SOUND_ENABLED=false image-ranker
```

### Exclusion Reasons
Expand All @@ -82,7 +114,7 @@ The JSON file should be a simple key-value pair object, where the keys are short
To use this feature, set the `EXCLUSION_REASONS_FILE` environment variable to the path of your JSON file:

```
EXCLUSION_REASONS_FILE=/path/to/your/exclusion_reasons.json python app.py
EXCLUSION_REASONS_FILE=/path/to/your/exclusion_reasons.json image-ranker
```

### Image Context
Expand Down
10 changes: 8 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
comparisons_autosave_prefix = 'comparisons_autosave_'
AUTOSAVE_FREQUENCY = int(os.environ.get('AUTOSAVE_FREQUENCY', '10'))
SOUND_ENABLED = os.environ.get('SOUND_ENABLED', 'True').lower() == 'true'
BASE_DIR = os.environ.get('BASE_DIR')

exclusion_reasons_file = os.environ.get('EXCLUSION_REASONS_FILE')
if exclusion_reasons_file:
Expand Down Expand Up @@ -75,7 +76,7 @@ def initialize_default_demo_directory():


def get_restriction_root():
base_dir = os.environ.get('BASE_DIR')
base_dir = BASE_DIR or os.environ.get('BASE_DIR')
if not base_dir:
return None
return os.path.realpath(os.path.abspath(os.path.expanduser(base_dir)))
Expand Down Expand Up @@ -979,7 +980,12 @@ def get_context():
else:
abort(404, f"Context not found for {filename} and no default is set.")

if __name__ == '__main__':
def main():
initialize_default_demo_directory()
global comparisons_since_autosave
comparisons_since_autosave = 0
app.run(debug=False, threaded=True)


if __name__ == '__main__':
main()
38 changes: 38 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"

[project]
name = "image-ranker"
version = "0.2.0"
description = "Pairwise image ranking web app powered by TrueSkill and Flask."
readme = "README.md"
requires-python = ">=3.11"
license = { file = "LICENSE.txt" }
authors = [
{ name = "Quentin Wach" },
]
dependencies = [
"Flask>=3.1,<4",
"trueskill>=0.4.5,<0.5",
]

[project.optional-dependencies]
dev = [
"pytest>=8.4,<9",
]

[project.urls]
Homepage = "https://github.com/QuentinWach/image-ranker"
Changelog = "https://github.com/QuentinWach/image-ranker/blob/main/CHANGELOG.md"
Issues = "https://github.com/QuentinWach/image-ranker/issues"
Releases = "https://github.com/QuentinWach/image-ranker/releases"

[project.scripts]
image-ranker = "app:main"

[tool.setuptools]
py-modules = ["app", "elo"]

[tool.pytest.ini_options]
testpaths = ["tests"]
44 changes: 44 additions & 0 deletions scripts/extract_release_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
#!/usr/bin/env python3

from __future__ import annotations

import pathlib
import sys


def extract_release_notes(changelog_path: pathlib.Path, version: str) -> str:
lines = changelog_path.read_text(encoding="utf-8").splitlines()
start_header = f"## [{version}]"
collected: list[str] = []
in_section = False

for line in lines:
if line.startswith(start_header):
in_section = True
collected.append(line)
continue

if in_section and line.startswith("## ["):
break

if in_section:
collected.append(line)

if not collected:
raise SystemExit(f"Could not find release notes for version {version} in {changelog_path}.")

return "\n".join(collected).strip() + "\n"


def main() -> int:
if len(sys.argv) != 2:
raise SystemExit("Usage: extract_release_notes.py <version>")

version = sys.argv[1]
changelog_path = pathlib.Path("CHANGELOG.md")
sys.stdout.write(extract_release_notes(changelog_path, version))
return 0


if __name__ == "__main__":
raise SystemExit(main())
2 changes: 1 addition & 1 deletion tests/test_resume_autosave.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
class ResumeAutosaveTest(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.base_dir = self.temp_dir.name
self.base_dir = os.path.realpath(self.temp_dir.name)
self.dataset_dir = os.path.join(self.base_dir, "dataset")
os.makedirs(self.dataset_dir)

Expand Down
Loading