diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 070cc06..41bf977 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -3,7 +3,11 @@ name: Upload Python Package on: push: tags: - - "*" + # Release tags are bare version numbers: 3.1.2, 3.1.2.dev0 + - "[0-9]*" + # A `v` prefix is NOT a valid release tag. It is matched here only so the + # job can fail with an explanatory error instead of silently doing nothing. + - "v[0-9]*" jobs: build-n-publish: @@ -11,6 +15,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + # full history so the master-ancestry check below can run + fetch-depth: 0 - name: Set up Python 3.10 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 with: @@ -19,11 +26,61 @@ jobs: run: >- python -m pip install - build wheel + build wheel packaging --user + - name: Verify tag format and version match + env: + TAG_NAME: ${{ github.ref_name }} + run: | + python - <<'PY' + import os + import pathlib + import re + import sys + from packaging.version import Version + + tag = os.environ["TAG_NAME"] + if tag.startswith("v"): + sys.exit( + f"::error::Release tags must be bare version numbers. " + f"`{tag}` has a `v` prefix; delete it and tag `{tag[1:]}` instead." + ) + pkg = re.search( + r'numerapi_version\s*=\s*"([^"]+)"', + pathlib.Path("setup.py").read_text(), + ).group(1) + print(f"tag={tag} setup.py={pkg}") + if Version(tag) != Version(pkg): + sys.exit( + f"::error::Tag {tag} does not match setup.py numerapi_version={pkg}" + ) + print(f"::notice::publishing numerapi {Version(pkg)}") + PY + - name: Final releases must come from master + env: + TAG_NAME: ${{ github.ref_name }} + run: | + python - <<'PY' > prerelease.txt + import os + from packaging.version import Version + + print("yes" if Version(os.environ["TAG_NAME"]).is_prerelease else "no") + PY + if [ "$(cat prerelease.txt)" = "no" ]; then + git fetch --no-tags --quiet origin master + if git merge-base --is-ancestor "$GITHUB_SHA" FETCH_HEAD; then + echo "final release from a commit on master: ok" + else + echo "::error::Final release tags must point at a commit on master; $GITHUB_SHA is not one. Cut pre-releases (e.g. 3.1.2.dev0) from preview instead." + exit 1 + fi + else + echo "pre-release: master-ancestry check skipped" + fi + rm -f prerelease.txt - name: Build a binary wheel and a source tarball run: >- - python setup.py sdist && python setup.py bdist_wheel + python -m build - name: Publish distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags') uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c44f916..fa7e210 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -5,22 +5,28 @@ on: [push, pull_request] jobs: build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.10 - uses: actions/setup-python@v2 + - uses: actions/checkout@v6 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 with: - python-version: "3.10" + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements_tests.txt + python -m pip install . -r requirements_tests.txt + - name: Require pandas 3 on Python 3.14 + if: matrix.python-version == '3.14' + run: python -m pip install "pandas>=3,<4" - name: Run tests run: python -m pytest --import-mode=append tests/ --cov=./ --cov-report=xml - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v5 with: directory: ./coverage/reports/ fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 6f4eba1..3aa711a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/CHANGELOG.md b/CHANGELOG.md index e643e0d..9623222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog Notable changes to this project. +## [3.1.2] - Unreleased +- support Python 3.14 and pandas 3: require `pandas>=2.3.3` on Python 3.14 and + keep `pandas>=1.1.0` below it +- declare `python_requires>=3.10` and advertise Python 3.10 through 3.14 +- 3.1.0 and 3.1.1 were never released; 3.1.0 was published briefly and + withdrawn from PyPI + +## [3.0.0] - 2026-08-07 +- remove the six deprecated Corr/MMC multiplier projections from + `list_rounds`; use the identity-preserving `roundScoreConfigs` list instead + ## [2.24.0] - 2026-08-03 - add exact `roundScoreConfigs` identities, scoring windows, and payout settings to `list_rounds` for Classic, Signals, and Crypto diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..3769649 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,195 @@ +# Releasing numerapi + +numerapi ships to **PyPI only**. There are no container images, no ECR, and no +branch-triggered deploys in this repo — if you are thinking of the +`master` → prod / `staging` → staging image flow from our other repos, that does +not apply here. + +## The model + +Two rules explain everything else: + +1. **`numerapi_version` in `setup.py` is the release.** The git tag is only the + trigger. Whatever that string says is what lands on PyPI. +2. **The version string picks the channel**, not the branch. A + [PEP 440](https://peps.python.org/pep-0440/) pre-release (`3.2.0.dev0`) is + invisible to `pip install numerapi`; a final version (`3.2.0`) is what + everyone gets by default. + +| Ref | Role | +| --- | --- | +| `/` | all work; branch off `preview` | +| `preview` | integration branch; **beta** releases are cut here | +| `master` | released state; **final** releases are cut here | +| `X.Y.Z.devN` tag | publishes a pre-release | +| `X.Y.Z` tag | publishes a final release | + +Nothing publishes on a branch push. Only pushing a tag publishes. + +| What a user runs | What they get | +| --- | --- | +| `pip install numerapi` / `pip install -U numerapi` | latest **final** version | +| `pip install 'numerapi==3.2.0.dev0'` | that exact pre-release | +| `pip install --pre numerapi` | latest including pre-releases | + +## Conventions + +- **Tags are bare version numbers and must match `setup.py` exactly:** `3.2.0`, + `3.2.0.dev0`. A `v` prefix is not allowed — CI rejects `v3.2.0` with an + explicit error. +- Use the canonical PEP 440 spelling with the dot: `3.2.0.dev0`, not `3.2.0dev0`. + Both normalize to the same release, but the canonical form avoids confusion. +- Pre-releases use `.devN`. Increment `N` for each beta on the same version line. +- **A version number can never be reused.** PyPI permanently rejects re-uploading + a version, even one that was deleted. If you burn a number, move to the next. + +## Develop without releasing + +```bash +git checkout preview && git pull +git checkout -b josh/some-feature +# ... work ... +git push -u origin josh/some-feature +gh pr create --base preview +``` + +Tests and lint run on every push. No tag means nothing is published. `preview` +can sit ahead of `master` indefinitely — that is what it is for. Leave +`setup.py` alone until you are actually cutting something. + +## Cut a beta (from `preview`) + +For beta users who need the code before it is stable. + +```bash +git checkout preview && git pull + +# 1. setup.py: numerapi_version = "3.2.0.dev0" +# 2. CHANGELOG.md: open an entry +# ## [3.2.0] - Unreleased +# - what changed +git commit -am "numerapi 3.2.0.dev0" +git push origin preview # publishes nothing + +# 3. tag and push — this is the release event +git tag 3.2.0.dev0 +git push origin 3.2.0.dev0 +``` + +Verify: + +```bash +gh run list --workflow=pypi.yml --limit 1 # expect success +pip install 'numerapi==3.2.0.dev0' # what beta users run +pip install -U numerapi # must NOT be the dev version +``` + +Tell beta users to install the exact version. Note that `pip index versions` and +the simple index can lag a few minutes behind a successful publish on CDN cache; +an exact-version install works immediately. + +For the next beta, repeat with `.dev1`, `.dev2`, … + +## Promote to a final release (from `master`) + +Flip the version to final **on `preview`, as the last commit before merging**, so +`master` never holds a pre-release string and picks up the release version +atomically at merge. + +```bash +git checkout preview && git pull + +# 1. setup.py: numerapi_version = "3.2.0" (drop the .devN suffix) +# 2. CHANGELOG.md: date the entry, e.g. ## [3.2.0] - 2026-08-17 +git commit -am "numerapi 3.2.0" +git push origin preview + +# 3. merge preview into master +gh pr create --base master --head preview --title "numerapi 3.2.0" +gh pr merge --merge + +# 4. tag master +git checkout master && git pull +grep numerapi_version setup.py # must read exactly 3.2.0 +git tag 3.2.0 +git push origin 3.2.0 + +# 5. keep preview caught up so it does not drift +git checkout preview && git merge master && git push origin preview +``` + +Verify with `pip install -U numerapi`. + +Then bump the numerapi pin in `tournament-monorepo` (`shared`, `init-round`, +`integration-test`, `compute-pickle-scheduler`). That PR moving through the +monorepo's own staging → master is what carries the new numerapi into staging +and prod images. Never pin a `.devN` version in anything that reaches prod. + +## Hotfix a released version + +Use this when `master` is released and `preview` holds unreleased work you do not +want to ship yet. + +```bash +git checkout -b hotfix/3.2.1 master # branch off master, NOT preview +# fix + setup.py 3.2.1 + CHANGELOG entry +gh pr create --base master +# after merge: +git checkout master && git pull +git tag 3.2.1 && git push origin 3.2.1 +git checkout preview && git merge master && git push origin preview +``` + +## Documentation + +Read the Docs is fully automatic — there is nothing to tag or move. + +- `/en/latest/` tracks `master`. +- `/en/stable/` tracks the greatest **non-pre-release** semver tag, so cutting + `3.2.0` promotes it; `3.2.0.dev0` is correctly ignored. + +Do not create a tag or branch named `stable`. That overrides the automatic +behavior above, has to be force-moved by hand on every release, and silently goes +stale when someone forgets. One used to exist here and was removed for exactly +those reasons. The trade-off is that a docs-only fix reaches `/en/latest/` +immediately but does not appear on `/en/stable/` until the next release; if that +matters, cut a patch release. + +## What CI enforces + +`.github/workflows/pypi.yml` runs on tag pushes that start with a digit (and on +`v`-prefixed tags, solely to reject them). It refuses to publish unless: + +1. **The tag has no `v` prefix.** `v3.2.0` fails with an error telling you to + re-tag as `3.2.0`. +2. **The tag matches `setup.py`.** Compared as normalized PEP 440 versions, so + `3.2.0dev0` and `3.2.0.dev0` are equivalent, but `3.2.0` against a `setup.py` + of `3.2.0.dev0` fails. +3. **Final releases point at a commit on `master`.** Pre-releases skip this + check, so betas can be cut from `preview` but a final one cannot. + +`pytest.yml` (Python 3.10–3.14) and `ruff.yml` run on every push and PR. + +## Troubleshooting + +**`File already exists` on publish.** That version is already on PyPI. Bump to +the next number — you cannot re-upload, and you cannot fix it by deleting the +release on PyPI either. + +**Tag mismatch error.** You tagged without bumping `setup.py`, or vice versa. Fix +`setup.py`, commit, delete the tag locally and on origin +(`git push origin :refs/tags/X.Y.Z`), then re-tag. Deleting a tag never publishes +anything. + +**"Final release tags must point at a commit on master."** You tagged a +suffix-free version on `preview`. Either merge to `master` first, or cut it as a +`.devN` pre-release instead. + +**Do not retro-tag old releases.** Any new tag starting with a digit triggers a +publish attempt that will fail on a duplicate version. Historical tags are +inconsistent (some `v`-prefixed, some not, several `.devN` tags that published +final versions before the guards existed) — leave them as they are. + +**Do not delete the `3.0.0.dev2` tag.** The commit it points at is on no branch, +and that tag is the only thing keeping the source of the published 3.0.0 +reachable. diff --git a/docs/round-score-configs.md b/docs/round-score-configs.md index 99db25d..bac0613 100644 --- a/docs/round-score-configs.md +++ b/docs/round-score-configs.md @@ -21,7 +21,7 @@ Each item includes: consistent with other date fields in numerapi. GraphQL float and integer fields retain their normal Python JSON types. -## Migrating from legacy multiplier keys +## Legacy multiplier keys removed in 3.0.0 Before 2.24.0, `list_rounds()` requested server compatibility fields. For a Signals round, a response could look like this even though the payout scores @@ -34,8 +34,8 @@ were Alpha and MPC: } ``` -In 2.24.0 the exact identities are available without knowing score names in -advance: +In 3.0.0, the exact identities are the only payout configuration returned by +`list_rounds()`: ```python { @@ -57,29 +57,16 @@ advance: "isPayout": True, "defaultMultiplier": 0.8, }, - ], - "defaultCorrMultiplier": None, - "defaultMmcMultiplier": None, + ] } ``` -The six established Corr/MMC keys (`min`, `max`, and `default` for each) stay -in the returned round dictionary throughout numerapi 2.x. They are now -identity-safe projections: Corr keys select only a payout config whose `name` -is exactly `correlation`, MMC keys select only a payout config whose `name` is -exactly `meta_model_contribution`, and the keys are `None` when there is no -exact match. Alpha and FNC are never projected as Corr; MPC is never projected -as MMC. If multiple exact payout configs exist, the projection uses the config -with the newest `roundNumberStart`, then compares the numeric `version` values -as integers and uses `id` for a numeric-version tie. If multiple configs at the -newest start contain a non-numeric future version, the compatibility keys are -`None` rather than guessing an order. The complete list remains available -unchanged in either case. - -These six compatibility keys are scheduled for removal in numerapi 3.0.0. -`list_rounds()` never exposed the three legacy TC multiplier fields, so this -migration does not introduce them. Code should migrate now by filtering -`roundScoreConfigs`, normally starting with `isPayout`. +The six Corr/MMC compatibility keys (`min`, `max`, and `default` for each) are +no longer added to the returned round dictionary. `list_rounds()` never +exposed the three legacy TC multiplier fields. Code should filter +`roundScoreConfigs`, normally starting with `isPayout`, and preserve each +configuration's `name`, `version`, and `scoreConfigId` rather than projecting +different scores into Corr or MMC roles. ## Deprecated performance endpoint diff --git a/numerapi/base_api.py b/numerapi/base_api.py index 8d332c4..afde839 100644 --- a/numerapi/base_api.py +++ b/numerapi/base_api.py @@ -751,13 +751,6 @@ def list_rounds( list of dicts: round entries matching the provided filters. Each entry includes ``roundScoreConfigs``, whose items retain the exact score identity and per-round payout settings returned by the API. - - The legacy ``minCorrMultiplier`` through - ``defaultMmcMultiplier`` keys remain until numerapi 3.0.0. They are - compatibility projections of payout configs whose names are - exactly ``correlation`` or ``meta_model_contribution``; they are - ``None`` when no such payout config exists. Use - ``roundScoreConfigs`` for all new integrations. """ query = """ query($tournament: Int @@ -835,57 +828,8 @@ def list_rounds( utils.replace( config, "scoringEnd", utils.parse_datetime_string ) - self._add_legacy_round_multipliers(round_info) return rounds - @staticmethod - def _add_legacy_round_multipliers(round_info: dict) -> None: - """Add deprecated, identity-safe round multiplier projections.""" - legacy_scores = { - "Corr": "correlation", - "Mmc": "meta_model_contribution", - } - multiplier_fields = { - "min": "minMultiplier", - "max": "maxMultiplier", - "default": "defaultMultiplier", - } - - for legacy_name, score_name in legacy_scores.items(): - matches = [ - config - for config in round_info["roundScoreConfigs"] - if config["isPayout"] and config["name"] == score_name - ] - config = Api._select_legacy_round_config(matches) - for prefix, config_field in multiplier_fields.items(): - field = f"{prefix}{legacy_name}Multiplier" - round_info[field] = ( - None if config is None else config[config_field] - ) - - @staticmethod - def _select_legacy_round_config(configs: List[Dict]) -> Dict | None: - """Select the latest config, failing closed on ambiguous versions.""" - if not configs: - return None - - latest_start = max(item["roundNumberStart"] for item in configs) - candidates = [ - item for item in configs if item["roundNumberStart"] == latest_start - ] - if len(candidates) == 1: - return candidates[0] - - try: - return max( - candidates, - key=lambda item: (int(item["version"]), item["id"]), - ) - except (TypeError, ValueError): - # A future non-numeric version contract cannot be ordered safely. - return None - def set_bio(self, model_id: str, bio: str) -> bool: """Set bio field for a model id. diff --git a/requirements.txt b/requirements.txt index 156d614..403675b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ pytz tqdm>=4.29.1 click>=7.0 fsspec[http] -pandas>=1.1.0 +pandas>=1.1.0; python_version < "3.14" +pandas>=2.3.3; python_version >= "3.14" diff --git a/requirements_tests.txt b/requirements_tests.txt index 2916994..8b9980f 100644 --- a/requirements_tests.txt +++ b/requirements_tests.txt @@ -3,4 +3,5 @@ pytest-cov codecov responses flake8 -pandas>=1.1.0 +pandas>=1.1.0; python_version < "3.14" +pandas>=2.3.3; python_version >= "3.14" diff --git a/setup.py b/setup.py index 1d93053..3370a33 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ def load(path): return open(path, "r").read() -numerapi_version = "2.24.0" +numerapi_version = "3.1.2.dev1" classifiers = [ "Development Status :: 5 - Production/Stable", @@ -15,6 +15,11 @@ def load(path): "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering", ] @@ -23,14 +28,15 @@ def load(path): setup( name="numerapi", version=numerapi_version, - maintainer="uuazed", - maintainer_email="uuazed@gmail.com", + maintainer="Numerai", + maintainer_email="tournament@numer.ai", description="Automatically download and upload data for the Numerai machine learning competition", long_description=load("README.md"), long_description_content_type="text/markdown", - url="https://github.com/uuazed/numerapi", + url="https://github.com/numerai/numerapi", platforms="OS Independent", classifiers=classifiers, + python_requires=">=3.10", license="MIT License", package_data={"numerapi": ["LICENSE", "README.md", "py.typed"]}, packages=find_packages(exclude=["tests"]), @@ -41,7 +47,8 @@ def load(path): "tqdm>=4.29.1", "click>=7.0", "fsspec[http]", - "pandas>=1.1.0", + "pandas>=1.1.0; python_version < '3.14'", + "pandas>=2.3.3; python_version >= '3.14'", ], entry_points={"console_scripts": ["numerapi = numerapi.cli:cli"]}, ) diff --git a/tests/test_base_api.py b/tests/test_base_api.py index 7e832f1..d10622a 100644 --- a/tests/test_base_api.py +++ b/tests/test_base_api.py @@ -369,8 +369,14 @@ def test_list_rounds(api): res[0]["roundScoreConfigs"][0]["scoringEnd"], datetime.datetime ) assert res[0]["roundScoreConfigs"][0]["scoreConfigId"] == "classic-corr" - assert res[0]["defaultCorrMultiplier"] == 0.75 - assert res[0]["defaultMmcMultiplier"] == 2.25 + assert not { + "minCorrMultiplier", + "maxCorrMultiplier", + "defaultCorrMultiplier", + "minMmcMultiplier", + "maxMmcMultiplier", + "defaultMmcMultiplier", + }.intersection(res[0]) request_body = json.loads(responses.calls[0].request.body) assert request_body["variables"]["tournament"] == 8 @@ -403,18 +409,23 @@ def test_list_rounds(api): } assert "roundScoreConfigs" in request_body["query"] assert all(field in request_body["query"] for field in requested_fields) - assert "minCorrMultiplier" not in request_body["query"] - assert "minMmcMultiplier" not in request_body["query"] + assert not { + "minCorrMultiplier", + "maxCorrMultiplier", + "defaultCorrMultiplier", + "minMmcMultiplier", + "maxMmcMultiplier", + "defaultMmcMultiplier", + }.intersection(request_body["query"].split()) @pytest.mark.parametrize( - ("api_class", "tournament", "score_names", "legacy_multipliers"), + ("api_class", "tournament", "score_names"), [ ( numerapi.NumerAPI, 8, ["correlation", "meta_model_contribution"], - (0.5, 0.5), ), ( numerapi.SignalsAPI, @@ -424,19 +435,17 @@ def test_list_rounds(api): "v4_feature_neutral_correlation", "meta_portfolio_contribution", ], - (None, None), ), ( numerapi.CryptoAPI, 12, ["correlation", "meta_model_contribution"], - (0.5, 0.5), ), ], ) @responses.activate def test_list_rounds_preserves_tournament_score_identities( - api_class, tournament, score_names, legacy_multipliers + api_class, tournament, score_names ): api = api_class() configs = [_round_score_config(name) for name in score_names] @@ -452,8 +461,6 @@ def test_list_rounds_preserves_tournament_score_identities( assert [ config["name"] for config in returned_round["roundScoreConfigs"] ] == score_names - assert returned_round["defaultCorrMultiplier"] == legacy_multipliers[0] - assert returned_round["defaultMmcMultiplier"] == legacy_multipliers[1] request_body = json.loads(responses.calls[0].request.body) assert request_body["variables"]["tournament"] == tournament @@ -492,74 +499,6 @@ def test_list_rounds_keeps_coexisting_and_unfamiliar_score_configs(api): config["scoreConfigId"] for config in returned_round["roundScoreConfigs"] ] == [config["scoreConfigId"] for config in configs] - assert returned_round["defaultCorrMultiplier"] == 0.4 - assert returned_round["defaultMmcMultiplier"] == 0.6 - - -@responses.activate -def test_list_rounds_orders_numeric_score_versions_numerically(api): - configs = [ - _round_score_config( - "correlation", - config_id="corr-9", - version="9", - multiplier=0.9, - round_number_start=200, - ), - _round_score_config( - "correlation", - config_id="corr-10", - version="10", - multiplier=1.0, - round_number_start=200, - ), - ] - responses.add( - responses.POST, - base_api.API_TOURNAMENT_URL, - json={"data": {"rounds": [{"roundScoreConfigs": configs}]}}, - ) - - returned_round = api.list_rounds()[0] - - assert [ - returned_round["minCorrMultiplier"], - returned_round["maxCorrMultiplier"], - returned_round["defaultCorrMultiplier"], - ] == [1.0, 1.0, 1.0] - - -@responses.activate -def test_list_rounds_fails_closed_for_ambiguous_non_numeric_versions(api): - configs = [ - _round_score_config( - "correlation", - config_id="corr-10", - version="10", - multiplier=1.0, - round_number_start=200, - ), - _round_score_config( - "correlation", - config_id="corr-next", - version="next", - multiplier=1.1, - round_number_start=200, - ), - ] - responses.add( - responses.POST, - base_api.API_TOURNAMENT_URL, - json={"data": {"rounds": [{"roundScoreConfigs": configs}]}}, - ) - - returned_round = api.list_rounds()[0] - - assert [ - returned_round["minCorrMultiplier"], - returned_round["maxCorrMultiplier"], - returned_round["defaultCorrMultiplier"], - ] == [None, None, None] @responses.activate