psyexp-core 0.5.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- psyexp_core-0.5.1/.github/workflows/publish.yml +70 -0
- psyexp_core-0.5.1/.github/workflows/release.yml +112 -0
- psyexp_core-0.5.1/.github/workflows/tests.yml +44 -0
- psyexp_core-0.5.1/.gitignore +10 -0
- psyexp_core-0.5.1/CHANGELOG.md +68 -0
- psyexp_core-0.5.1/Makefile +30 -0
- psyexp_core-0.5.1/PKG-INFO +92 -0
- psyexp_core-0.5.1/README.md +74 -0
- psyexp_core-0.5.1/docs/ci-setup.md +100 -0
- psyexp_core-0.5.1/docs/releasing.md +109 -0
- psyexp_core-0.5.1/pyproject.toml +44 -0
- psyexp_core-0.5.1/src/psyexp_core/__init__.py +19 -0
- psyexp_core-0.5.1/src/psyexp_core/diagnostics.py +21 -0
- psyexp_core-0.5.1/src/psyexp_core/instructions.py +70 -0
- psyexp_core-0.5.1/src/psyexp_core/keyboard.py +150 -0
- psyexp_core-0.5.1/src/psyexp_core/manifest.py +151 -0
- psyexp_core-0.5.1/src/psyexp_core/recording.py +26 -0
- psyexp_core-0.5.1/src/psyexp_core/rundir.py +17 -0
- psyexp_core-0.5.1/src/psyexp_core/screen.py +101 -0
- psyexp_core-0.5.1/src/psyexp_core/wizard.py +177 -0
- psyexp_core-0.5.1/tests/test_keyboard.py +100 -0
- psyexp_core-0.5.1/tests/test_manifest.py +86 -0
- psyexp_core-0.5.1/tests/test_recording.py +60 -0
- psyexp_core-0.5.1/uv.lock +5803 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
# Publishes psyexp-core to PyPI when a GitHub Release is published.
|
|
4
|
+
#
|
|
5
|
+
# Publishing is deliberately tied to the *Release* event, NOT to pushing a tag.
|
|
6
|
+
# A tag only runs release-check.yml (tests + version/changelog/lock checks, then a
|
|
7
|
+
# DRAFT Release), so tags can be created, moved, or deleted freely while iterating
|
|
8
|
+
# without ever touching PyPI. A version ships only when a human publishes the draft.
|
|
9
|
+
#
|
|
10
|
+
# Auth uses PyPI Trusted Publishing (OIDC) — no API token is stored as a secret.
|
|
11
|
+
# The `pypi` environment scopes the OIDC trust and gates publishes behind a
|
|
12
|
+
# required-reviewer approval. One-time setup: see docs/ci-setup.md.
|
|
13
|
+
on:
|
|
14
|
+
release:
|
|
15
|
+
types: [published]
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
# Gate the release on the full test matrix (reuses tests.yml).
|
|
19
|
+
tests:
|
|
20
|
+
uses: ./.github/workflows/tests.yml
|
|
21
|
+
|
|
22
|
+
build:
|
|
23
|
+
needs: tests
|
|
24
|
+
runs-on: ubuntu-latest
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v5
|
|
27
|
+
|
|
28
|
+
- name: Install uv
|
|
29
|
+
uses: astral-sh/setup-uv@v6
|
|
30
|
+
|
|
31
|
+
# Never ship a wheel whose version disagrees with the release tag.
|
|
32
|
+
- name: Verify release tag matches pyproject version
|
|
33
|
+
run: |
|
|
34
|
+
set -euo pipefail
|
|
35
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
36
|
+
version="$(uv version --short)"
|
|
37
|
+
echo "Release tag: $tag pyproject version: $version"
|
|
38
|
+
if [ "$tag" != "$version" ]; then
|
|
39
|
+
echo "::error::Release tag ($tag) does not match pyproject version ($version)."
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
- name: Build sdist + wheel
|
|
44
|
+
run: uv build
|
|
45
|
+
|
|
46
|
+
- uses: actions/upload-artifact@v4
|
|
47
|
+
with:
|
|
48
|
+
name: dist
|
|
49
|
+
path: dist/
|
|
50
|
+
|
|
51
|
+
pypi:
|
|
52
|
+
needs: build
|
|
53
|
+
runs-on: ubuntu-latest
|
|
54
|
+
environment:
|
|
55
|
+
name: pypi
|
|
56
|
+
url: https://pypi.org/project/psyexp-core/
|
|
57
|
+
permissions:
|
|
58
|
+
id-token: write # mint the OIDC token for Trusted Publishing
|
|
59
|
+
steps:
|
|
60
|
+
- uses: actions/download-artifact@v4
|
|
61
|
+
with:
|
|
62
|
+
name: dist
|
|
63
|
+
path: dist/
|
|
64
|
+
|
|
65
|
+
- name: Publish to PyPI
|
|
66
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
67
|
+
with:
|
|
68
|
+
# Tolerate re-running a Release whose version is already on PyPI
|
|
69
|
+
# (PyPI versions are immutable): skip instead of hard-failing.
|
|
70
|
+
skip-existing: true
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
name: release-check
|
|
2
|
+
|
|
3
|
+
# On a version tag: run the test matrix, verify the version/changelog/lock all
|
|
4
|
+
# agree, and create a DRAFT GitHub Release with notes from CHANGELOG.md. Tagging
|
|
5
|
+
# does not publish anything — a human reviews and publishes the draft, which then
|
|
6
|
+
# triggers publish.yml (PyPI). See docs/releasing.md.
|
|
7
|
+
on:
|
|
8
|
+
push:
|
|
9
|
+
tags:
|
|
10
|
+
- "v*"
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
# Tests must pass before we accept a release tag.
|
|
14
|
+
tests:
|
|
15
|
+
uses: ./.github/workflows/tests.yml
|
|
16
|
+
|
|
17
|
+
version-check:
|
|
18
|
+
runs-on: ubuntu-latest
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v5
|
|
21
|
+
|
|
22
|
+
- name: Install uv
|
|
23
|
+
uses: astral-sh/setup-uv@v6
|
|
24
|
+
|
|
25
|
+
- name: Verify version bump and lockfile
|
|
26
|
+
run: |
|
|
27
|
+
set -euo pipefail
|
|
28
|
+
|
|
29
|
+
tag="${GITHUB_REF_NAME#v}"
|
|
30
|
+
echo "Tag version: $tag"
|
|
31
|
+
|
|
32
|
+
pyproject_version="$(uv version --short)"
|
|
33
|
+
echo "pyproject version: $pyproject_version"
|
|
34
|
+
|
|
35
|
+
# Project's own pinned version inside the lockfile.
|
|
36
|
+
lock_version="$(
|
|
37
|
+
awk '
|
|
38
|
+
/^name = "psyexp-core"$/ { found = 1; next }
|
|
39
|
+
found && /^version = / {
|
|
40
|
+
sub(/^version = "/, ""); sub(/"$/, ""); print; exit
|
|
41
|
+
}
|
|
42
|
+
' uv.lock
|
|
43
|
+
)"
|
|
44
|
+
echo "uv.lock version: $lock_version"
|
|
45
|
+
|
|
46
|
+
if [ "$pyproject_version" != "$tag" ]; then
|
|
47
|
+
echo "::error::pyproject.toml version ($pyproject_version) does not match tag ($tag). Bump the version before tagging."
|
|
48
|
+
exit 1
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
if [ "$lock_version" != "$tag" ]; then
|
|
52
|
+
echo "::error::uv.lock version ($lock_version) does not match tag ($tag). Run 'uv lock' and commit the result."
|
|
53
|
+
exit 1
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
# Ensure the lockfile is fully consistent with pyproject.toml
|
|
57
|
+
# (catches dependency drift as well as a stale self-version).
|
|
58
|
+
uv lock --check
|
|
59
|
+
|
|
60
|
+
# CHANGELOG.md must document this version (the draft Release notes are
|
|
61
|
+
# extracted from it below).
|
|
62
|
+
if ! awk -v ver="$tag" '
|
|
63
|
+
/^## / { heading = $0; gsub(/[][]/, "", heading); if (index(heading, ver)) found = 1 }
|
|
64
|
+
END { exit found ? 0 : 1 }
|
|
65
|
+
' CHANGELOG.md; then
|
|
66
|
+
echo "::error::CHANGELOG.md has no section for $tag. Add a '## v$tag' entry before tagging."
|
|
67
|
+
exit 1
|
|
68
|
+
fi
|
|
69
|
+
|
|
70
|
+
echo "Version $tag is consistent across pyproject.toml, uv.lock, and CHANGELOG.md."
|
|
71
|
+
|
|
72
|
+
# After the gates pass, draft a GitHub Release with notes from CHANGELOG.md.
|
|
73
|
+
# The draft is NOT published, so nothing reaches PyPI until a human publishes it.
|
|
74
|
+
draft-release:
|
|
75
|
+
needs: [tests, version-check]
|
|
76
|
+
runs-on: ubuntu-latest
|
|
77
|
+
permissions:
|
|
78
|
+
contents: write
|
|
79
|
+
steps:
|
|
80
|
+
- uses: actions/checkout@v5
|
|
81
|
+
|
|
82
|
+
# Pull the section for this tag out of CHANGELOG.md: the heading containing
|
|
83
|
+
# the version (without the leading "v") through to the next "## " heading.
|
|
84
|
+
- name: Extract release notes
|
|
85
|
+
id: notes
|
|
86
|
+
run: |
|
|
87
|
+
version="${GITHUB_REF_NAME#v}"
|
|
88
|
+
notes_file="$(mktemp)"
|
|
89
|
+
awk -v ver="$version" '
|
|
90
|
+
$0 ~ "^## " {
|
|
91
|
+
if (found) exit
|
|
92
|
+
heading = $0
|
|
93
|
+
gsub(/[][]/, "", heading)
|
|
94
|
+
if (index(heading, ver)) { found = 1; next }
|
|
95
|
+
}
|
|
96
|
+
found { print }
|
|
97
|
+
' CHANGELOG.md > "$notes_file"
|
|
98
|
+
|
|
99
|
+
if [ ! -s "$notes_file" ]; then
|
|
100
|
+
printf 'Release %s\n' "$GITHUB_REF_NAME" > "$notes_file"
|
|
101
|
+
fi
|
|
102
|
+
|
|
103
|
+
echo "file=$notes_file" >> "$GITHUB_OUTPUT"
|
|
104
|
+
|
|
105
|
+
- name: Create draft GitHub Release
|
|
106
|
+
uses: softprops/action-gh-release@v2
|
|
107
|
+
with:
|
|
108
|
+
name: ${{ github.ref_name }}
|
|
109
|
+
body_path: ${{ steps.notes.outputs.file }}
|
|
110
|
+
draft: true
|
|
111
|
+
# Mark as prerelease when the tag carries an rc/alpha/beta suffix.
|
|
112
|
+
prerelease: ${{ contains(github.ref_name, '-rc') || contains(github.ref_name, '-alpha') || contains(github.ref_name, '-beta') }}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
workflow_call:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
strategy:
|
|
13
|
+
fail-fast: false
|
|
14
|
+
matrix:
|
|
15
|
+
python-version: ["3.11", "3.12", "3.13"]
|
|
16
|
+
|
|
17
|
+
steps:
|
|
18
|
+
- uses: actions/checkout@v5
|
|
19
|
+
|
|
20
|
+
- name: Install uv
|
|
21
|
+
uses: astral-sh/setup-uv@v6
|
|
22
|
+
with:
|
|
23
|
+
enable-cache: true
|
|
24
|
+
|
|
25
|
+
- name: Set up Python ${{ matrix.python-version }}
|
|
26
|
+
run: uv python install ${{ matrix.python-version }}
|
|
27
|
+
|
|
28
|
+
# The tested surface (manifest, recording, diagnostics, rundir) is
|
|
29
|
+
# PsychoPy-free, so we skip the heavy GL/PsychoPy stack and install the
|
|
30
|
+
# project without its runtime deps plus the dev tooling only.
|
|
31
|
+
- name: Install
|
|
32
|
+
run: |
|
|
33
|
+
uv venv --python ${{ matrix.python-version }}
|
|
34
|
+
uv pip install --no-deps -e .
|
|
35
|
+
uv pip install pytest ruff
|
|
36
|
+
|
|
37
|
+
# Use --no-sync so `uv run` does not re-resolve and install the full
|
|
38
|
+
# dependency tree (which includes PsychoPy/wxPython and fails to build on
|
|
39
|
+
# CI). We rely on the no-deps venv set up above instead.
|
|
40
|
+
- name: Lint
|
|
41
|
+
run: uv run --no-sync ruff check .
|
|
42
|
+
|
|
43
|
+
- name: Test
|
|
44
|
+
run: uv run --no-sync pytest
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
See [docs/releasing.md](docs/releasing.md) for the release process.
|
|
8
|
+
|
|
9
|
+
## v0.5.1
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- PyPI publishing on a published GitHub Release via Trusted Publishing (OIDC; no
|
|
14
|
+
stored token).
|
|
15
|
+
- Draft GitHub Release creation on tag push, with notes pulled from this file.
|
|
16
|
+
- This changelog and a release guide ([docs/releasing.md](docs/releasing.md)).
|
|
17
|
+
- `[project.urls]` for the PyPI project page.
|
|
18
|
+
|
|
19
|
+
## v0.5.0
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- Timed-press + keyboard-clock API in `keyboard`: `get_presses` (name + reaction
|
|
24
|
+
time), `reset_clock_on_flip`, `reset_clock`, `clock_time`, and the `KeyPress`
|
|
25
|
+
dataclass — so timing-critical response windows can read frame-accurate RT
|
|
26
|
+
through the shared abstraction.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- `keyboard` imports PsychoPy lazily, so the module (and its PsychoPy-free
|
|
31
|
+
timed-press / clock helpers) stays importable and unit-testable in headless/CI
|
|
32
|
+
environments without the PsychoPy stack.
|
|
33
|
+
|
|
34
|
+
## v0.4.0
|
|
35
|
+
|
|
36
|
+
### Changed
|
|
37
|
+
|
|
38
|
+
- Instruction-pager page type uses a generic, so callers keep their own page type.
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- Makefile for release automation.
|
|
43
|
+
|
|
44
|
+
## v0.3.0
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
|
|
48
|
+
- Loud warning when psychtoolbox is unavailable and the keyboard falls back to
|
|
49
|
+
PsychoPy's focus-dependent `event` backend.
|
|
50
|
+
|
|
51
|
+
## v0.2.0
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- Poll for keyboard input rather than blocking, so the window keeps flipping (and
|
|
56
|
+
can come to the foreground to receive keys) on macOS.
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- Co-development instructions for overlaying an editable checkout over a git pin.
|
|
61
|
+
|
|
62
|
+
## v0.1.0
|
|
63
|
+
|
|
64
|
+
Initial release: the task-agnostic harness — `screen` (fullscreen window +
|
|
65
|
+
frame-timing calibration), `diagnostics`, `rundir`, `manifest`, `recording`
|
|
66
|
+
(`CsvWriter`), `wizard` setup primitives, the `instructions` pager, and the
|
|
67
|
+
`keyboard` PTB/event abstraction — with the version resolved from `pyproject.toml`
|
|
68
|
+
and CI for tests and release checks.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
.PHONY: release bump-patch bump-minor bump-major version
|
|
2
|
+
|
|
3
|
+
# Current version, read straight from pyproject.toml.
|
|
4
|
+
VERSION := $(shell grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2)
|
|
5
|
+
|
|
6
|
+
version:
|
|
7
|
+
@echo $(VERSION)
|
|
8
|
+
|
|
9
|
+
# Cut a release: bump pyproject.toml to $(TO), commit, and tag v$(TO).
|
|
10
|
+
# Usage: make release TO=0.4.0
|
|
11
|
+
release:
|
|
12
|
+
@test -n "$(TO)" || { echo "usage: make release TO=X.Y.Z"; exit 1; }
|
|
13
|
+
@test -z "$$(git status --porcelain)" || { echo "working tree is dirty; commit or stash first"; exit 1; }
|
|
14
|
+
@git rev-parse -q --verify "refs/tags/v$(TO)" >/dev/null && { echo "tag v$(TO) already exists"; exit 1; } || true
|
|
15
|
+
sed -i '' 's/^version = ".*"/version = "$(TO)"/' pyproject.toml
|
|
16
|
+
uv lock
|
|
17
|
+
git add pyproject.toml uv.lock
|
|
18
|
+
git commit -m "chore: release v$(TO)"
|
|
19
|
+
git tag -a "v$(TO)" -m "v$(TO)"
|
|
20
|
+
@echo "tagged v$(TO) — run 'git push && git push --tags' to publish"
|
|
21
|
+
|
|
22
|
+
# Convenience wrappers that compute the next version from the current one.
|
|
23
|
+
bump-patch:
|
|
24
|
+
@$(MAKE) release TO=$(shell echo $(VERSION) | awk -F. '{printf "%d.%d.%d", $$1, $$2, $$3+1}')
|
|
25
|
+
|
|
26
|
+
bump-minor:
|
|
27
|
+
@$(MAKE) release TO=$(shell echo $(VERSION) | awk -F. '{printf "%d.%d.0", $$1, $$2+1}')
|
|
28
|
+
|
|
29
|
+
bump-major:
|
|
30
|
+
@$(MAKE) release TO=$(shell echo $(VERSION) | awk -F. '{printf "%d.0.0", $$1+1}')
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: psyexp-core
|
|
3
|
+
Version: 0.5.1
|
|
4
|
+
Summary: Task-agnostic harness for PsychoPy experiments: screen/frame-timing setup, run manifests, CSV writers, setup-wizard primitives, instruction pager, and keyboard abstraction.
|
|
5
|
+
Project-URL: Homepage, https://github.com/HAPNlab/psyexp-core
|
|
6
|
+
Project-URL: Repository, https://github.com/HAPNlab/psyexp-core
|
|
7
|
+
Author: Eric Wang
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: prompt-toolkit>=3.0
|
|
10
|
+
Requires-Dist: psychopy>=2025.1
|
|
11
|
+
Requires-Dist: pyobjc-framework-quartz>=10; sys_platform == 'darwin'
|
|
12
|
+
Requires-Dist: questionary>=2.0
|
|
13
|
+
Requires-Dist: rich>=14.3.3
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
16
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# psyexp-core
|
|
20
|
+
|
|
21
|
+
Task-agnostic harness for PsychoPy experiments, shared across the lab's task
|
|
22
|
+
repos (`heat-task`, `mid-task`, `mid-task-deterministic`). It owns the *plumbing*
|
|
23
|
+
that every task duplicates; each task repo keeps only its own stimuli, trial
|
|
24
|
+
logic, and record schemas.
|
|
25
|
+
|
|
26
|
+
## What's in here
|
|
27
|
+
|
|
28
|
+
| Module | Responsibility |
|
|
29
|
+
| --- | --- |
|
|
30
|
+
| `screen` | `setup_screen()` — open a fullscreen PsychoPy window, enable VSYNC, run a frame-timing calibration, and return a `ScreenDiagnostics`. |
|
|
31
|
+
| `diagnostics` | The `ScreenDiagnostics` dataclass (import-light; no PsychoPy). |
|
|
32
|
+
| `rundir` | `make_run_dir(data_dir, label, session_time)` — timestamped output directory. |
|
|
33
|
+
| `manifest` | `write_manifest(...)` + `system_info()` — JSON run manifest with system/display/process diagnostics and the resolved `psyexp_core_version`. App-specific fields are injected via `header` / `study_params`. |
|
|
34
|
+
| `recording` | `CsvWriter` base class (maps a dataclass record onto a fixed column schema). |
|
|
35
|
+
| `wizard` | questionary / prompt_toolkit setup-wizard primitives: shared styles, `ask_text` / `ask_select` / `ask_confirm`, `PosFloatValidator`, `prompt_unique_name`, `quit_app`. |
|
|
36
|
+
| `instructions` | `page_through(...)` — a self-paced, keypress-driven instruction pager. |
|
|
37
|
+
| `keyboard` | PTB / PsychoPy-event keyboard abstraction: `build_keyboard` / `get_keys` / `wait_for_keys` / `clear_events`, plus the timed-press API for response windows — `get_presses` (name + rt), `reset_clock_on_flip` / `reset_clock` / `clock_time`. |
|
|
38
|
+
|
|
39
|
+
## Use from a task repo
|
|
40
|
+
|
|
41
|
+
Add it as a dependency. For day-to-day development, point at a local checkout so
|
|
42
|
+
edits are live without reinstalling:
|
|
43
|
+
|
|
44
|
+
```toml
|
|
45
|
+
# your-task/pyproject.toml
|
|
46
|
+
dependencies = ["psyexp-core"]
|
|
47
|
+
|
|
48
|
+
[tool.uv.sources]
|
|
49
|
+
psyexp-core = { path = "../psyexp-core", editable = true }
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
For a reproducible release build, pin a tagged ref instead:
|
|
53
|
+
|
|
54
|
+
```toml
|
|
55
|
+
[tool.uv.sources]
|
|
56
|
+
psyexp-core = { git = "ssh://git@github.com/<you>/psyexp-core.git", tag = "v0.1.0" }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`write_manifest` records the resolved `psyexp_core_version` so each run is
|
|
60
|
+
traceable back to a core version.
|
|
61
|
+
|
|
62
|
+
### Co-developing core while a task repo keeps the git pin
|
|
63
|
+
|
|
64
|
+
Lab task repos (e.g. `heat-task`) commit the **git-tag** source above so clones
|
|
65
|
+
reproduce exactly, then overlay a local editable install for development:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
uv pip install -e ../psyexp-core
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Gotcha:** `uv run` re-syncs the task venv from its `uv.lock` on every launch,
|
|
72
|
+
which reverts that editable install straight back to the pinned tag (symptoms:
|
|
73
|
+
your local core edits silently don't take effect). Set `UV_NO_SYNC=1` in the task
|
|
74
|
+
repo (export it in your shell, or use `uv run --no-sync`) so the editable overlay
|
|
75
|
+
sticks; run a manual `uv sync` only when you change other deps, then re-run the
|
|
76
|
+
editable install. See heat-task's README ("Co-developing `psyexp-core` locally")
|
|
77
|
+
for the full workflow.
|
|
78
|
+
|
|
79
|
+
## Releasing
|
|
80
|
+
|
|
81
|
+
Tagging and publishing are deliberately separate, so tags stay cheap to iterate on:
|
|
82
|
+
|
|
83
|
+
1. **Bump + lock + changelog**, then tag `vX.Y.Z`. The tag runs the checks and
|
|
84
|
+
creates a **draft** GitHub Release — it does **not** publish anything.
|
|
85
|
+
2. **Review the draft** Release and publish it. That triggers
|
|
86
|
+
[`publish.yml`](.github/workflows/publish.yml), which uploads to
|
|
87
|
+
[PyPI](https://pypi.org/project/psyexp-core/) via **Trusted Publishing** (OIDC;
|
|
88
|
+
no API token stored).
|
|
89
|
+
|
|
90
|
+
PyPI versions are immutable, so retagging never republishes; bump the version to
|
|
91
|
+
ship new code. See **[docs/releasing.md](docs/releasing.md)** for the full process,
|
|
92
|
+
SemVer policy, pre-releases, retag semantics, and the one-time PyPI setup.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# psyexp-core
|
|
2
|
+
|
|
3
|
+
Task-agnostic harness for PsychoPy experiments, shared across the lab's task
|
|
4
|
+
repos (`heat-task`, `mid-task`, `mid-task-deterministic`). It owns the *plumbing*
|
|
5
|
+
that every task duplicates; each task repo keeps only its own stimuli, trial
|
|
6
|
+
logic, and record schemas.
|
|
7
|
+
|
|
8
|
+
## What's in here
|
|
9
|
+
|
|
10
|
+
| Module | Responsibility |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| `screen` | `setup_screen()` — open a fullscreen PsychoPy window, enable VSYNC, run a frame-timing calibration, and return a `ScreenDiagnostics`. |
|
|
13
|
+
| `diagnostics` | The `ScreenDiagnostics` dataclass (import-light; no PsychoPy). |
|
|
14
|
+
| `rundir` | `make_run_dir(data_dir, label, session_time)` — timestamped output directory. |
|
|
15
|
+
| `manifest` | `write_manifest(...)` + `system_info()` — JSON run manifest with system/display/process diagnostics and the resolved `psyexp_core_version`. App-specific fields are injected via `header` / `study_params`. |
|
|
16
|
+
| `recording` | `CsvWriter` base class (maps a dataclass record onto a fixed column schema). |
|
|
17
|
+
| `wizard` | questionary / prompt_toolkit setup-wizard primitives: shared styles, `ask_text` / `ask_select` / `ask_confirm`, `PosFloatValidator`, `prompt_unique_name`, `quit_app`. |
|
|
18
|
+
| `instructions` | `page_through(...)` — a self-paced, keypress-driven instruction pager. |
|
|
19
|
+
| `keyboard` | PTB / PsychoPy-event keyboard abstraction: `build_keyboard` / `get_keys` / `wait_for_keys` / `clear_events`, plus the timed-press API for response windows — `get_presses` (name + rt), `reset_clock_on_flip` / `reset_clock` / `clock_time`. |
|
|
20
|
+
|
|
21
|
+
## Use from a task repo
|
|
22
|
+
|
|
23
|
+
Add it as a dependency. For day-to-day development, point at a local checkout so
|
|
24
|
+
edits are live without reinstalling:
|
|
25
|
+
|
|
26
|
+
```toml
|
|
27
|
+
# your-task/pyproject.toml
|
|
28
|
+
dependencies = ["psyexp-core"]
|
|
29
|
+
|
|
30
|
+
[tool.uv.sources]
|
|
31
|
+
psyexp-core = { path = "../psyexp-core", editable = true }
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
For a reproducible release build, pin a tagged ref instead:
|
|
35
|
+
|
|
36
|
+
```toml
|
|
37
|
+
[tool.uv.sources]
|
|
38
|
+
psyexp-core = { git = "ssh://git@github.com/<you>/psyexp-core.git", tag = "v0.1.0" }
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`write_manifest` records the resolved `psyexp_core_version` so each run is
|
|
42
|
+
traceable back to a core version.
|
|
43
|
+
|
|
44
|
+
### Co-developing core while a task repo keeps the git pin
|
|
45
|
+
|
|
46
|
+
Lab task repos (e.g. `heat-task`) commit the **git-tag** source above so clones
|
|
47
|
+
reproduce exactly, then overlay a local editable install for development:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
uv pip install -e ../psyexp-core
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Gotcha:** `uv run` re-syncs the task venv from its `uv.lock` on every launch,
|
|
54
|
+
which reverts that editable install straight back to the pinned tag (symptoms:
|
|
55
|
+
your local core edits silently don't take effect). Set `UV_NO_SYNC=1` in the task
|
|
56
|
+
repo (export it in your shell, or use `uv run --no-sync`) so the editable overlay
|
|
57
|
+
sticks; run a manual `uv sync` only when you change other deps, then re-run the
|
|
58
|
+
editable install. See heat-task's README ("Co-developing `psyexp-core` locally")
|
|
59
|
+
for the full workflow.
|
|
60
|
+
|
|
61
|
+
## Releasing
|
|
62
|
+
|
|
63
|
+
Tagging and publishing are deliberately separate, so tags stay cheap to iterate on:
|
|
64
|
+
|
|
65
|
+
1. **Bump + lock + changelog**, then tag `vX.Y.Z`. The tag runs the checks and
|
|
66
|
+
creates a **draft** GitHub Release — it does **not** publish anything.
|
|
67
|
+
2. **Review the draft** Release and publish it. That triggers
|
|
68
|
+
[`publish.yml`](.github/workflows/publish.yml), which uploads to
|
|
69
|
+
[PyPI](https://pypi.org/project/psyexp-core/) via **Trusted Publishing** (OIDC;
|
|
70
|
+
no API token stored).
|
|
71
|
+
|
|
72
|
+
PyPI versions are immutable, so retagging never republishes; bump the version to
|
|
73
|
+
ship new code. See **[docs/releasing.md](docs/releasing.md)** for the full process,
|
|
74
|
+
SemVer policy, pre-releases, retag semantics, and the one-time PyPI setup.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Setting up PyPI CI (one-time)
|
|
2
|
+
|
|
3
|
+
This is the **one-time** setup that lets [`publish.yml`](../.github/workflows/publish.yml)
|
|
4
|
+
upload `psyexp-core` to PyPI. For the day-to-day release process (bump, tag,
|
|
5
|
+
publish the draft), see [releasing.md](releasing.md).
|
|
6
|
+
|
|
7
|
+
## How auth works: Trusted Publishing (OIDC), no stored token
|
|
8
|
+
|
|
9
|
+
We do **not** store a PyPI API token anywhere — not as a repo secret, not as an
|
|
10
|
+
environment secret. Instead the publish job uses **Trusted Publishing**: at run
|
|
11
|
+
time GitHub mints a short-lived OIDC token, and PyPI accepts the upload because it
|
|
12
|
+
matches a *trusted publisher* you registered (owner + repo + workflow + environment).
|
|
13
|
+
|
|
14
|
+
Why this is the safest option:
|
|
15
|
+
|
|
16
|
+
- There's no long-lived credential to leak — the token exists only for minutes,
|
|
17
|
+
inside the one publish job.
|
|
18
|
+
- The token is scoped to the `pypi` **environment**, so only a run in that
|
|
19
|
+
environment can mint it — not test/PR/push workflows.
|
|
20
|
+
- The `pypi` environment adds a **manual approval gate** before the job runs.
|
|
21
|
+
|
|
22
|
+
The publish job already declares this (no edits needed):
|
|
23
|
+
|
|
24
|
+
```yaml
|
|
25
|
+
# .github/workflows/publish.yml
|
|
26
|
+
permissions:
|
|
27
|
+
id-token: write # mint the OIDC token
|
|
28
|
+
environment:
|
|
29
|
+
name: pypi # gate + scope
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Step 1 — Create the `pypi` GitHub Environment + approval gate
|
|
33
|
+
|
|
34
|
+
GitHub auto-creates an environment on first use *without* protections, so this step
|
|
35
|
+
is only needed to add the reviewer gate (recommended).
|
|
36
|
+
|
|
37
|
+
**UI:** Repo → **Settings → Environments → New environment** → name it `pypi`.
|
|
38
|
+
Then under **Deployment protection rules**:
|
|
39
|
+
|
|
40
|
+
- Enable **Required reviewers** and add yourself (and/or a `@HAPNlab` team). The
|
|
41
|
+
publish job will now pause and wait for an approval click before it runs.
|
|
42
|
+
- *(Optional)* **Deployment branches and tags** → *Selected* → add a tag rule like
|
|
43
|
+
`v*` so only version tags can deploy to `pypi`.
|
|
44
|
+
|
|
45
|
+
**CLI alternative** (needs repo admin; reviewer IDs are numeric user/team IDs):
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
# create/configure the environment with a required reviewer
|
|
49
|
+
gh api -X PUT repos/HAPNlab/psyexp-core/environments/pypi \
|
|
50
|
+
-F "reviewers[][type]=User" -F "reviewers[][id]=<YOUR_USER_ID>"
|
|
51
|
+
# look up your numeric id:
|
|
52
|
+
gh api users/<your-login> --jq .id
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Step 2 — Register the trusted publisher on PyPI
|
|
56
|
+
|
|
57
|
+
This tells PyPI to trust uploads from this repo's publish workflow.
|
|
58
|
+
|
|
59
|
+
### If the project does **not** exist on PyPI yet (first release)
|
|
60
|
+
|
|
61
|
+
Use a **pending publisher** — it creates the project on first successful upload:
|
|
62
|
+
|
|
63
|
+
1. Log in to PyPI → **Account settings → Publishing** (or
|
|
64
|
+
<https://pypi.org/manage/account/publishing/>).
|
|
65
|
+
2. Under **Add a new pending publisher**, fill in:
|
|
66
|
+
- **PyPI Project Name:** `psyexp-core`
|
|
67
|
+
- **Owner:** `HAPNlab`
|
|
68
|
+
- **Repository name:** `psyexp-core`
|
|
69
|
+
- **Workflow name:** `publish.yml`
|
|
70
|
+
- **Environment name:** `pypi`
|
|
71
|
+
3. Save.
|
|
72
|
+
|
|
73
|
+
### If the project already exists
|
|
74
|
+
|
|
75
|
+
Project page → **Manage → Publishing → Add a new publisher**, with the same
|
|
76
|
+
owner / repo / workflow / environment values above.
|
|
77
|
+
|
|
78
|
+
> The **Environment name must be exactly `pypi`** — it has to match the
|
|
79
|
+
> `environment: name:` in `publish.yml`, or PyPI will reject the OIDC token.
|
|
80
|
+
|
|
81
|
+
## Step 3 — Do a release
|
|
82
|
+
|
|
83
|
+
Follow [releasing.md](releasing.md): bump the version + `CHANGELOG.md`, `uv lock`,
|
|
84
|
+
tag `vX.Y.Z`. The tag drafts a GitHub Release; **publish the draft**. That triggers
|
|
85
|
+
`publish.yml`, which will:
|
|
86
|
+
|
|
87
|
+
1. run the test matrix and build the sdist/wheel, then
|
|
88
|
+
2. pause on the `pypi` environment for your **approval**, then
|
|
89
|
+
3. upload to PyPI via OIDC.
|
|
90
|
+
|
|
91
|
+
Approve it from the **Actions** run page (or the repo's **Environments** view).
|
|
92
|
+
|
|
93
|
+
## Troubleshooting
|
|
94
|
+
|
|
95
|
+
| Symptom | Cause / fix |
|
|
96
|
+
|---|---|
|
|
97
|
+
| `invalid-publisher` / OIDC rejected | The PyPI trusted publisher's owner/repo/workflow/**environment** don't all match. Re-check Step 2 — `pypi` and `publish.yml` are the usual culprits. |
|
|
98
|
+
| Job runs but never uploads / waits forever | It's parked on the environment approval gate. Approve it on the run page. |
|
|
99
|
+
| `File already exists` | That version is already on PyPI (versions are immutable). `skip-existing: true` turns this into a skip; bump the version to ship new code. See [releasing.md](releasing.md#retag--re-release-semantics). |
|
|
100
|
+
| `id-token` permission error | The job needs `permissions: id-token: write` (already set in `publish.yml`). |
|