diff --git a/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE/ISSUE_TEMPLATE.md similarity index 100% rename from ISSUE_TEMPLATE.md rename to .github/ISSUE_TEMPLATE/ISSUE_TEMPLATE.md index 11290dc66..d38955bc0 100644 --- a/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE/ISSUE_TEMPLATE.md @@ -18,6 +18,7 @@ following rules before submitting: Example: ```python import openml + openml.flows.get_flow(1) ``` If the code is too long, feel free to put it in a public gist and link @@ -46,4 +47,3 @@ import openml; print("OpenML", openml.__version__) - diff --git a/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md similarity index 94% rename from PULL_REQUEST_TEMPLATE.md rename to .github/PULL_REQUEST_TEMPLATE.md index 5584e6438..585989c2f 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -5,7 +5,7 @@ the contribution guidelines: https://github.com/openml/openml-python/blob/main/C Please make sure that: * the title of the pull request is descriptive -* this pull requests is against the `develop` branch +* this pull requests is against the `main` branch * for any new functionality, consider adding a relevant example * add unit tests for new functionalities * collect files uploaded to test server using _mark_entity_for_removal() @@ -19,7 +19,7 @@ Please make sure that: * Change Log Entry: -#### Details +#### Details - diff --git a/.github/workflows/dist.yaml b/.github/workflows/dist.yaml index b81651cea..3c4e242e2 100644 --- a/.github/workflows/dist.yaml +++ b/.github/workflows/dist.yaml @@ -6,14 +6,12 @@ on: push: branches: - main - - develop tags: - "v*.*.*" pull_request: branches: - main - - develop concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -23,11 +21,11 @@ jobs: dist: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: - python-version: 3.8 + python-version: "3.10" - name: Build dist run: | pip install build diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index b583b6423..9f4bc4bd6 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -5,14 +5,12 @@ on: push: branches: - main - - develop tags: - "v*.*.*" pull_request: branches: - main - - develop concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -22,13 +20,13 @@ jobs: build-and-deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: Setup Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: - python-version: 3.8 + python-version: "3.10" - name: Install dependencies run: | pip install -e .[docs,examples] @@ -40,7 +38,7 @@ jobs: CI: false GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PAGES_BRANCH: gh-pages - if: (contains(github.ref, 'develop') || contains(github.ref, 'main')) && github.event_name == 'push' + if: contains(github.ref, 'main') && github.event_name == 'push' run: | git config user.name doc-bot git config user.email doc-bot@openml.com diff --git a/.github/workflows/release_docker.yaml b/.github/workflows/release_docker.yaml index fc629a4e4..4973e5706 100644 --- a/.github/workflows/release_docker.yaml +++ b/.github/workflows/release_docker.yaml @@ -4,7 +4,7 @@ on: workflow_dispatch: push: branches: - - 'develop' + - 'main' - 'docker' tags: - 'v*' @@ -24,7 +24,7 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Login to DockerHub if: github.event_name != 'pull_request' @@ -34,17 +34,17 @@ jobs: password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Check out the repo - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Extract metadata (tags, labels) for Docker Hub id: meta_dockerhub - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: images: "openml/openml-python" - name: Build and push id: docker_build - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: ./docker/ tags: ${{ steps.meta_dockerhub.outputs.tags }} @@ -54,13 +54,13 @@ jobs: - name: Update repo description if: ${{ startsWith(github.ref, 'refs/tags/v') }} - uses: peter-evans/dockerhub-description@v4 + uses: peter-evans/dockerhub-description@v5 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} repository: openml/openml-python short-description: "pre-installed openml-python environment" readme-filepath: ./docker/readme.md - + - name: Image digest run: echo ${{ steps.docker_build.outputs.digest }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 31cdff602..4bd72f974 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,3 +1,4 @@ +--- name: Tests on: @@ -6,14 +7,12 @@ on: push: branches: - main - - develop tags: - "v*.*.*" pull_request: branches: - main - - develop concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -21,72 +20,87 @@ concurrency: jobs: test: - name: (${{ matrix.os }}, Py${{ matrix.python-version }}, sk${{ matrix.scikit-learn }}, sk-only:${{ matrix.sklearn-only }}) + name: (${{ matrix.os }},Py${{ matrix.python-version }},sk${{ matrix.scikit-learn }}${{ matrix.pandas-version != '' && format(',pd:{0}', matrix.pandas-version) || '' }},sk-only:${{ matrix.sklearn-only }}) runs-on: ${{ matrix.os }} + strategy: + fail-fast: false matrix: - python-version: ["3.9"] - scikit-learn: ["1.0.*", "1.1.*", "1.2.*", "1.3.*", "1.4.*", "1.5.*"] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + scikit-learn: ["1.3.*", "1.4.*", "1.5.*", "1.6.*", "1.7.*"] os: [ubuntu-latest] sklearn-only: ["true"] + + exclude: + # (python, sklearn) combinations for which there is no PyPI release + # scikit-learn 1.3 + - python-version: "3.13" + scikit-learn: "1.3.*" + - python-version: "3.14" + scikit-learn: "1.3.*" + # scikit-learn 1.4 + - python-version: "3.13" + scikit-learn: "1.4.*" + - python-version: "3.14" + scikit-learn: "1.4.*" + # scikit-learn 1.5 + - python-version: "3.14" + scikit-learn: "1.5.*" + # scikit-learn 1.6 + - python-version: "3.14" + scikit-learn: "1.6.*" + # scikit-learn 1.7 is installed with pandas 3 + - python-version: "3.10" + scikit-learn: "1.7.*" + + include: + # Full test run on ubuntu, 3.14 - os: ubuntu-latest - python-version: "3.8" # no scikit-learn 0.23 release for Python 3.9 - scikit-learn: "0.23.1" - sklearn-only: "true" - # scikit-learn 0.24 relies on scipy defaults, so we need to fix the version - # c.f. https://github.com/openml/openml-python/pull/1267 - - os: ubuntu-latest - python-version: "3.9" - scikit-learn: "0.24" - scipy: "1.10.0" - sklearn-only: "true" - # Do a Windows and Ubuntu test for _all_ openml functionality - # I am not sure why these are on 3.8 and older scikit-learn + python-version: "3.14" + scikit-learn: "1.7.*" + sklearn-only: "false" + + # Full test run on Windows - os: windows-latest - python-version: "3.8" - scikit-learn: 0.24.* - scipy: "1.10.0" - sklearn-only: 'false' - # Include a code cov version + python-version: "3.12" + scikit-learn: "1.5.*" + sklearn-only: "false" + + # Coverage run - os: ubuntu-latest + python-version: "3.12" + scikit-learn: "1.5.*" + sklearn-only: "false" code-cov: true - python-version: "3.8" - scikit-learn: 0.23.1 - sklearn-only: 'false' - fail-fast: false steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 2 + - name: Setup Python ${{ matrix.python-version }} - if: matrix.os != 'windows-latest' # windows-latest only uses preinstalled Python (3.9.13) - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} - - name: Install test dependencies + + - name: Install test dependencies, scikit-learn, and pandas + shell: bash run: | python -m pip install --upgrade pip - pip install -e .[test] - - name: Install scikit-learn ${{ matrix.scikit-learn }} - run: | - pip install scikit-learn==${{ matrix.scikit-learn }} - - name: Install numpy for Python 3.8 - # Python 3.8 & scikit-learn<0.24 requires numpy<=1.23.5 - if: ${{ matrix.python-version == '3.8' && matrix.scikit-learn == '0.23.1' }} - run: | - pip install numpy==1.23.5 - - name: "Install NumPy 1.x and SciPy <1.11 for scikit-learn < 1.4" - if: ${{ contains(fromJSON('["1.0.*", "1.1.*", "1.2.*", "1.3.*"]'), matrix.scikit-learn) }} - run: | - # scipy has a change to the 'mode' behavior which breaks scikit-learn < 1.4 - # numpy 2.0 has several breaking changes - pip install "numpy<2.0" "scipy<1.11" - - name: Install scipy ${{ matrix.scipy }} - if: ${{ matrix.scipy }} - run: | - pip install scipy==${{ matrix.scipy }} + pip install -e .[test] scikit-learn==${{ matrix.scikit-learn }} + + # scikit-learn 1.7+ requires pandas 3.x, earlier versions use pandas 2.x + version="${{ matrix.scikit-learn }}" + major=$(echo "$version" | cut -d. -f1) + minor=$(echo "$version" | cut -d. -f2) + + if [[ "$major" -gt 1 ]] || { [[ "$major" -eq 1 ]] && [[ "$minor" -ge 7 ]]; }; then + pip install "pandas==3.*" + else + pip install "pandas==2.*" "pyarrow<26" + fi + - name: Store repository status id: status-before if: matrix.os != 'windows-latest' @@ -94,28 +108,99 @@ jobs: git_status=$(git status --porcelain -b) echo "BEFORE=$git_status" >> $GITHUB_ENV echo "Repository status before tests: $git_status" + + - name: Clone Services + if: matrix.os == 'ubuntu-latest' + id: clone-services + run: | + git clone --depth 1 https://github.com/openml/services.git + + - name: Start Docker Services + id: start-services + if: matrix.os == 'ubuntu-latest' + working-directory: ./services + run: | + chmod -R a+rw ./data + chmod -R a+rw ./logs + docker compose --profile rest-api --profile minio --profile evaluation-engine up -d + + echo "Waiting for PHP API to boot..." + timeout 60s bash -c 'until [ "$(docker inspect -f {{.State.Health.Status}} openml-php-rest-api)" == "healthy" ]; do sleep 5; done' + + echo "Final Verification: Gateway Connectivity..." + curl -sSfL http://localhost:8000/api/v1/xml/data/1 | head -n 15 + + docker container ls + - name: Show installed dependencies run: python -m pip list + - name: Run tests on Ubuntu Test if: matrix.os == 'ubuntu-latest' + env: + OPENML_TEST_SERVER_ADMIN_KEY: ${{ secrets.OPENML_TEST_SERVER_ADMIN_KEY }} + OPENML_USE_LOCAL_SERVICES: "true" run: | - if [ ${{ matrix.code-cov }} ]; then codecov='--cov=openml --long --cov-report=xml'; fi - # Most of the time, running only the scikit-learn tests is sufficient - if [ ${{ matrix.sklearn-only }} = 'true' ]; then marks='sklearn and not production'; else marks='not production'; fi - echo pytest -n 4 --durations=20 --dist load -sv $codecov -o log_cli=true -m "$marks" + if [ "${{ matrix.code-cov }}" = "true" ]; then + codecov="--cov=openml --long --cov-report=xml" + fi + + if [ "${{ matrix.sklearn-only }}" = "true" ]; then + marks="sklearn and not production_server" + else + marks="not production_server" + fi + pytest -n 4 --durations=20 --dist load -sv $codecov -o log_cli=true -m "$marks" + - name: Run tests on Ubuntu Production if: matrix.os == 'ubuntu-latest' + env: + OPENML_TEST_SERVER_ADMIN_KEY: ${{ secrets.OPENML_TEST_SERVER_ADMIN_KEY }} + OPENML_USE_LOCAL_SERVICES: "true" run: | - if [ ${{ matrix.code-cov }} ]; then codecov='--cov=openml --long --cov-report=xml'; fi - # Most of the time, running only the scikit-learn tests is sufficient - if [ ${{ matrix.sklearn-only }} = 'true' ]; then marks='sklearn and production'; else marks='production'; fi - echo pytest -n 4 --durations=20 --dist load -sv $codecov -o log_cli=true -m "$marks" + if [ "${{ matrix.code-cov }}" = "true" ]; then + codecov="--cov=openml --long --cov-report=xml" + fi + + if [ "${{ matrix.sklearn-only }}" = "true" ]; then + marks="sklearn and production_server" + else + marks="production_server" + fi + pytest -n 4 --durations=20 --dist load -sv $codecov -o log_cli=true -m "$marks" + - name: Run tests on Windows if: matrix.os == 'windows-latest' + env: + OPENML_TEST_SERVER_ADMIN_KEY: ${{ secrets.OPENML_TEST_SERVER_ADMIN_KEY }} run: | # we need a separate step because of the bash-specific if-statement in the previous one. - pytest -n 4 --durations=20 --dist load -sv --reruns 5 --reruns-delay 1 + pytest -n 4 --durations=20 --dist load -sv --reruns 5 --reruns-delay 1 -m "not test_server" + + - name: Upload coverage + if: matrix.code-cov && always() + uses: codecov/codecov-action@v7 + with: + files: coverage.xml + token: ${{ secrets.CODECOV_TOKEN }} + cleanup: true + fail_ci_if_error: true + verbose: true + + - name: Dump server logs + if: always() && steps.start-services.outcome == 'success' + run: | + docker logs openml-php-rest-api -t + + - name: Cleanup Docker setup + if: always() && steps.clone-services.outcome == 'success' + working-directory: ./services + run: | + docker compose --profile rest-api --profile minio --profile evaluation-engine down -v + cd .. + sudo rm -rf services + - name: Check for files left behind by test if: matrix.os != 'windows-latest' && always() run: | @@ -127,11 +212,3 @@ jobs: echo "Not all generated files have been deleted!" exit 1 fi - - name: Upload coverage - if: matrix.code-cov && always() - uses: codecov/codecov-action@v4 - with: - files: coverage.xml - token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true - verbose: true diff --git a/.gitignore b/.gitignore index 132070bf3..d512c0ee6 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,8 @@ target/ .idea *.swp .vscode +.cursorignore +.cursorindexingignore # MYPY .mypy_cache @@ -96,4 +98,17 @@ dmypy.sock # Tests .pytest_cache -.venv \ No newline at end of file + +# Virtual environments +oenv/ +venv/ +.env/ +.venv +.venv/ + +# Python cache +__pycache__/ +*.pyc + +# Ruff +.ruff-cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 95e2a5239..f69cafe88 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,26 +1,22 @@ +exclude: '^tests/files/' default_language_version: python: python3 -files: | - (?x)^( - openml| - tests - )/.*\.py$ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.3 + rev: v0.17.1 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix, --no-cache] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.13.0 + rev: v2.4.0 hooks: - id: mypy additional_dependencies: - types-requests - types-python-dateutil - repo: https://github.com/python-jsonschema/check-jsonschema - rev: 0.29.4 + rev: 0.38.2 hooks: - id: check-github-workflows files: '^github/workflows/.*\.ya?ml$' @@ -28,21 +24,18 @@ repos: - id: check-dependabot files: '^\.github/dependabot\.ya?ml$' - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-added-large-files - files: ".*" - id: check-case-conflict - files: ".*" - id: check-merge-conflict - files: ".*" - id: check-yaml - files: ".*" + exclude: ^mkdocs\.yml$ - id: end-of-file-fixer - files: ".*" - types: ["yaml"] - id: check-toml - files: ".*" types: ["toml"] - id: debug-statements files: '^src/.*\.py$' + - id: mixed-line-ending + args: ['--fix=lf'] + - id: trailing-whitespace diff --git a/CITATION.cff b/CITATION.cff index c5454ef6f..8bf714750 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,5 +1,12 @@ cff-version: 1.2.0 -message: "If you use this software in a publication, please cite the metadata from preferred-citation." +title: "OpenML Python" +message: "If you use this software in a publication, please cite the metadata from 'preferred-citation'." +type: software +license: "BSD-3-Clause" +repository-code: "https://github.com/openml/openml-python" +url: "https://openml.github.io/openml-python/" +authors: + - name: "OpenML-Python Contributors" preferred-citation: type: article authors: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 35ab30b4a..d6fe25f63 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,8 +28,8 @@ Great! You've decided you want to help out. Now what? All contributions should be linked to issues on the [GitHub issue tracker](https://github.com/openml/openml-python/issues). In particular for new contributors, the *good first issue* label should help you find issues which are suitable for beginners. Resolving these issues allows you to start -contributing to the project without much prior knowledge. Your assistance in this area -will be greatly appreciated by the more experienced developers as it helps free up +contributing to the project without much prior knowledge. Your assistance in this area +will be greatly appreciated by the more experienced developers as it helps free up their time to concentrate on other issues. If you encounter a particular part of the documentation or code that you want to improve, @@ -39,16 +39,16 @@ This is important since you can first get feedback or pointers from experienced To let everyone know you are working on an issue, please leave a comment that states you will work on the issue (or, if you have the permission, *assign* yourself to the issue). This avoids double work! -## Contributing Workflow Overview +## Contributing Workflow Overview To contribute to the openml-python package, follow these steps: 0. Determine how you want to contribute (see above). 1. Set up your local development environment. - 1. Fork and clone the `openml-python` repository. Then, create a new branch from the ``develop`` branch. If you are new to `git`, see our [detailed documentation](#basic-git-workflow), or rely on your favorite IDE. + 1. Fork and clone the `openml-python` repository. Then, create a new branch from the ``main`` branch. If you are new to `git`, see our [detailed documentation](#basic-git-workflow), or rely on your favorite IDE. 2. [Install the local dependencies](#install-local-dependencies) to run the tests for your contribution. 3. [Test your installation](#testing-your-installation) to ensure everything is set up correctly. 4. Implement your contribution. If contributing to the documentation, see [here](#contributing-to-the-documentation). -5. [Create a pull request](#pull-request-checklist). +5. [Create a pull request](#pull-request-checklist). ### Install Local Dependencies @@ -56,11 +56,11 @@ We recommend following the instructions below to install all requirements locall However, it is also possible to use the [openml-python docker image](https://github.com/openml/openml-python/blob/main/docker/readme.md) for testing and building documentation. Moreover, feel free to use any alternative package managers, such as `pip`. -1. To ensure a smooth development experience, we recommend using the `uv` package manager. Thus, first install `uv`. If any Python version already exists on your system, follow the steps below, otherwise see [here](https://docs.astral.sh/uv/getting-started/installation/). +1. To ensure a smooth development experience, we recommend using the `uv` package manager. Thus, first install `uv`. If any Python version already exists on your system, follow the steps below, otherwise see [here](https://docs.astral.sh/uv/getting-started/installation/). ```bash pip install uv ``` -2. Create a virtual environment using `uv` and activate it. This will ensure that the dependencies for `openml-python` do not interfere with other Python projects on your system. +2. Create a virtual environment using `uv` and activate it. This will ensure that the dependencies for `openml-python` do not interfere with other Python projects on your system. ```bash uv venv --seed --python 3.8 ~/.venvs/openml-python source ~/.venvs/openml-python/bin/activate @@ -91,14 +91,51 @@ pytest tests/test_datasets/test_dataset.py::OpenMLDatasetTest pytest tests/test_datasets/test_dataset.py::OpenMLDatasetTest::test_get_data ``` -To test your new contribution, add [unit tests](https://github.com/openml/openml-python/tree/develop/tests), and, if needed, [examples](https://github.com/openml/openml-python/tree/develop/examples) for any new functionality being introduced. Some notes on unit tests and examples: +To test your new contribution, add [unit tests](https://github.com/openml/openml-python/tree/main/tests), and, if needed, [examples](https://github.com/openml/openml-python/tree/main/examples) for any new functionality being introduced. Some notes on unit tests and examples: * If a unit test contains an upload to the test server, please ensure that it is followed by a file collection for deletion, to prevent the test server from bulking up. For example, `TestBase._mark_entity_for_removal('data', dataset.dataset_id)`, `TestBase._mark_entity_for_removal('flow', (flow.flow_id, flow.name))`. * Please ensure that the example is run on the test server by beginning with the call to `openml.config.start_using_configuration_for_example()`, which is done by default for tests derived from `TestBase`. * Add the `@pytest.mark.sklearn` marker to your unit tests if they have a dependency on scikit-learn. +#### Running Tests That Require Admin Privileges + +Some tests require admin privileges on the test server and will be automatically skipped unless you provide an admin API key. For regular contributors, the tests will skip gracefully. For core contributors who need to run these tests locally, you can set up the key by exporting the variable as below before running the tests: + +```bash +# For windows +$env:OPENML_TEST_SERVER_ADMIN_KEY = "admin-key" +# For linux/mac +export OPENML_TEST_SERVER_ADMIN_KEY="admin-key" +``` + +#### Diagnosing Slow Tests + +If you suspect a test (or the suite as a whole) is running too slowly, `pytest` already exposes everything you need to investigate it. A few invocations that are useful when looking into test runtimes: + +```bash +# Show the 20 slowest tests (use 0 to list every test's duration) +pytest tests --durations=20 + +# Fail any test that exceeds the given timeout (requires pytest-timeout) +pytest tests --timeout=600 + +# Investigate only fixture/setup costs without actually running the tests +pytest tests --setup-only + +# Profile a specific module, class, or test +pytest tests/test_datasets/test_dataset.py --durations=0 + +# Skip the slow live-server tests while profiling locally +pytest tests --durations=0 -m "not production_server and not test_server" + +# Run the suite in parallel to reproduce CI behaviour (requires pytest-xdist) +pytest tests -n 4 --dist=load --durations=0 +``` + +Combining these with the marker filters (`production_server`, `test_server`, `sklearn`) makes it straightforward to narrow the investigation down to the slow tests without changing project configuration. + ### Pull Request Checklist -You can go to the `openml-python` GitHub repository to create the pull request by [comparing the branch](https://github.com/openml/openml-python/compare) from your fork with the `develop` branch of the `openml-python` repository. When creating a pull request, make sure to follow the comments and structured provided by the template on GitHub. +You can go to the `openml-python` GitHub repository to create the pull request by [comparing the branch](https://github.com/openml/openml-python/compare) from your fork with the `main` branch of the `openml-python` repository. When creating a pull request, make sure to follow the comments and structured provided by the template on GitHub. **An incomplete contribution** -- where you expect to do more work before receiving a full review -- should be submitted as a `draft`. These may be useful @@ -108,7 +145,7 @@ Drafts often benefit from the inclusion of a [task list](https://github.com/blog/1375-task-lists-in-gfm-issues-pulls-comments) in the PR description. ---- +--- # Appendix @@ -116,8 +153,7 @@ in the PR description. The preferred workflow for contributing to openml-python is to fork the [main repository](https://github.com/openml/openml-python) on -GitHub, clone, check out the branch `develop`, and develop on a new branch -branch. Steps: +GitHub, clone, and develop on a new branch: 0. Make sure you have git installed, and a GitHub account. @@ -134,19 +170,13 @@ local disk: cd openml-python ``` -3. Switch to the ``develop`` branch: - - ```bash - git checkout develop - ``` - 3. Create a ``feature`` branch to hold your development changes: ```bash git checkout -b feature/my-feature ``` - Always use a ``feature`` branch. It's good practice to never work on the ``main`` or ``develop`` branch! + Always use a ``feature`` branch. It's good practice to never work on the ``main`` branch! To make the nature of your pull request easily visible, please prepend the name of the branch with the type of changes you want to merge, such as ``feature`` if it contains a new feature, ``fix`` for a bugfix, ``doc`` for documentation and ``maint`` for other maintenance on the package. 4. Develop the feature on your feature branch. Add changed files using ``git add`` and then ``git commit`` files: @@ -178,7 +208,7 @@ Before each commit, it will automatically run: if the resulting code is very bloated, consider a (small) refactor. - [mypy](https://mypy.readthedocs.io/en/stable/) a static type checker. In particular, make sure each function you work on has type hints. - + If you want to run the pre-commit tests without doing a commit, run: ```bash $ make check @@ -203,4 +233,4 @@ When dependencies are installed, run ```bash mkdocs serve ``` -This will open a preview of the website. \ No newline at end of file +This will open a preview of the website. diff --git a/LICENSE b/LICENSE index e08aa862b..7c0204d1c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 3-Clause License -Copyright (c) 2014-2019, Matthias Feurer, Jan van Rijn, Andreas Müller, +Copyright (c) 2014-2019, Matthias Feurer, Jan van Rijn, Andreas Müller, Joaquin Vanschoren and others. All rights reserved. @@ -29,7 +29,7 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -License of the files CONTRIBUTING.md, ISSUE_TEMPLATE.md and +License of the files CONTRIBUTING.md, ISSUE_TEMPLATE.md and PULL_REQUEST_TEMPLATE.md: Those files are modifications of the respecting templates in scikit-learn and @@ -52,7 +52,7 @@ modification, are permitted provided that the following conditions are met: c. Neither the name of the Scikit-learn Developers nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written - permission. + permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" diff --git a/README.md b/README.md index 081bf7923..1c9634a6c 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,13 @@ ## The Python API for a World of Data and More :dizzy: [![Latest Release](https://img.shields.io/github/v/release/openml/openml-python)](https://github.com/openml/openml-python/releases) -[![Python Versions](https://img.shields.io/badge/python-3.8%20%7C%203.9%20%7C%203.10%20%7C%203.11%20%7C%203.12%20%7C%203.13-blue)](https://pypi.org/project/openml/) +[![Python Versions](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-blue)](https://pypi.org/project/openml/) [![Downloads](https://static.pepy.tech/badge/openml)](https://pepy.tech/project/openml) +[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.PLACEHOLDER.svg)](https://doi.org/10.5281/zenodo.PLACEHOLDER) [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) -[Installation](https://openml.github.io/openml-python/main/#how-to-get-openml-for-python) | [Documentation](https://openml.github.io/openml-python) | [Contribution guidelines](https://github.com/openml/openml-python/blob/develop/CONTRIBUTING.md) +[Installation](https://openml.github.io/openml-python/main/#how-to-get-openml-for-python) | [Documentation](https://openml.github.io/openml-python) | [Contribution guidelines](https://github.com/openml/openml-python/blob/main/CONTRIBUTING.md) OpenML-Python provides an easy-to-use and straightforward Python interface for [OpenML](http://openml.org), an online platform for open science collaboration in machine learning. @@ -33,7 +34,7 @@ Use the following code to get the [credit-g](https://www.openml.org/search?type= ```python import openml -dataset = openml.datasets.get_dataset("credit-g") # or by ID get_dataset(31) +dataset = openml.datasets.get_dataset("credit-g") # or by ID get_dataset(31) X, y, categorical_indicator, attribute_names = dataset.get_data(target="class") ``` @@ -53,14 +54,16 @@ Use an [OpenML benchmarking suite](https://docs.openml.org/concepts/benchmarking ```python import openml -suite = openml.study.get_suite("amlb-classification-all") # Get a curated list of tasks for classification +suite = openml.study.get_suite( + "amlb-classification-all" +) # Get a curated list of tasks for classification for task_id in suite.tasks: task = openml.tasks.get_task(task_id) ``` ## :magic_wand: Installation -OpenML-Python is supported on Python 3.8 - 3.13 and is available on Linux, MacOS, and Windows. +OpenML-Python is supported on Python 3.10 - 3.14 and is available on Linux, MacOS, and Windows. You can install OpenML-Python with: @@ -89,3 +92,14 @@ Bibtex entry: url = {http://jmlr.org/papers/v22/19-920.html} } ``` +## :handshake: Contributing + +We welcome contributions from both new and experienced developers! + +If you would like to contribute to OpenML-Python, please read our +[Contribution Guidelines](https://github.com/openml/openml-python/blob/main/CONTRIBUTING.md). + +If you are new to open-source development, a great way to get started is by +looking at issues labeled **"good first issue"** in our GitHub issue tracker. +These tasks are beginner-friendly and help you understand the project structure, +development workflow, and how to submit a pull request. diff --git a/docker/readme.md b/docker/readme.md index d0af9d9fe..1bca80b38 100644 --- a/docker/readme.md +++ b/docker/readme.md @@ -1,8 +1,8 @@ # OpenML Python Container This docker container has the latest version of openml-python downloaded and pre-installed. -It can also be used by developers to run unit tests or build the docs in -a fresh and/or isolated unix environment. +It can also be used by developers to run unit tests or build the docs in +a fresh and/or isolated unix environment. This document contains information about: 1. [Usage](#usage): how to use the image and its main modes. @@ -42,32 +42,32 @@ docker run -v PATH/TO/FILE:/openml/MY_SCRIPT.py openml/openml-python MY_SCRIPT.p ### Running unit tests You can run the unit tests by passing `test` as the first argument. -It also requires a local or remote repository to be specified, which is explained +It also requires a local or remote repository to be specified, which is explained [below]((#using-local-or-remote-code). For this example, we specify to test the -`develop` branch: +`my-feature` branch: ```text -docker run openml/openml-python test develop +docker run openml/openml-python test my-feature ``` ### Building documentation -You can build the documentation by passing `doc` as the first argument, -you should [mount]((https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount)) +You can build the documentation by passing `doc` as the first argument, +you should [mount]((https://docs.docker.com/storage/bind-mounts/#start-a-container-with-a-bind-mount)) an output directory in which the docs will be stored. You also need to provide a remote or local repository as explained in [the section below]((#using-local-or-remote-code). -In this example, we build documentation for the `develop` branch. +In this example, we build documentation for the `my-feature` branch. On Windows: ```text - docker run --mount type=bind,source="E:\\files/output",destination="/output" openml/openml-python doc develop + docker run --mount type=bind,source="E:\\files/output",destination="/output" openml/openml-python doc my-feature ``` on Linux: ```text - docker run --mount type=bind,source="./output",destination="/output" openml/openml-python doc develop + docker run --mount type=bind,source="./output",destination="/output" openml/openml-python doc my-feature ``` - + see [the section below]((#using-local-or-remote-code) for running against local changes or a remote branch. @@ -106,26 +106,26 @@ The branch may be specified by name directly if it exists on the original reposi Where `BRANCH` is the name of the branch for which to generate the documentation. It is also possible to build the documentation from the branch on a fork, -in this case the `BRANCH` should be specified as `GITHUB_NAME#BRANCH` (e.g. +in this case the `BRANCH` should be specified as `GITHUB_NAME#BRANCH` (e.g. `PGijsbers#my_feature_branch`) and the name of the forked repository should be `openml-python`. ## For developers -This section contains some notes about the structure of the image, +This section contains some notes about the structure of the image, intended for those who want to work on it. ### Added Directories The `openml/openml-python` image is built on a vanilla `python:3` image. Additionally, it contains the following files are directories: - - `/openml`: contains the openml-python repository in the state with which the image - was built by default. If working with a `BRANCH`, this repository will be set to + - `/openml`: contains the openml-python repository in the state with which the image + was built by default. If working with a `BRANCH`, this repository will be set to the `HEAD` of `BRANCH`. - `/openml/venv/`: contains the used virtual environment for `doc` and `test`. It has - `openml-python` dependencies pre-installed. When invoked with `doc` or `test`, the + `openml-python` dependencies pre-installed. When invoked with `doc` or `test`, the dependencies will be updated based on the `setup.py` of the `BRANCH` or mounted `/code`. - `/scripts/startup.sh`: the entrypoint of the image. Takes care of the automated features (e.g. `doc` and `test`). ## Building the image To build the image yourself, execute `docker build -f Dockerfile .` from the `docker` -directory of the `openml-python` repository. It will use the `startup.sh` as is, so any +directory of the `openml-python` repository. It will use the `startup.sh` as is, so any local changes will be present in the image. diff --git a/docker/startup.sh b/docker/startup.sh index 34a5c61f3..3f08920a5 100644 --- a/docker/startup.sh +++ b/docker/startup.sh @@ -67,7 +67,7 @@ cd $code_dir pip install -e .[test,examples,docs,examples_unix] if [ "$1" == "test" ]; then - pytest -n 4 --durations=20 --timeout=600 --timeout-method=thread --dist load -sv + pytest --durations=20 --timeout=600 --timeout-method=thread --dist load -sv fi if [ "$1" == "doc" ]; then @@ -77,4 +77,4 @@ if [ "$1" == "doc" ]; then if [ -d "/output" ]; then cp -r /openml/doc/build /output fi -fi \ No newline at end of file +fi diff --git a/docs/details.md b/docs/details.md index bf4b0cd2b..3af9f37ab 100644 --- a/docs/details.md +++ b/docs/details.md @@ -1,21 +1,21 @@ # Advanced User Guide This document highlights some of the more advanced features of -`openml-python`. +`openml-python`. ## Configuration -The configuration file resides in a directory `.config/openml` in the +The configuration file resides in a directory `~/.config/openml` in the home directory of the user and is called config (More specifically, it -resides in the [configuration directory specified by the XDGB Base +resides in the [configuration directory specified by the XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html)). It consists of `key = value` pairs which are separated by newlines. The following keys are defined: - apikey: required to access the server. -- server: the server to connect to (default: `http://www.openml.org`). - For connection to the test server, set this to `test.openml.org`. +- server: the server to connect to (default: `https://www.openml.org`). + For connection to the test server, set this to `https://test.openml.org`. - cachedir: the root folder where the cache file directories should be created. If not given, will default to `~/.openml/cache` - avoid_duplicate_runs: if set to `True` (default), when certain functions @@ -34,7 +34,7 @@ following keys are defined: trying to reconnect for a longer time, quickly increasing the time between retries. -- connection_n_retries: number of times to retry a request if they fail. +- connection_n_retries: number of times to retry a request if they fail. Default depends on retry_policy (5 for `human`, 50 for `robot`) - verbosity: the level of output: - 0: normal output @@ -43,7 +43,7 @@ Default depends on retry_policy (5 for `human`, 50 for `robot`) This file is easily configurable by the `openml` command line interface. To see where the file is stored, and what its values are, use openml -configure none. +configure none. ## Docker @@ -73,4 +73,3 @@ list of **tasks**. A further explanation is given in the [OpenML user guide](https://docs.openml.org/concepts/). - diff --git a/docs/developer_setup.md b/docs/developer_setup.md new file mode 100644 index 000000000..55a73fef9 --- /dev/null +++ b/docs/developer_setup.md @@ -0,0 +1,210 @@ +# OpenML Local Development Environment Setup + +This guide outlines the standard procedures for setting up a local development environment for the OpenML ecosystem. It covers the configuration of the backend servers (API v1 and API v2) and the Python Client SDK. + +OpenML currently has two backend architecture: + +* **API v1**: The PHP-based server currently serving production traffic. +* **API v2**: The Python-based server (FastAPI) currently under active development. + +> Note on Migration: API v1 is projected to remain operational through at least 2026. API v2 is the target architecture for future development. + +## 1. API v1 Setup (PHP Backend) + +This section details the deployment of the legacy PHP backend. + +### Prerequisites + +* **Docker**: Docker Desktop (Ensure the daemon is running). +* **Version Control**: Git. + +### Installation Steps + +#### 1. Clone the Repository + +Retrieve the OpenML services source code: + +```bash +git clone https://github.com/openml/services +cd services +``` + +#### 2. Configure File Permissions + +To ensure the containerized PHP service can write to the local filesystem, initialize the data directory permissions. + +From the repository root: + +```bash +chown -R www-data:www-data data/php +``` + +If the `www-data` user does not exist on the host system, grant full permissions as a fallback: + +```bash +chmod -R 777 data/php +``` + +#### 3. Launch Services + +Initialize the container stack: + +```bash +docker compose --profile all up -d +``` + +#### Warning: Container Conflicts + +If API v2 (Python backend) containers are present on the system, name conflicts may occur. To resolve this, stop and remove existing containers before launching API v1: + +```bash +docker compose --profile all down +docker compose --profile all up -d +``` + +#### 4. Verification + +Validate the deployment by accessing the flow endpoint. A successful response will return structured JSON data. + +* **Endpoint**: http://localhost:8080/api/v1/json/flow/181 + +### Client Configuration + +To direct the `openml-python` client to the local API v1 instance, modify the configuration as shown below. The API key corresponds to the default key located in `services/config/php/.env`. + +```python +import openml +from openml_sklearn.extension import SklearnExtension +from sklearn.neighbors import KNeighborsClassifier + +# Configure client to use local Docker instance +openml.config.server = "http://localhost:8080/api/v1/xml" +openml.config.apikey = "AD000000000000000000000000000000" + +# Test flow publication +clf = KNeighborsClassifier(n_neighbors=3) +extension = SklearnExtension() +knn_flow = extension.model_to_flow(clf) + +knn_flow.publish() +``` + +## 2. API v2 Setup (Python Backend) + +This section details the deployment of the FastAPI backend. + +### Prerequisites + +* **Docker**: Docker Desktop (Ensure the daemon is running). +* **Version Control**: Git. + +### Installation Steps + +#### 1. Clone the Repository + +Retrieve the API v2 source code: + +```bash +git clone https://github.com/openml/server-api +cd server-api +``` + +#### 2. Launch Services + +Build and start the container stack: + +```bash +docker compose --profile all up +``` + +#### 3. Verification + +Validate the deployment using the following endpoints: + +* **Task Endpoint**: http://localhost:8001/tasks/31 +* **Swagger UI (Documentation)**: http://localhost:8001/docs + +## 3. Python SDK (`openml-python`) Setup + +This section outlines the environment setup for contributing to the OpenML Python client. + +### Installation Steps + +#### 1. Clone the Repository + +```bash +git clone https://github.com/openml/openml-python +cd openml-python +``` + +#### 2. Environment Initialization + +Create an isolated virtual environment (example using Conda): + +```bash +conda create -n openml-python-dev python=3.12 +conda activate openml-python-dev +``` + +#### 3. Install Dependencies + +Install the package in editable mode, including development and documentation dependencies: + +```bash +python -m pip install -e ".[dev,docs]" +``` + +#### 4. Configure Quality Gates + +Install pre-commit hooks to enforce coding standards: + +```bash +pre-commit install +pre-commit run --all-files +``` + +## 4. Testing Guidelines + +The OpenML Python SDK utilizes `pytest` markers to categorize tests based on dependencies and execution context. + +| Marker | Description | +|-------------------|-----------------------------------------------------------------------------| +| `sklearn` | Tests requiring `scikit-learn`. Skipped if the library is missing. | +| `production_server`| Tests that interact with the live OpenML server (real API calls). | +| `test_server` | Tests requiring the OpenML test server environment. | + +### Execution Examples + +Run the full test suite: + +```bash +pytest +``` + +Run a specific subset (e.g., `scikit-learn` tests): + +```bash +pytest -m sklearn +``` + +Exclude production tests (local only): + +```bash +pytest -m "not production_server" +``` + +### Admin Privilege Tests + +Certain tests require administrative privileges on the test server. These are skipped automatically unless an admin API key is provided via environment variables. + +#### Windows (PowerShell): + +```shell +$env:OPENML_TEST_SERVER_ADMIN_KEY = "admin-key" +``` + +#### Linux/macOS: + +```bash +export OPENML_TEST_SERVER_ADMIN_KEY="admin-key" +``` diff --git a/docs/extensions.md b/docs/extensions.md index 858447440..e036389ed 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -27,11 +27,11 @@ to upload it. However, in order to simplify the process of uploading flows and runs from a specific library, an additional interface can be built. The OpenML-Python team does not have the capacity to develop and maintain such interfaces on its own. For this reason, we have built an -extension interface to allows others to contribute back. Building a -suitable extension for therefore requires an understanding of the +extension interface to allow others to contribute back. Building a +suitable extension therefore requires an understanding of the current OpenML-Python support. -[This tutorial](../examples/Basics/simple_flows_and_runs_tutorial) shows how the scikit-learn +[This tutorial](../examples/Basics/simple_flows_and_runs_tutorial) shows how the scikit-learn extension works with OpenML-Python. #### API @@ -52,7 +52,7 @@ must be called to allow OpenML-Python to interface the new extension. The following methods should get implemented. Although the documentation in the extension interface should always be leading, here -we list some additional information and best practices. +we list some additional information and best practices. Note that most methods are relatively simple and can be implemented in several lines of code. @@ -65,8 +65,8 @@ and can be implemented in several lines of code. Typically, the flow-dependency field is used to check whether the specific library is present, and no unknown libraries are present there. - - `can_handle_model`: Similar as - `can_handle_flow`:, except that in + - `can_handle_model`: Similar to + `can_handle_flow`, except that in this case a Python object is given. As such, in many cases, this method can be implemented by checking whether this adheres to a certain base class. @@ -135,10 +135,10 @@ and can be implemented in several lines of code. ### Hosting the library Each extension created should be a stand-alone repository, compatible -with the [OpenML-Python repository](https://github.com/openml/openml-python). +with the [OpenML-Python repository](https://github.com/openml/openml-python). The extension repository should work off-the-shelf with *OpenML-Python* installed. -Create a public Github repo with the following directory structure: +Create a public GitHub repo with the following directory structure: | [repo name] | |-- [extension name] @@ -149,7 +149,7 @@ Create a public Github repo with the following directory structure: ### Recommended - Test cases to keep the extension up to date with the - Openml-Python upstream changes. + OpenML-Python upstream changes. - Documentation of the extension API, especially if any new functionality added to OpenML-Python\'s extension design. - Examples to show how the new extension interfaces and works with diff --git a/docs/index.md b/docs/index.md index 1058c3956..f9ee299dd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,13 +4,13 @@ Welcome to the documentation of the OpenML Python API, a connector to the collaborative machine learning platform -[OpenML.org](https://www.openml.org). +[OpenML.org](https://www.openml.org). OpenML-Python can download or upload data from OpenML, such as datasets and machine learning experiment results. If you are new to OpenML, we recommend checking out the [OpenML documentation](https://docs.openml.org/) -to get familiar with the concepts and features of OpenML. In particular, we recommend -reading more about the [OpenML concepts](https://docs.openml.org/concepts/). +to get familiar with the concepts and features of OpenML. In particular, we recommend +reading more about the [OpenML concepts](https://docs.openml.org/concepts/). ## :joystick: Minimal Examples @@ -78,7 +78,7 @@ Contributing to the OpenML package is highly appreciated. Please see the ## Citing OpenML-Python If you use OpenML-Python in a scientific publication, we would -appreciate a reference to our JMLR-MLOSS paper +appreciate a reference to our JMLR-MLOSS paper ["OpenML-Python: an extensible Python API for OpenML"](https://www.jmlr.org/papers/v22/19-920.html): === "Bibtex" @@ -98,6 +98,6 @@ appreciate a reference to our JMLR-MLOSS paper === "MLA" - Feurer, Matthias, et al. + Feurer, Matthias, et al. "OpenML-Python: an extensible Python API for OpenML." - _Journal of Machine Learning Research_ 22.100 (2021):1−5. + _Journal of Machine Learning Research_ 22.100 (2021): 1−5. diff --git a/examples/Advanced/fetch_evaluations_tutorial.py b/examples/Advanced/fetch_evaluations_tutorial.py index 1b759423b..97b8d1bef 100644 --- a/examples/Advanced/fetch_evaluations_tutorial.py +++ b/examples/Advanced/fetch_evaluations_tutorial.py @@ -75,7 +75,7 @@ def plot_cdf(values, metric="predictive_accuracy"): max_val = max(values) - n, bins, patches = plt.hist(values, density=True, histtype="step", cumulative=True, linewidth=3) + _, _, patches = plt.hist(values, density=True, histtype="step", cumulative=True, linewidth=3) patches[0].set_xy(patches[0].get_xy()[:-1]) plt.xlim(max(0, min(values) - 0.1), 1) plt.title("CDF") @@ -116,7 +116,7 @@ def plot_flow_compare(evaluations, top_n=10, metric="predictive_accuracy"): for i in range(len(flow_ids)): flow_values = evaluations[evaluations.flow_id == flow_ids[i]].value df = pd.concat([df, flow_values], ignore_index=True, axis=1) - fig, axs = plt.subplots() + _, axs = plt.subplots() df.boxplot() axs.set_title("Boxplot comparing " + metric + " for different flows") axs.set_ylabel(metric) @@ -178,4 +178,4 @@ def plot_flow_compare(evaluations, top_n=10, metric="predictive_accuracy"): function="predictive_accuracy", flows=[6767], size=100, parameters_in_separate_columns=True ) -print(evals_setups.head(10)) \ No newline at end of file +print(evals_setups.head(10)) diff --git a/examples/Advanced/suites_tutorial.py b/examples/Advanced/suites_tutorial.py index 7ca42079d..8459510ef 100644 --- a/examples/Advanced/suites_tutorial.py +++ b/examples/Advanced/suites_tutorial.py @@ -72,7 +72,7 @@ # %% all_tasks = list(openml.tasks.list_tasks()["tid"]) -task_ids_for_suite = sorted(np.random.choice(all_tasks, replace=False, size=20)) +task_ids_for_suite = sorted(np.random.choice(all_tasks, replace=False, size=20)) # noqa: NPY002 # The study needs a machine-readable and unique alias. To obtain this, # we simply generate a random uuid. diff --git a/examples/Advanced/tasks_tutorial.py b/examples/Advanced/tasks_tutorial.py index dff7293ad..082baad7b 100644 --- a/examples/Advanced/tasks_tutorial.py +++ b/examples/Advanced/tasks_tutorial.py @@ -24,13 +24,15 @@ # # We will start by simply listing only *supervised classification* tasks. # -# **openml.tasks.list_tasks()** returns a dictionary of dictionaries by default, but we -# request a +# **openml.list_tasks()** (or **openml.tasks.list_tasks()**) returns a dictionary of dictionaries by default, but we request a # [pandas dataframe](https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.html) # instead to have better visualization capabilities and easier access: # %% -tasks = openml.tasks.list_tasks(task_type=TaskType.SUPERVISED_CLASSIFICATION) +# New: top-level convenience alias +tasks = openml.list_tasks(task_type=TaskType.SUPERVISED_CLASSIFICATION) +# Old path still works: +# tasks = openml.tasks.list_tasks(task_type=TaskType.SUPERVISED_CLASSIFICATION) print(tasks.columns) print(f"First 5 of {len(tasks)} tasks:") print(tasks.head()) @@ -66,7 +68,9 @@ # Similar to listing tasks by task type, we can list tasks by tags: # %% -tasks = openml.tasks.list_tasks(tag="OpenML100") +tasks = openml.list_tasks(tag="OpenML100") +# Old path still works: +# tasks = openml.tasks.list_tasks(tag="OpenML100") print(f"First 5 of {len(tasks)} tasks:") print(tasks.head()) @@ -74,7 +78,9 @@ # Furthermore, we can list tasks based on the dataset id: # %% -tasks = openml.tasks.list_tasks(data_id=1471) +tasks = openml.list_tasks(data_id=1471) +# Old path still works: +# tasks = openml.tasks.list_tasks(data_id=1471) print(f"First 5 of {len(tasks)} tasks:") print(tasks.head()) @@ -82,7 +88,9 @@ # In addition, a size limit and an offset can be applied both separately and simultaneously: # %% -tasks = openml.tasks.list_tasks(size=10, offset=50) +tasks = openml.list_tasks(size=10, offset=50) +# Old path still works: +# tasks = openml.tasks.list_tasks(size=10, offset=50) print(tasks) # %% [markdown] @@ -98,7 +106,9 @@ # Finally, it is also possible to list all tasks on OpenML with: # %% -tasks = openml.tasks.list_tasks() +tasks = openml.list_tasks() +# Old path still works: +# tasks = openml.tasks.list_tasks() print(len(tasks)) # %% [markdown] @@ -118,7 +128,10 @@ # %% task_id = 31 -task = openml.tasks.get_task(task_id) +# New: top-level convenience alias +task = openml.get_task(task_id) +# Old path still works: +# task = openml.tasks.get_task(task_id) # %% # Properties of the task are stored as member variables: diff --git a/examples/Basics/introduction_tutorial.py b/examples/Basics/introduction_tutorial.py index c864772f5..2ba2d0ef1 100644 --- a/examples/Basics/introduction_tutorial.py +++ b/examples/Basics/introduction_tutorial.py @@ -12,7 +12,7 @@ # For certain functionality, such as uploading tasks or datasets, users have to # sign up. Only accessing the data on OpenML does not require an account! # -# If you don’t have an account yet, sign up now. +# If you don't have an account yet, sign up now. # You will receive an API key, which will authenticate you to the server # and allow you to download and upload datasets, tasks, runs and flows. # @@ -52,4 +52,4 @@ # %% import openml -openml.config.set_root_cache_directory("YOURDIR") \ No newline at end of file +openml.config.set_root_cache_directory("YOURDIR") diff --git a/examples/Basics/simple_datasets_tutorial.py b/examples/Basics/simple_datasets_tutorial.py index 75d36ed0f..a5a1d6b5a 100644 --- a/examples/Basics/simple_datasets_tutorial.py +++ b/examples/Basics/simple_datasets_tutorial.py @@ -14,7 +14,10 @@ # ## List datasets stored on OpenML # %% -datasets_df = openml.datasets.list_datasets() +# New: top-level convenience alias +datasets_df = openml.list_datasets() +# Old path still works for backwards compatibility: +# datasets_df = openml.datasets.list_datasets() print(datasets_df.head(n=10)) # %% [markdown] @@ -22,7 +25,10 @@ # %% # Iris dataset https://www.openml.org/d/61 -dataset = openml.datasets.get_dataset(dataset_id=61) +# New: top-level convenience alias +dataset = openml.get_dataset(dataset_id=61) +# Old path still works: +# dataset = openml.datasets.get_dataset(dataset_id=61) # Print a summary print( diff --git a/examples/Basics/simple_flows_and_runs_tutorial.py b/examples/Basics/simple_flows_and_runs_tutorial.py index 41eed9234..5ecd18d9d 100644 --- a/examples/Basics/simple_flows_and_runs_tutorial.py +++ b/examples/Basics/simple_flows_and_runs_tutorial.py @@ -29,7 +29,10 @@ # NOTE: We are using task 119 from the test server: https://test.openml.org/d/20 # %% -task = openml.tasks.get_task(119) +# New: top-level convenience alias +task = openml.get_task(119) +# Old path still works: +# task = openml.tasks.get_task(119) # Get the data dataset = task.get_dataset() @@ -54,7 +57,7 @@ # %% [markdown] # ## Upload the machine learning experiments to OpenML -# First, create a fow and fill it with metadata about the machine learning model. +# First, create a flow and fill it with metadata about the machine learning model. # %% knn_flow = openml.flows.OpenMLFlow( @@ -85,7 +88,7 @@ # Format the predictions for OpenML predictions = [] for test_index, y_true_i, y_pred_i, y_pred_proba_i in zip( - test_indices, y_test, y_pred, y_pred_proba + test_indices, y_test, y_pred, y_pred_proba, strict=False ): predictions.append( openml.runs.functions.format_prediction( @@ -95,7 +98,7 @@ index=test_index, prediction=y_pred_i, truth=y_true_i, - proba=dict(zip(task.class_labels, y_pred_proba_i)), + proba=dict(zip(task.class_labels, y_pred_proba_i, strict=False)), ) ) diff --git a/examples/Basics/simple_tasks_tutorial.py b/examples/Basics/simple_tasks_tutorial.py index 598ce4e71..be82663e3 100644 --- a/examples/Basics/simple_tasks_tutorial.py +++ b/examples/Basics/simple_tasks_tutorial.py @@ -10,7 +10,10 @@ # [supervised classification on credit-g](https://www.openml.org/search?type=task&id=31&source_data.data_id=31): # %% -task = openml.tasks.get_task(31) +# New: top-level convenience alias +task = openml.get_task(31) +# Old path still works: +# task = openml.tasks.get_task(31) # %% [markdown] # Get the dataset and its data from the task. diff --git a/examples/_external_or_deprecated/2015_neurips_feurer_example.py b/examples/_external_or_deprecated/2015_neurips_feurer_example.py index ae59c9ced..2dfc4bb97 100644 --- a/examples/_external_or_deprecated/2015_neurips_feurer_example.py +++ b/examples/_external_or_deprecated/2015_neurips_feurer_example.py @@ -13,12 +13,10 @@ | Matthias Feurer, Aaron Klein, Katharina Eggensperger, Jost Springenberg, Manuel Blum and Frank Hutter | In *Advances in Neural Information Processing Systems 28*, 2015 | Available at https://papers.nips.cc/paper/5872-efficient-and-robust-automated-machine-learning.pdf -""" # noqa F401 +""" # License: BSD 3-Clause -import pandas as pd - import openml #################################################################################################### @@ -68,7 +66,7 @@ task_ids = [] for did in dataset_ids: - tasks_ = list(tasks.query("did == {}".format(did)).tid) + tasks_ = list(tasks.query(f"did == {did}").tid) if len(tasks_) >= 1: # if there are multiple task, take the one with lowest ID (oldest). task_id = min(tasks_) else: diff --git a/examples/_external_or_deprecated/2018_ida_strang_example.py b/examples/_external_or_deprecated/2018_ida_strang_example.py index 8b225125b..0e180badf 100644 --- a/examples/_external_or_deprecated/2018_ida_strang_example.py +++ b/examples/_external_or_deprecated/2018_ida_strang_example.py @@ -17,8 +17,8 @@ # License: BSD 3-Clause import matplotlib.pyplot as plt + import openml -import pandas as pd ############################################################################## # A basic step for each data-mining or machine learning task is to determine @@ -86,10 +86,9 @@ def determine_class(val_lin, val_nonlin): if val_lin < val_nonlin: return class_values[0] - elif val_nonlin < val_lin: + if val_nonlin < val_lin: return class_values[1] - else: - return class_values[2] + return class_values[2] evaluations["class"] = evaluations.apply( diff --git a/examples/_external_or_deprecated/2018_kdd_rijn_example.py b/examples/_external_or_deprecated/2018_kdd_rijn_example.py index 6522013e3..957281616 100644 --- a/examples/_external_or_deprecated/2018_kdd_rijn_example.py +++ b/examples/_external_or_deprecated/2018_kdd_rijn_example.py @@ -32,16 +32,17 @@ import sys -if sys.platform == "win32": # noqa +if sys.platform == "win32": print( "The pyrfr library (requirement of fanova) can currently not be installed on Windows systems" ) - exit() + sys.exit() # DEPRECATED EXAMPLE -- Avoid running this code in our CI/CD pipeline print("This example is deprecated, remove the `if False` in this code to use it manually.") if False: import json + import fanova import matplotlib.pyplot as plt import pandas as pd @@ -49,7 +50,6 @@ import openml - ############################################################################## # With the advent of automated machine learning, automated hyperparameter # optimization methods are by now routinely used in data mining. However, this @@ -80,7 +80,7 @@ # important when it is put on a log-scale. All these simplifications can be # addressed by defining a ConfigSpace. For a more elaborated example that uses # this, please see: - # https://github.com/janvanrijn/openml-pimp/blob/d0a14f3eb480f2a90008889f00041bdccc7b9265/examples/plot/plot_fanova_aggregates.py # noqa F401 + # https://github.com/janvanrijn/openml-pimp/blob/d0a14f3eb480f2a90008889f00041bdccc7b9265/examples/plot/plot_fanova_aggregates.py suite = openml.study.get_suite("OpenML100") flow_id = 7707 @@ -97,8 +97,7 @@ if limit_nr_tasks is not None and idx >= limit_nr_tasks: continue print( - "Starting with task %d (%d/%d)" - % (task_id, idx + 1, len(suite.tasks) if limit_nr_tasks is None else limit_nr_tasks) + f"Starting with task {task_id} ({idx + 1}/{len(suite.tasks) if limit_nr_tasks is None else limit_nr_tasks})" ) # note that we explicitly only include tasks from the benchmark suite that was specified (as per the for-loop) evals = openml.evaluations.list_evaluations_setups( @@ -121,13 +120,13 @@ [ dict( **{name: json.loads(value) for name, value in setup["parameters"].items()}, - **{performance_column: setup[performance_column]} + **{performance_column: setup[performance_column]}, ) for _, setup in evals.iterrows() ] ) except json.decoder.JSONDecodeError as e: - print("Task %d error: %s" % (task_id, e)) + print(f"Task {task_id} error: {e}") continue # apply our filters, to have only the setups that comply to the hyperparameters we want for filter_key, filter_value in parameter_filters.items(): @@ -156,19 +155,21 @@ Y=setups_evals[performance_column].to_numpy(), n_trees=n_trees, ) - for idx, pname in enumerate(parameter_names): + for idx, pname in enumerate(parameter_names): # noqa: PLW2901 try: fanova_results.append( { "hyperparameter": pname.split(".")[-1], - "fanova": evaluator.quantify_importance([idx])[(idx,)]["individual importance"], + "fanova": evaluator.quantify_importance([idx])[(idx,)][ + "individual importance" + ], } ) except RuntimeError as e: # functional ANOVA sometimes crashes with a RuntimeError, e.g., on tasks where the performance is constant # for all configurations (there is no variance). We will skip these tasks (like the authors did in the # paper). - print("Task %d error: %s" % (task_id, e)) + print(f"Task {task_id} error: {e}") continue # transform ``fanova_results`` from a list of dicts into a DataFrame diff --git a/examples/_external_or_deprecated/2018_neurips_perrone_example.py b/examples/_external_or_deprecated/2018_neurips_perrone_example.py index 0d72846ac..8a3c36994 100644 --- a/examples/_external_or_deprecated/2018_neurips_perrone_example.py +++ b/examples/_external_or_deprecated/2018_neurips_perrone_example.py @@ -27,16 +27,17 @@ # License: BSD 3-Clause -import openml import numpy as np import pandas as pd from matplotlib import pyplot as plt -from sklearn.pipeline import Pipeline -from sklearn.impute import SimpleImputer from sklearn.compose import ColumnTransformer +from sklearn.ensemble import RandomForestRegressor +from sklearn.impute import SimpleImputer from sklearn.metrics import mean_squared_error +from sklearn.pipeline import Pipeline from sklearn.preprocessing import OneHotEncoder -from sklearn.ensemble import RandomForestRegressor + +import openml flow_type = "svm" # this example will use the smaller svm flow evaluations ############################################################################ @@ -44,7 +45,7 @@ # a tabular format that can be used to build models. -def fetch_evaluations(run_full=False, flow_type="svm", metric="area_under_roc_curve"): +def fetch_evaluations(run_full=False, flow_type="svm", metric="area_under_roc_curve"): # noqa: FBT002 """ Fetch a list of evaluations based on the flows and tasks used in the experiments. @@ -101,7 +102,10 @@ def fetch_evaluations(run_full=False, flow_type="svm", metric="area_under_roc_cu def create_table_from_evaluations( - eval_df, flow_type="svm", run_count=np.iinfo(np.int64).max, task_ids=None + eval_df, + flow_type="svm", + run_count=np.iinfo(np.int64).max, # noqa: B008 + task_ids=None, ): """ Create a tabular data with its ground truth from a dataframe of evaluations. @@ -206,7 +210,7 @@ def list_categorical_attributes(flow_type="svm"): model.fit(X, y) y_pred = model.predict(X) -print("Training RMSE : {:.5}".format(mean_squared_error(y, y_pred))) +print(f"Training RMSE : {mean_squared_error(y, y_pred):.5}") ############################################################################# @@ -231,9 +235,9 @@ def random_sample_configurations(num_samples=100): X = pd.DataFrame(np.nan, index=range(num_samples), columns=colnames) for i in range(len(colnames)): if len(ranges[i]) == 2: - col_val = np.random.uniform(low=ranges[i][0], high=ranges[i][1], size=num_samples) + col_val = np.random.uniform(low=ranges[i][0], high=ranges[i][1], size=num_samples) # noqa: NPY002 else: - col_val = np.random.choice(ranges[i], size=num_samples) + col_val = np.random.choice(ranges[i], size=num_samples) # noqa: NPY002 X.iloc[:, i] = col_val return X diff --git a/examples/_external_or_deprecated/README.md b/examples/_external_or_deprecated/README.md index d25a81baa..d37c87fcd 100644 --- a/examples/_external_or_deprecated/README.md +++ b/examples/_external_or_deprecated/README.md @@ -1,5 +1,5 @@ # External or Deprecated Examples -This directory contains examples that are either external or deprecated. They may not be maintained or updated +This directory contains examples that are either external or deprecated. They may not be maintained or updated regularly, and their functionality might not align with the latest version of the library. Moreover, -they are not shown on the documentation website. \ No newline at end of file +they are not shown on the documentation website. diff --git a/examples/_external_or_deprecated/benchmark_with_optunahub.py b/examples/_external_or_deprecated/benchmark_with_optunahub.py index ece3e7c40..38114bc44 100644 --- a/examples/_external_or_deprecated/benchmark_with_optunahub.py +++ b/examples/_external_or_deprecated/benchmark_with_optunahub.py @@ -100,7 +100,7 @@ def objective(trial: optuna.Trial) -> Pipeline: run.publish() logger.log(1, f"Run was uploaded to - {run.openml_url}") - except Exception as e: + except Exception as e: # noqa: BLE001 logger.log(1, f"Could not publish run - {e}") else: logger.log( diff --git a/examples/_external_or_deprecated/fetch_runtimes_tutorial.py b/examples/_external_or_deprecated/fetch_runtimes_tutorial.py index b2a3f1d2a..c8f85adc5 100644 --- a/examples/_external_or_deprecated/fetch_runtimes_tutorial.py +++ b/examples/_external_or_deprecated/fetch_runtimes_tutorial.py @@ -39,17 +39,16 @@ # # * (Case 5) Running models that do not release the Python Global Interpreter Lock (GIL) -import openml import numpy as np -from matplotlib import pyplot as plt from joblib.parallel import parallel_backend - -from sklearn.naive_bayes import GaussianNB -from sklearn.tree import DecisionTreeClassifier -from sklearn.neural_network import MLPClassifier +from matplotlib import pyplot as plt from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import GridSearchCV, RandomizedSearchCV +from sklearn.naive_bayes import GaussianNB +from sklearn.neural_network import MLPClassifier +from sklearn.tree import DecisionTreeClassifier +import openml # %% [markdown] # # Preparing tasks and scikit-learn models @@ -63,12 +62,7 @@ # Viewing associated data n_repeats, n_folds, n_samples = task.get_split_dimensions() print( - "Task {}: number of repeats: {}, number of folds: {}, number of samples {}.".format( - task_id, - n_repeats, - n_folds, - n_samples, - ) + f"Task {task_id}: number of repeats: {n_repeats}, number of folds: {n_folds}, number of samples {n_samples}." ) @@ -101,7 +95,7 @@ def print_compare_runtimes(measures): measures = run1.fold_evaluations print("The timing and performance metrics available: ") -for key in measures.keys(): +for key in measures: print(key) print() @@ -206,7 +200,6 @@ def print_compare_runtimes(measures): # included in the `wall_clock_time_millis_training` measure recorded. # %% -from sklearn.model_selection import GridSearchCV clf = RandomForestClassifier(n_estimators=10, n_jobs=2) @@ -284,22 +277,18 @@ def print_compare_runtimes(measures): # %% + def extract_refit_time(run, repeat, fold): - refit_time = ( + return ( run.fold_evaluations["wall_clock_time_millis"][repeat][fold] - run.fold_evaluations["wall_clock_time_millis_training"][repeat][fold] - run.fold_evaluations["wall_clock_time_millis_testing"][repeat][fold] ) - return refit_time for repeat in range(n_repeats): for fold in range(n_folds): - print( - "Repeat #{}-Fold #{}: {:.4f}".format( - repeat, fold, extract_refit_time(run4, repeat, fold) - ) - ) + print(f"Repeat #{repeat}-Fold #{fold}: {extract_refit_time(run4, repeat, fold):.4f}") # %% [markdown] # Along with the GridSearchCV already used above, we demonstrate how such diff --git a/examples/_external_or_deprecated/flow_id_tutorial.py b/examples/_external_or_deprecated/flow_id_tutorial.py index e813655fc..19190cf0b 100644 --- a/examples/_external_or_deprecated/flow_id_tutorial.py +++ b/examples/_external_or_deprecated/flow_id_tutorial.py @@ -9,7 +9,6 @@ import openml - # %% [markdown] # .. warning:: # .. include:: ../../test_server_usage_warning.txt @@ -48,7 +47,7 @@ # %% [markdown] # ## 2. Obtaining a flow given its name # The schema of a flow is given in XSD ( -# [here](https://github.com/openml/OpenML/blob/master/openml_OS/views/pages/api_new/v1/xsd/openml.implementation.upload.xsd)). # noqa E501 +# [here](https://github.com/openml/OpenML/blob/master/openml_OS/views/pages/api_new/v1/xsd/openml.implementation.upload.xsd)). # Only two fields are required, a unique name, and an external version. While it should be pretty # obvious why we need a name, the need for the additional external version information might not # be immediately clear. However, this information is very important as it allows to have multiple diff --git a/examples/_external_or_deprecated/flows_and_runs_tutorial.py b/examples/_external_or_deprecated/flows_and_runs_tutorial.py index 2d1bcb864..71d6960bd 100644 --- a/examples/_external_or_deprecated/flows_and_runs_tutorial.py +++ b/examples/_external_or_deprecated/flows_and_runs_tutorial.py @@ -3,8 +3,7 @@ # This tutorial covers how to train/run a model and how to upload the results. # %% -import openml -from sklearn import compose, ensemble, impute, neighbors, preprocessing, pipeline, tree +from sklearn import compose, ensemble, impute, neighbors, pipeline, preprocessing, tree import openml diff --git a/examples/_external_or_deprecated/plot_svm_hyperparameters_tutorial.py b/examples/_external_or_deprecated/plot_svm_hyperparameters_tutorial.py index faced588b..7bb72db5a 100644 --- a/examples/_external_or_deprecated/plot_svm_hyperparameters_tutorial.py +++ b/examples/_external_or_deprecated/plot_svm_hyperparameters_tutorial.py @@ -2,9 +2,10 @@ # # Plotting hyperparameter surfaces # %% -import openml import numpy as np +import openml + # %% [markdown] # # First step - obtaining the data # First, we need to choose an SVM flow, for example 8353, and a task. Finding the IDs of them are diff --git a/examples/_external_or_deprecated/run_setup_tutorial.py b/examples/_external_or_deprecated/run_setup_tutorial.py index 55d25d291..25591bb58 100644 --- a/examples/_external_or_deprecated/run_setup_tutorial.py +++ b/examples/_external_or_deprecated/run_setup_tutorial.py @@ -23,15 +23,15 @@ # %% import numpy as np -import openml -from openml.extensions.sklearn import cat, cont - -from sklearn.pipeline import make_pipeline, Pipeline from sklearn.compose import ColumnTransformer -from sklearn.impute import SimpleImputer -from sklearn.preprocessing import OneHotEncoder, FunctionTransformer -from sklearn.ensemble import RandomForestClassifier from sklearn.decomposition import TruncatedSVD +from sklearn.ensemble import RandomForestClassifier +from sklearn.impute import SimpleImputer +from sklearn.pipeline import Pipeline, make_pipeline +from sklearn.preprocessing import OneHotEncoder + +import openml +from openml.extensions.sklearn import cat, cont # %% [markdown] # .. warning:: diff --git a/examples/_external_or_deprecated/upload_amlb_flows_and_runs.py b/examples/_external_or_deprecated/upload_amlb_flows_and_runs.py index 15ec0e1fb..b43926d4e 100644 --- a/examples/_external_or_deprecated/upload_amlb_flows_and_runs.py +++ b/examples/_external_or_deprecated/upload_amlb_flows_and_runs.py @@ -14,10 +14,10 @@ # %% from collections import OrderedDict + import numpy as np import openml -from openml import OpenMLClassificationTask from openml.runs.functions import format_prediction # %% [markdown] @@ -43,17 +43,17 @@ # version of the package/script is used. Use tags so users can find your flow easily. # %% -general = dict( - name="automlbenchmark_autosklearn", - description=( +general = { + "name": "automlbenchmark_autosklearn", + "description": ( "Auto-sklearn as set up by the AutoML Benchmark" "Source: https://github.com/openml/automlbenchmark/releases/tag/v0.9" ), - external_version="amlb==0.9", - language="English", - tags=["amlb", "benchmark", "study_218"], - dependencies="amlb==0.9", -) + "external_version": "amlb==0.9", + "language": "English", + "tags": ["amlb", "benchmark", "study_218"], + "dependencies": "amlb==0.9", +} # %% [markdown] # Next we define the flow hyperparameters. We define their name and default value in `parameters`, @@ -62,14 +62,14 @@ # The use of ordered dicts is required. # %% -flow_hyperparameters = dict( - parameters=OrderedDict(time="240", memory="32", cores="8"), - parameters_meta_info=OrderedDict( +flow_hyperparameters = { + "parameters": OrderedDict(time="240", memory="32", cores="8"), + "parameters_meta_info": OrderedDict( cores=OrderedDict(description="number of available cores", data_type="int"), memory=OrderedDict(description="memory in gigabytes", data_type="int"), time=OrderedDict(description="time in minutes", data_type="int"), ), -) +} # %% [markdown] # It is possible to build a flow which uses other flows. @@ -89,11 +89,11 @@ # %% autosklearn_flow = openml.flows.get_flow(9313) # auto-sklearn 0.5.1 -subflow = dict( - components=OrderedDict(automl_tool=autosklearn_flow), +subflow = { + "components": OrderedDict(automl_tool=autosklearn_flow), # If you do not want to reference a subflow, you can use the following: # components=OrderedDict(), -) +} # %% [markdown] # With all parameters of the flow defined, we can now initialize the OpenMLFlow and publish. @@ -172,19 +172,19 @@ ] # random class probabilities (Iris has 150 samples and 3 classes): -r = np.random.rand(150 * n_repeats, 3) +r = np.random.rand(150 * n_repeats, 3) # noqa: NPY002 # scale the random values so that the probabilities of each sample sum to 1: y_proba = r / r.sum(axis=1).reshape(-1, 1) y_pred = y_proba.argmax(axis=1) -class_map = dict(zip(range(3), task.class_labels)) +class_map = dict(zip(range(3), task.class_labels, strict=False)) _, y_true = task.get_X_and_y() y_true = [class_map[y] for y in y_true] # We format the predictions with the utility function `format_prediction`. # It will organize the relevant data in the expected format/order. predictions = [] -for where, y, yp, proba in zip(all_test_indices, y_true, y_pred, y_proba): +for where, y, yp, proba in zip(all_test_indices, y_true, y_pred, y_proba, strict=False): repeat, fold, index = where prediction = format_prediction( @@ -194,7 +194,7 @@ index=index, prediction=class_map[yp], truth=y, - proba={c: pb for (c, pb) in zip(task.class_labels, proba)}, + proba=dict(zip(task.class_labels, proba, strict=False)), ) predictions.append(prediction) @@ -203,7 +203,7 @@ # We use the argument setup_string because the used flow was a script. # %% -benchmark_command = f"python3 runbenchmark.py auto-sklearn medium -m aws -t 119" +benchmark_command = "python3 runbenchmark.py auto-sklearn medium -m aws -t 119" my_run = openml.runs.OpenMLRun( task_id=task_id, flow_id=flow_id, diff --git a/mkdocs.yml b/mkdocs.yml index 92ba3c851..419cc249e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -65,6 +65,7 @@ nav: - Advanced User Guide: details.md - API: reference/ - Contributing: contributing.md + - Developer Setup: developer_setup.md markdown_extensions: - pymdownx.highlight: @@ -127,7 +128,6 @@ plugins: docstring_options: ignore_init_summary: true trim_doctest_flags: true - returns_multiple_items: false show_docstring_attributes: true show_docstring_description: true show_root_heading: true @@ -138,7 +138,7 @@ plugins: merge_init_into_class: true show_symbol_type_heading: true show_symbol_type_toc: true - docstring_style: google + docstring_style: numpy inherited_members: true show_if_no_docstring: false show_bases: true diff --git a/openml/__init__.py b/openml/__init__.py index c49505eb9..374ebfc48 100644 --- a/openml/__init__.py +++ b/openml/__init__.py @@ -18,9 +18,11 @@ # License: BSD 3-Clause from __future__ import annotations +from typing import TYPE_CHECKING + from . import ( _api_calls, - config, + _config as _config_module, datasets, evaluations, exceptions, @@ -33,10 +35,14 @@ utils, ) from .__version__ import __version__ +from ._api import _backend from .datasets import OpenMLDataFeature, OpenMLDataset +from .datasets.functions import get_dataset, list_datasets from .evaluations import OpenMLEvaluation from .flows import OpenMLFlow +from .flows.functions import get_flow, list_flows from .runs import OpenMLRun +from .runs.functions import get_run, list_runs from .setups import OpenMLParameter, OpenMLSetup from .study import OpenMLBenchmarkSuite, OpenMLStudy from .tasks import ( @@ -48,6 +54,12 @@ OpenMLSupervisedTask, OpenMLTask, ) +from .tasks.functions import get_task, list_tasks + +if TYPE_CHECKING: + from ._config import OpenMLConfigManager + +config: OpenMLConfigManager = _config_module.__config def populate_cache( @@ -91,33 +103,42 @@ def populate_cache( __all__ = [ - "OpenMLDataset", + "OpenMLBenchmarkSuite", + "OpenMLClassificationTask", + "OpenMLClusteringTask", "OpenMLDataFeature", - "OpenMLRun", - "OpenMLSplit", + "OpenMLDataset", "OpenMLEvaluation", - "OpenMLSetup", - "OpenMLParameter", - "OpenMLTask", - "OpenMLSupervisedTask", - "OpenMLClusteringTask", + "OpenMLFlow", "OpenMLLearningCurveTask", + "OpenMLParameter", "OpenMLRegressionTask", - "OpenMLClassificationTask", - "OpenMLFlow", + "OpenMLRun", + "OpenMLSetup", + "OpenMLSplit", "OpenMLStudy", - "OpenMLBenchmarkSuite", + "OpenMLSupervisedTask", + "OpenMLTask", + "__version__", + "_api_calls", + "_backend", + "config", "datasets", "evaluations", "exceptions", "extensions", - "config", - "runs", "flows", - "tasks", + "get_dataset", + "get_flow", + "get_run", + "get_task", + "list_datasets", + "list_flows", + "list_runs", + "list_tasks", + "runs", "setups", "study", + "tasks", "utils", - "_api_calls", - "__version__", ] diff --git a/openml/_api/__init__.py b/openml/_api/__init__.py new file mode 100644 index 000000000..7766016d1 --- /dev/null +++ b/openml/_api/__init__.py @@ -0,0 +1,85 @@ +from .clients import ( + HTTPCache, + HTTPClient, + MinIOClient, +) +from .resources import ( + API_REGISTRY, + DatasetAPI, + DatasetV1API, + DatasetV2API, + EstimationProcedureAPI, + EstimationProcedureV1API, + EstimationProcedureV2API, + EvaluationAPI, + EvaluationMeasureAPI, + EvaluationMeasureV1API, + EvaluationMeasureV2API, + EvaluationV1API, + EvaluationV2API, + FallbackProxy, + FlowAPI, + FlowV1API, + FlowV2API, + ResourceAPI, + ResourceV1API, + ResourceV2API, + RunAPI, + RunV1API, + RunV2API, + SetupAPI, + SetupV1API, + SetupV2API, + StudyAPI, + StudyV1API, + StudyV2API, + TaskAPI, + TaskV1API, + TaskV2API, +) +from .setup import ( + APIBackend, + APIBackendBuilder, + _backend, +) + +__all__ = [ + "API_REGISTRY", + "APIBackend", + "APIBackendBuilder", + "DatasetAPI", + "DatasetV1API", + "DatasetV2API", + "EstimationProcedureAPI", + "EstimationProcedureV1API", + "EstimationProcedureV2API", + "EvaluationAPI", + "EvaluationMeasureAPI", + "EvaluationMeasureV1API", + "EvaluationMeasureV2API", + "EvaluationV1API", + "EvaluationV2API", + "FallbackProxy", + "FlowAPI", + "FlowV1API", + "FlowV2API", + "HTTPCache", + "HTTPClient", + "MinIOClient", + "ResourceAPI", + "ResourceV1API", + "ResourceV2API", + "RunAPI", + "RunV1API", + "RunV2API", + "SetupAPI", + "SetupV1API", + "SetupV2API", + "StudyAPI", + "StudyV1API", + "StudyV2API", + "TaskAPI", + "TaskV1API", + "TaskV2API", + "_backend", +] diff --git a/openml/_api/clients/__init__.py b/openml/_api/clients/__init__.py new file mode 100644 index 000000000..42f11fbcf --- /dev/null +++ b/openml/_api/clients/__init__.py @@ -0,0 +1,8 @@ +from .http import HTTPCache, HTTPClient +from .minio import MinIOClient + +__all__ = [ + "HTTPCache", + "HTTPClient", + "MinIOClient", +] diff --git a/openml/_api/clients/http.py b/openml/_api/clients/http.py new file mode 100644 index 000000000..41b8462f1 --- /dev/null +++ b/openml/_api/clients/http.py @@ -0,0 +1,841 @@ +from __future__ import annotations + +import hashlib +import json +import logging +import math +import random +import time +import xml +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast +from urllib.parse import urlencode, urljoin, urlparse + +import requests +import xmltodict +from requests import Response + +import openml +from openml.enums import APIVersion, RetryPolicy +from openml.exceptions import ( + OpenMLAuthenticationError, + OpenMLHashException, + OpenMLServerError, + OpenMLServerException, + OpenMLServerNoResult, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Mapping + + +class HTTPCache: + """ + Filesystem-based cache for HTTP responses. + + This class stores HTTP responses on disk using a structured directory layout + derived from the request URL and parameters. Each cached response consists of + three files: metadata (``meta.json``), headers (``headers.json``), and the raw + body (``body.bin``). + + Notes + ----- + The cache key is derived from the URL (domain and path components) and query + parameters, excluding the ``api_key`` parameter. + """ + + @property + def path(self) -> Path: + return Path(openml.config.get_cache_directory()) + + def get_key(self, url: str, params: dict[str, Any]) -> str: + """ + Generate a filesystem-safe cache key for a request. + + The key is constructed from URL path segments and + URL-encoded query parameters (excluding ``api_key``). + + Parameters + ---------- + url : str + The full request URL. + params : dict of str to Any + Query parameters associated with the request. + + Returns + ------- + str + A relative path string representing the cache key. + """ + parsed_url = urlparse(url) + path_parts = parsed_url.path.strip("/").split("/") + + filtered_params = {k: v for k, v in params.items() if k != "api_key"} + params_part = [urlencode(filtered_params)] if filtered_params else [] + + return str(Path(*path_parts, *params_part)) + + def _key_to_path(self, key: str) -> Path: + """ + Convert a cache key into an absolute filesystem path. + + Parameters + ---------- + key : str + Cache key as returned by :meth:`get_key`. + + Returns + ------- + pathlib.Path + Absolute path corresponding to the cache entry. + """ + return self.path.joinpath(key) + + def _get_body_filename_from_response(self, response: Response) -> str: + content_type = response.headers.get("Content-Type", "").lower() + + # check for .json + if "application/json" in content_type: + return "body.json" + + # check for .xml + if "text/xml" in content_type: + return "body.xml" + + # check for .zip + if response.content.startswith(b"PK\x03\x04"): + return "body.zip" + + # check for .arff + for raw_line in response.text.splitlines(): + stripped_line = raw_line.strip() + # skip empty lines and comments + if not stripped_line or stripped_line.startswith("%"): + continue + if stripped_line.lower().startswith("@relation"): + return "body.arff" + break + + return "body.txt" + + def _get_body_filename_from_path(self, path: Path) -> str: + candidates = [p for p in path.glob("body.*") if len(p.suffixes) == 1] + + if not candidates: + raise FileNotFoundError(f"No body file found in path: {path}") + + if len(candidates) > 1: + raise FileNotFoundError( + f"Multiple body files found in path: {path} ({[p.name for p in candidates]})" + ) + + return candidates[0].name + + def load(self, key: str) -> Response: + """ + Load a cached HTTP response from disk. + + Parameters + ---------- + key : str + Cache key identifying the stored response. + + Returns + ------- + requests.Response + Reconstructed response object with status code, headers, body, and metadata. + + Raises + ------ + FileNotFoundError + If the cache entry or required files are missing. + ValueError + If required metadata is missing or malformed. + """ + path = self._key_to_path(key) + + if not path.exists(): + raise FileNotFoundError(f"Cache path not found: {path}") + + meta_path = path / "meta.json" + meta_raw = meta_path.read_bytes() if meta_path.exists() else "{}" + meta = json.loads(meta_raw) + + headers_path = path / "headers.json" + headers_raw = headers_path.read_bytes() if headers_path.exists() else "{}" + headers = json.loads(headers_raw) + + body_path = path / self._get_body_filename_from_path(path) + body = body_path.read_bytes() + + response = Response() + response.headers = headers + response._content = body + response.status_code = meta.get("status_code") + response.url = meta.get("url") + response.reason = meta.get("reason") + response.encoding = meta.get("encoding") + + return response + + def save(self, key: str, response: Response) -> None: + """ + Persist an HTTP response to disk. + + Parameters + ---------- + key : str + Cache key identifying where to store the response. + response : requests.Response + Response object to cache. + + Notes + ----- + The response body is stored as binary data. Headers and metadata + (status code, URL, reason, encoding, elapsed time, request info, and + creation timestamp) are stored as JSON. + """ + path = self._key_to_path(key) + path.mkdir(parents=True, exist_ok=True) + + body_filename = self._get_body_filename_from_response(response) + with (path / body_filename).open("wb") as f: + f.write(response.content) + + with (path / "headers.json").open("w", encoding="utf-8") as f: + json.dump(dict(response.headers), f) + + meta = { + "status_code": response.status_code, + "url": response.url, + "reason": response.reason, + "encoding": response.encoding, + "created_at": time.time(), + "request": { + "method": response.request.method if response.request else None, + "url": response.request.url if response.request else None, + "headers": dict(response.request.headers) if response.request else None, + "body": response.request.body if response.request else None, + }, + } + + with (path / "meta.json").open("w", encoding="utf-8") as f: + json.dump(meta, f) + + +class HTTPClient: + """ + HTTP client for interacting with the OpenML API. + + This client supports configurable retry policies, optional filesystem + caching, API key authentication, and response validation including + checksum verification. + + Parameters + ---------- + api_version : APIVersion + Backend API Version. + """ + + def __init__( + self, + *, + api_version: APIVersion, + ) -> None: + self.api_version = api_version + + self.cache = HTTPCache() + + @property + def server(self) -> str: + server = openml.config.servers[self.api_version]["server"] + if server is None: + servers_repr = {k.value: v for k, v in openml.config.servers.items()} + raise ValueError( + f'server found to be None for api_version="{self.api_version}" in {servers_repr}' + ) + return cast("str", server) + + @property + def api_key(self) -> str | None: + return cast("str | None", openml.config.servers[self.api_version]["apikey"]) + + @property + def retries(self) -> int: + return cast("int", openml.config.connection_n_retries) + + @property + def retry_policy(self) -> RetryPolicy: + return RetryPolicy.HUMAN if openml.config.retry_policy == "human" else RetryPolicy.ROBOT + + @property + def retry_func(self) -> Callable: + return self._human_delay if self.retry_policy == RetryPolicy.HUMAN else self._robot_delay + + def _robot_delay(self, n: int) -> float: + """ + Compute delay for automated retry policy. + + Parameters + ---------- + n : int + Current retry attempt number (1-based). + + Returns + ------- + float + Number of seconds to wait before the next retry. + + Notes + ----- + Uses a sigmoid-based growth curve with Gaussian noise to gradually + increase waiting time. + """ + wait = (1 / (1 + math.exp(-(n * 0.5 - 4)))) * 60 + variation = random.gauss(0, wait / 10) + return max(1.0, wait + variation) + + def _human_delay(self, n: int) -> float: + """ + Compute delay for human-like retry policy. + + Parameters + ---------- + n : int + Current retry attempt number (1-based). + + Returns + ------- + float + Number of seconds to wait before the next retry. + """ + return max(1.0, n) + + def _parse_exception_response( + self, + response: Response, + ) -> tuple[int | None, str]: + """ + Parse an error response returned by the server. + + Parameters + ---------- + response : requests.Response + HTTP response containing error details in JSON or XML format. + + Returns + ------- + tuple of (int or None, str) + Parsed error code and combined error message. The code may be + ``None`` if unavailable. + """ + content_type = response.headers.get("Content-Type", "").lower() + + if "application/json" in content_type: + server_exception = response.json() + server_error = server_exception["detail"] + code = server_error.get("code") + message = server_error.get("message") + additional_information = server_error.get("additional_information") + else: + server_exception = xmltodict.parse(response.text) + server_error = server_exception["oml:error"] + code = server_error.get("oml:code") + message = server_error.get("oml:message") + additional_information = server_error.get("oml:additional_information") + + if code is not None: + code = int(code) + + if message and additional_information: + full_message = f"{message} - {additional_information}" + else: + full_message = message or additional_information or "" + + return code, full_message + + def _raise_code_specific_error( + self, + code: int, + message: str, + url: str, + files: Mapping[str, Any] | None, + ) -> None: + """ + Raise specialized exceptions based on OpenML error codes. + + Parameters + ---------- + code : int + Server-provided error code. + message : str + Parsed error message. + url : str + Request URL associated with the error. + files : Mapping of str to Any or None + Files sent with the request, if any. + + Raises + ------ + OpenMLServerNoResult + If the error indicates a missing resource. + OpenMLNotAuthorizedError + If authentication is required or invalid. + OpenMLServerException + For other server-side errors (except retryable database errors). + """ + if code in [111, 372, 512, 500, 482, 542, 674]: + # 512 for runs, 372 for datasets, 500 for flows + # 482 for tasks, 542 for evaluations, 674 for setups + # 111 for dataset descriptions + raise OpenMLServerNoResult(code=code, message=message, url=url) + + # 163: failure to validate flow XML (https://www.openml.org/api_docs#!/flow/post_flow) + if code == 163 and files is not None and "description" in files: + # file_elements['description'] is the XML file description of the flow + message = f"\n{files['description']}\n{message}" + + # Propagate all server errors to the calling functions, except + # for 107 which represents a database connection error. + # These are typically caused by high server load, + # which means trying again might resolve the issue. + # DATABASE_CONNECTION_ERRCODE + if code != 107: + raise OpenMLServerException(code=code, message=message, url=url) + + def _validate_response( + self, + method: str, + url: str, + files: Mapping[str, Any] | None, + response: Response, + ) -> Exception | None: + """ + Validate an HTTP response and determine whether to retry. + + Parameters + ---------- + method : str + HTTP method used for the request. + url : str + Full request URL. + files : Mapping of str to Any or None + Files sent with the request, if any. + response : requests.Response + Received HTTP response. + + Returns + ------- + Exception or None + ``None`` if the response is valid. Otherwise, an exception + indicating the error to raise or retry. + + Raises + ------ + OpenMLServerError + For unexpected server errors or malformed responses. + """ + if ( + "Content-Encoding" not in response.headers + or response.headers["Content-Encoding"] != "gzip" + ): + logging.warning(f"Received uncompressed content from OpenML for {url}.") + + if response.status_code == 200: + return None + + if response.status_code == requests.codes.URI_TOO_LONG: + raise OpenMLServerError(f"URI too long! ({url})") + + try: + code, message = self._parse_exception_response(response) + except Exception as e: + error = OpenMLServerError( + f"Unexpected server error when calling {url}. Please contact the " + f"developers!\nStatus code: {response.status_code}\n{response.text}" + ) + if method == "GET" and isinstance( + e, (requests.exceptions.JSONDecodeError, xml.parsers.expat.ExpatError) + ): + error.__cause__ = e + return error + raise error from e + + if code is not None: + self._raise_code_specific_error( + code=code, + message=message, + url=url, + files=files, + ) + + return OpenMLServerException(code=code, message=message, url=url) + + def __request( # noqa: PLR0913,PLR0917 + self, + session: requests.Session, + method: str, + url: str, + params: Mapping[str, Any], + data: Mapping[str, Any], + headers: Mapping[str, str], + files: Mapping[str, Any] | None, + **request_kwargs: Any, + ) -> tuple[Response | None, Exception | None]: + """ + Execute a single HTTP request attempt. + + Parameters + ---------- + session : requests.Session + Active session used to send the request. + method : str + HTTP method (e.g., ``GET``, ``POST``). + url : str + Full request URL. + params : Mapping of str to Any + Query parameters. + data : Mapping of str to Any + Request body data. + headers : Mapping of str to str + HTTP headers. + files : Mapping of str to Any or None + Files to upload. + **request_kwargs : Any + Additional arguments forwarded to ``requests.Session.request``. + + Returns + ------- + tuple of (requests.Response or None, Exception or None) + Response and potential retry exception. + """ + exception: Exception | None = None + response: Response | None = None + + try: + response = session.request( + method=method, + url=url, + params=params, + data=data, + headers=headers, + files=files, + **request_kwargs, + ) + except ( + requests.exceptions.ChunkedEncodingError, + requests.exceptions.ConnectionError, + requests.exceptions.SSLError, + ) as e: + exception = e + + if response is not None: + exception = self._validate_response( + method=method, + url=url, + files=files, + response=response, + ) + + return response, exception + + def _request( # noqa: PLR0913, C901 + self, + method: str, + path: str, + *, + enable_cache: bool = False, + refresh_cache: bool = False, + use_api_key: bool = False, + md5_checksum: str | None = None, + **request_kwargs: Any, + ) -> Response: + """ + Send an HTTP request with retry, caching, and validation support. + + Parameters + ---------- + method : str + HTTP method to use. + path : str + API path relative to the base URL. + enable_cache : bool, optional + Whether to load/store response from cache. + refresh_cache : bool, optional + Only used when `enable_cache=True`. If True, ignore any existing + cached response and overwrite it with a fresh one. + use_api_key : bool, optional + Whether to include the API key in query parameters. + md5_checksum : str or None, optional + Expected MD5 checksum of the response body. + **request_kwargs : Any + Additional arguments passed to the underlying request. + + Returns + ------- + requests.Response + Final validated response. + + Raises + ------ + Exception + Propagates network, validation, or server exceptions after retries. + OpenMLHashException + If checksum verification fails. + """ + url = urljoin(self.server, path) + retries = max(1, self.retries) + + params = request_kwargs.pop("params", {}).copy() + data = request_kwargs.pop("data", {}).copy() + + if use_api_key: + if self.api_key is None: + raise OpenMLAuthenticationError( + message=( + f"The API call {url} requires authentication via an API key. " + "Please configure OpenML-Python to use your API " + "as described in this example: " + "https://openml.github.io/openml-python/latest/examples/Basics/introduction_tutorial/#authentication" + ) + ) + params["api_key"] = self.api_key + + if method.upper() in {"POST", "PUT", "PATCH"}: + data = {**params, **data} + params = {} + + # prepare headers + headers = request_kwargs.pop("headers", {}).copy() + headers.update(openml.config._HEADERS) + + files = request_kwargs.pop("files", None) + + if enable_cache and not refresh_cache: + cache_key = self.cache.get_key(url, params) + try: + return self.cache.load(cache_key) + except FileNotFoundError: + pass # cache miss, continue + except Exception: + raise # propagate unexpected cache errors + + with requests.Session() as session: + for retry_counter in range(1, retries + 1): + response, exception = self.__request( + session=session, + method=method, + url=url, + params=params, + data=data, + headers=headers, + files=files, + **request_kwargs, + ) + + # executed successfully + if exception is None: + break + # tries completed + if retry_counter >= retries: + raise exception + + delay = self.retry_func(retry_counter) + time.sleep(delay) + + # response is guaranteed to be not `None` + # otherwise an exception would have been raised before + response = cast("Response", response) + + if md5_checksum is not None: + self._verify_checksum(response, md5_checksum) + + if enable_cache: + cache_key = self.cache.get_key(url, params) + self.cache.save(cache_key, response) + + return response + + def _verify_checksum(self, response: Response, md5_checksum: str) -> None: + """ + Verify MD5 checksum of a response body. + + Parameters + ---------- + response : requests.Response + HTTP response whose content should be verified. + md5_checksum : str + Expected hexadecimal MD5 checksum. + + Raises + ------ + OpenMLHashException + If the computed checksum does not match the expected value. + """ + # ruff sees hashlib.md5 as insecure + actual = hashlib.md5(response.content).hexdigest() # noqa: S324 + if actual != md5_checksum: + raise OpenMLHashException( + f"Checksum of downloaded file is unequal to the expected checksum {md5_checksum} " + f"when downloading {response.url}.", + ) + + def get( + self, + path: str, + *, + enable_cache: bool = False, + refresh_cache: bool = False, + use_api_key: bool = False, + md5_checksum: str | None = None, + **request_kwargs: Any, + ) -> Response: + """ + Send a GET request. + + Parameters + ---------- + path : str + API path relative to the base URL. + enable_cache : bool, optional + Whether to use the response cache. + refresh_cache : bool, optional + Whether to ignore existing cached entries. + use_api_key : bool, optional + Whether to include the API key. + md5_checksum : str or None, optional + Expected MD5 checksum for response validation. + **request_kwargs : Any + Additional request arguments. + + Returns + ------- + requests.Response + HTTP response. + """ + return self._request( + method="GET", + path=path, + enable_cache=enable_cache, + refresh_cache=refresh_cache, + use_api_key=use_api_key, + md5_checksum=md5_checksum, + **request_kwargs, + ) + + def post( + self, + path: str, + *, + use_api_key: bool = True, + **request_kwargs: Any, + ) -> Response: + """ + Send a POST request. + + Parameters + ---------- + path : str + API path relative to the base URL. + use_api_key : bool, optional + Whether to include the API key. + **request_kwargs : Any + Additional request arguments. + + Returns + ------- + requests.Response + HTTP response. + """ + return self._request( + method="POST", + path=path, + enable_cache=False, + use_api_key=use_api_key, + **request_kwargs, + ) + + def delete( + self, + path: str, + **request_kwargs: Any, + ) -> Response: + """ + Send a DELETE request. + + Parameters + ---------- + path : str + API path relative to the base URL. + **request_kwargs : Any + Additional request arguments. + + Returns + ------- + requests.Response + HTTP response. + """ + return self._request( + method="DELETE", + path=path, + enable_cache=False, + use_api_key=True, + **request_kwargs, + ) + + def download( + self, + url: str, + handler: Callable[[Response, Path, str], None] | None = None, + encoding: str = "utf-8", + file_name: str = "response.txt", + md5_checksum: str | None = None, + ) -> Path: + """ + Download a resource and store it in the cache directory. + + Parameters + ---------- + url : str + Absolute URL of the resource to download. + handler : callable or None, optional + Custom handler function accepting ``(response, path, encoding)`` + and returning a ``pathlib.Path``. + encoding : str, optional + Text encoding used when writing the response body. + file_name : str, optional + Name of the saved file. + md5_checksum : str or None, optional + Expected MD5 checksum for integrity verification. + + Returns + ------- + pathlib.Path + Path to the downloaded file. + + Raises + ------ + OpenMLHashException + If checksum verification fails. + """ + base = self.cache.path + file_path = base / "downloads" / urlparse(url).path.lstrip("/") / file_name + file_path = file_path.expanduser() + file_path.parent.mkdir(parents=True, exist_ok=True) + if file_path.exists(): + return file_path + + response = self.get(url, md5_checksum=md5_checksum) + + def write_to_file(response: Response, path: Path, encoding: str) -> None: + path.write_text(response.text, encoding) + + handler = handler or write_to_file + handler(response, file_path, encoding) + return file_path + + def cache_path_from_url(self, url: str) -> Path: + full_url = urljoin(self.server, url) + key = self.cache.get_key(full_url, params={}) + path = self.cache._key_to_path(key) + return path / self.cache._get_body_filename_from_path(path) diff --git a/openml/_api/clients/minio.py b/openml/_api/clients/minio.py new file mode 100644 index 000000000..920b485e0 --- /dev/null +++ b/openml/_api/clients/minio.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from pathlib import Path + +import openml + + +class MinIOClient: + """ + Lightweight client configuration for interacting with a MinIO-compatible + object storage service. + + This class stores basic configuration such as a base filesystem path and + default HTTP headers. It is intended to be extended with actual request + or storage logic elsewhere. + + Attributes + ---------- + path : pathlib.Path or None + Configured base path for storage operations. + headers : dict of str to str + Default HTTP headers, including a user-agent identifying the + OpenML Python client version. + """ + + @property + def path(self) -> Path: + return Path(openml.config.get_cache_directory()) diff --git a/openml/_api/resources/__init__.py b/openml/_api/resources/__init__.py new file mode 100644 index 000000000..6d957966e --- /dev/null +++ b/openml/_api/resources/__init__.py @@ -0,0 +1,63 @@ +from ._registry import API_REGISTRY +from .base import ( + DatasetAPI, + EstimationProcedureAPI, + EvaluationAPI, + EvaluationMeasureAPI, + FallbackProxy, + FlowAPI, + ResourceAPI, + ResourceV1API, + ResourceV2API, + RunAPI, + SetupAPI, + StudyAPI, + TaskAPI, +) +from .dataset import DatasetV1API, DatasetV2API +from .estimation_procedure import ( + EstimationProcedureV1API, + EstimationProcedureV2API, +) +from .evaluation import EvaluationV1API, EvaluationV2API +from .evaluation_measure import EvaluationMeasureV1API, EvaluationMeasureV2API +from .flow import FlowV1API, FlowV2API +from .run import RunV1API, RunV2API +from .setup import SetupV1API, SetupV2API +from .study import StudyV1API, StudyV2API +from .task import TaskV1API, TaskV2API + +__all__ = [ + "API_REGISTRY", + "DatasetAPI", + "DatasetV1API", + "DatasetV2API", + "EstimationProcedureAPI", + "EstimationProcedureV1API", + "EstimationProcedureV2API", + "EvaluationAPI", + "EvaluationMeasureAPI", + "EvaluationMeasureV1API", + "EvaluationMeasureV2API", + "EvaluationV1API", + "EvaluationV2API", + "FallbackProxy", + "FlowAPI", + "FlowV1API", + "FlowV2API", + "ResourceAPI", + "ResourceV1API", + "ResourceV2API", + "RunAPI", + "RunV1API", + "RunV2API", + "SetupAPI", + "SetupV1API", + "SetupV2API", + "StudyAPI", + "StudyV1API", + "StudyV2API", + "TaskAPI", + "TaskV1API", + "TaskV2API", +] diff --git a/openml/_api/resources/_registry.py b/openml/_api/resources/_registry.py new file mode 100644 index 000000000..66d7ec428 --- /dev/null +++ b/openml/_api/resources/_registry.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from openml.enums import APIVersion, ResourceType + +from .dataset import DatasetV1API, DatasetV2API +from .estimation_procedure import ( + EstimationProcedureV1API, + EstimationProcedureV2API, +) +from .evaluation import EvaluationV1API, EvaluationV2API +from .evaluation_measure import EvaluationMeasureV1API, EvaluationMeasureV2API +from .flow import FlowV1API, FlowV2API +from .run import RunV1API, RunV2API +from .setup import SetupV1API, SetupV2API +from .study import StudyV1API, StudyV2API +from .task import TaskV1API, TaskV2API + +if TYPE_CHECKING: + from .base import ResourceAPI + +API_REGISTRY: dict[ + APIVersion, + dict[ResourceType, type[ResourceAPI]], +] = { + APIVersion.V1: { + ResourceType.DATASET: DatasetV1API, + ResourceType.TASK: TaskV1API, + ResourceType.EVALUATION_MEASURE: EvaluationMeasureV1API, + ResourceType.ESTIMATION_PROCEDURE: EstimationProcedureV1API, + ResourceType.EVALUATION: EvaluationV1API, + ResourceType.FLOW: FlowV1API, + ResourceType.STUDY: StudyV1API, + ResourceType.RUN: RunV1API, + ResourceType.SETUP: SetupV1API, + }, + APIVersion.V2: { + ResourceType.DATASET: DatasetV2API, + ResourceType.TASK: TaskV2API, + ResourceType.EVALUATION_MEASURE: EvaluationMeasureV2API, + ResourceType.ESTIMATION_PROCEDURE: EstimationProcedureV2API, + ResourceType.EVALUATION: EvaluationV2API, + ResourceType.FLOW: FlowV2API, + ResourceType.STUDY: StudyV2API, + ResourceType.RUN: RunV2API, + ResourceType.SETUP: SetupV2API, + }, +} diff --git a/openml/_api/resources/base/__init__.py b/openml/_api/resources/base/__init__.py new file mode 100644 index 000000000..ed6dc26f7 --- /dev/null +++ b/openml/_api/resources/base/__init__.py @@ -0,0 +1,30 @@ +from .base import ResourceAPI +from .fallback import FallbackProxy +from .resources import ( + DatasetAPI, + EstimationProcedureAPI, + EvaluationAPI, + EvaluationMeasureAPI, + FlowAPI, + RunAPI, + SetupAPI, + StudyAPI, + TaskAPI, +) +from .versions import ResourceV1API, ResourceV2API + +__all__ = [ + "DatasetAPI", + "EstimationProcedureAPI", + "EvaluationAPI", + "EvaluationMeasureAPI", + "FallbackProxy", + "FlowAPI", + "ResourceAPI", + "ResourceV1API", + "ResourceV2API", + "RunAPI", + "SetupAPI", + "StudyAPI", + "TaskAPI", +] diff --git a/openml/_api/resources/base/base.py b/openml/_api/resources/base/base.py new file mode 100644 index 000000000..625681e3b --- /dev/null +++ b/openml/_api/resources/base/base.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, NoReturn + +from openml.exceptions import ( + OpenMLNotAuthorizedError, + OpenMLNotSupportedError, + OpenMLServerError, + OpenMLServerException, +) + +if TYPE_CHECKING: + from collections.abc import Mapping + from typing import Any + + from openml._api.clients import HTTPClient, MinIOClient + from openml.enums import APIVersion, ResourceType + + +class ResourceAPI(ABC): + """ + Abstract base class for OpenML resource APIs. + + This class defines the common interface for interacting with OpenML + resources (e.g., datasets, flows, runs) across different API versions. + Concrete subclasses must implement the resource-specific operations + such as publishing, deleting, and tagging. + + Parameters + ---------- + http : HTTPClient + Configured HTTP client used for communication with the OpenML API. + minio : MinIOClient + Configured MinIO client used for object storage operations. + + Attributes + ---------- + api_version : APIVersion + API version implemented by the resource. + resource_type : ResourceType + Type of OpenML resource handled by the implementation. + _http : HTTPClient + Internal HTTP client instance. + _minio : MinIOClient or None + Internal MinIO client instance, if provided. + """ + + api_version: APIVersion + resource_type: ResourceType + + def __init__(self, http: HTTPClient, minio: MinIOClient): + self._http = http + self._minio = minio + + @abstractmethod + def delete(self, resource_id: int) -> bool: + """ + Delete a resource by its identifier. + + Parameters + ---------- + resource_id : int + Unique identifier of the resource to delete. + + Returns + ------- + bool + ``True`` if the deletion was successful. + + Notes + ----- + Concrete subclasses must implement this method. + """ + + @abstractmethod + def publish(self, path: str, files: Mapping[str, Any] | None) -> int: + """ + Publish a new resource to the OpenML server. + + Parameters + ---------- + path : str + API endpoint path used for publishing the resource. + files : Mapping of str to Any or None + Files or payload data required for publishing. The structure + depends on the resource type. + + Returns + ------- + int + Identifier of the newly created resource. + + Notes + ----- + Concrete subclasses must implement this method. + """ + + @abstractmethod + def tag(self, resource_id: int, tag: str) -> list[str]: + """ + Add a tag to a resource. + + Parameters + ---------- + resource_id : int + Identifier of the resource to tag. + tag : str + Tag to associate with the resource. + + Returns + ------- + list of str + Updated list of tags assigned to the resource. + + Notes + ----- + Concrete subclasses must implement this method. + """ + + @abstractmethod + def untag(self, resource_id: int, tag: str) -> list[str]: + """ + Remove a tag from a resource. + + Parameters + ---------- + resource_id : int + Identifier of the resource to untag. + tag : str + Tag to remove from the resource. + + Returns + ------- + list of str + Updated list of tags assigned to the resource. + + Notes + ----- + Concrete subclasses must implement this method. + """ + + @abstractmethod + def _get_endpoint_name(self) -> str: + """ + Return the endpoint name for the current resource type. + + Returns + ------- + str + Endpoint segment used in API paths. + + Notes + ----- + Datasets use the special endpoint name ``"data"`` instead of + their enum value. + """ + + def _handle_delete_exception( + self, resource_type: str, exception: OpenMLServerException + ) -> None: + """ + Map V1 deletion error codes to more specific exceptions. + + Parameters + ---------- + resource_type : str + Endpoint name of the resource type. + exception : OpenMLServerException + Original exception raised during deletion. + + Raises + ------ + OpenMLNotAuthorizedError + If the resource cannot be deleted due to ownership or + dependent entities. + OpenMLServerError + If deletion fails for an unknown reason. + OpenMLServerException + If the error code is not specially handled. + """ + # https://github.com/openml/OpenML/blob/21f6188d08ac24fcd2df06ab94cf421c946971b0/openml_OS/views/pages/api_new/v1/xml/pre.php + # Most exceptions are descriptive enough to be raised as their standard + # OpenMLServerException, however there are two cases where we add information: + # - a generic "failed" message, we direct them to the right issue board + # - when the user successfully authenticates with the server, + # but user is not allowed to take the requested action, + # in which case we specify a OpenMLNotAuthorizedError. + by_other_user = [323, 353, 393, 453, 594] + has_dependent_entities = [324, 326, 327, 328, 354, 454, 464, 595] + unknown_reason = [325, 355, 394, 455, 593] + if exception.code in by_other_user: + raise OpenMLNotAuthorizedError( + message=( + f"The {resource_type} can not be deleted because it was not uploaded by you." + ), + ) from exception + if exception.code in has_dependent_entities: + raise OpenMLNotAuthorizedError( + message=( + f"The {resource_type} can not be deleted because " + f"it still has associated entities: {exception.message}" + ), + ) from exception + if exception.code in unknown_reason: + raise OpenMLServerError( + message=( + f"The {resource_type} can not be deleted for unknown reason," + " please open an issue at: https://github.com/openml/openml/issues/new" + ), + ) from exception + raise exception + + def _not_supported(self, *, method: str) -> NoReturn: + """ + Raise an error indicating that a method is not supported. + + Parameters + ---------- + method : str + Name of the unsupported method. + + Raises + ------ + OpenMLNotSupportedError + If the current API version does not support the requested method + for the given resource type. + """ + version = getattr(self.api_version, "value", "unknown") + resource = getattr(self.resource_type, "value", "unknown") + + raise OpenMLNotSupportedError( + f"{self.__class__.__name__}: " + f"{version} API does not support `{method}` " + f"for resource `{resource}`" + ) diff --git a/openml/_api/resources/base/fallback.py b/openml/_api/resources/base/fallback.py new file mode 100644 index 000000000..063154b1d --- /dev/null +++ b/openml/_api/resources/base/fallback.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from openml.exceptions import OpenMLNotSupportedError + +if TYPE_CHECKING: + from collections.abc import Callable + + +class FallbackProxy: + """ + Proxy object that provides transparent fallback across multiple API versions. + + This class delegates attribute access to a sequence of API implementations. + When a callable attribute is invoked and raises ``OpenMLNotSupportedError``, + the proxy automatically attempts the same method on subsequent API instances + until one succeeds. + + Parameters + ---------- + *api_versions : Any + One or more API implementation instances ordered by priority. + The first API is treated as the primary implementation, and + subsequent APIs are used as fallbacks. + + Raises + ------ + ValueError + If no API implementations are provided. + + Notes + ----- + Attribute lookup is performed dynamically via ``__getattr__``. + Only methods that raise ``OpenMLNotSupportedError`` trigger fallback + behavior. Other exceptions are propagated immediately. + """ + + def __init__(self, *api_versions: Any): + if not api_versions: + raise ValueError("At least one API version must be provided") + self._apis = api_versions + + def __getattr__(self, name: str) -> Any: + """ + Dynamically resolve attribute access across API implementations. + + Parameters + ---------- + name : str + Name of the attribute being accessed. + + Returns + ------- + Any + The resolved attribute. If it is callable, a wrapped function + providing fallback behavior is returned. + + Raises + ------ + AttributeError + If none of the API implementations define the attribute. + """ + api, attr = self._find_attr(name) + if callable(attr): + return self._wrap_callable(name, api, attr) + return attr + + def _find_attr(self, name: str) -> tuple[Any, Any]: + """ + Find the first API implementation that defines a given attribute. + + Parameters + ---------- + name : str + Name of the attribute to search for. + + Returns + ------- + tuple of (Any, Any) + The API instance and the corresponding attribute. + + Raises + ------ + AttributeError + If no API implementation defines the attribute. + """ + for api in self._apis: + attr = getattr(api, name, None) + if attr is not None: + return api, attr + raise AttributeError(f"{self.__class__.__name__} has no attribute {name}") + + def _wrap_callable( + self, + name: str, + primary_api: Any, + primary_attr: Callable[..., Any], + ) -> Callable[..., Any]: + """ + Wrap a callable attribute to enable fallback behavior. + + Parameters + ---------- + name : str + Name of the method being wrapped. + primary_api : Any + Primary API instance providing the callable. + primary_attr : Callable[..., Any] + Callable attribute obtained from the primary API. + + Returns + ------- + Callable[..., Any] + Wrapped function that attempts the primary call first and + falls back to other APIs if ``OpenMLNotSupportedError`` is raised. + """ + + def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return primary_attr(*args, **kwargs) + except OpenMLNotSupportedError: + return self._call_fallbacks(name, primary_api, *args, **kwargs) + + return wrapper + + def _call_fallbacks( + self, + name: str, + skip_api: Any, + *args: Any, + **kwargs: Any, + ) -> Any: + """ + Attempt to call a method on fallback API implementations. + + Parameters + ---------- + name : str + Name of the method to invoke. + skip_api : Any + API instance to skip (typically the primary API that already failed). + *args : Any + Positional arguments passed to the method. + **kwargs : Any + Keyword arguments passed to the method. + + Returns + ------- + Any + Result returned by the first successful fallback invocation. + + Raises + ------ + OpenMLNotSupportedError + If all API implementations either do not define the method + or raise ``OpenMLNotSupportedError``. + """ + for api in self._apis: + if api is skip_api: + continue + attr = getattr(api, name, None) + if callable(attr): + try: + return attr(*args, **kwargs) + except OpenMLNotSupportedError: + continue + raise OpenMLNotSupportedError(f"Could not fallback to any API for method: {name}") diff --git a/openml/_api/resources/base/resources.py b/openml/_api/resources/base/resources.py new file mode 100644 index 000000000..61b576e27 --- /dev/null +++ b/openml/_api/resources/base/resources.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from abc import abstractmethod +from typing import TYPE_CHECKING, Any + +from openml.enums import ResourceType + +from .base import ResourceAPI + +if TYPE_CHECKING: + import builtins + from collections.abc import Iterable + + import pandas as pd + + from openml.estimation_procedures import OpenMLEstimationProcedure + from openml.evaluations import OpenMLEvaluation + from openml.flows.flow import OpenMLFlow + from openml.runs.run import OpenMLRun + from openml.setups.setup import OpenMLSetup + from openml.tasks.task import TaskType + + +class DatasetAPI(ResourceAPI): + """Abstract API interface for dataset resources.""" + + resource_type: ResourceType = ResourceType.DATASET + + +class TaskAPI(ResourceAPI): + """Abstract API interface for task resources.""" + + resource_type: ResourceType = ResourceType.TASK + + +class EvaluationMeasureAPI(ResourceAPI): + """Abstract API interface for evaluation measure resources.""" + + resource_type: ResourceType = ResourceType.EVALUATION_MEASURE + + @abstractmethod + def list(self) -> list[str]: ... + + +class EstimationProcedureAPI(ResourceAPI): + """Abstract API interface for estimation procedure resources.""" + + resource_type: ResourceType = ResourceType.ESTIMATION_PROCEDURE + + @abstractmethod + def list(self) -> list[OpenMLEstimationProcedure]: ... + + +class EvaluationAPI(ResourceAPI): + """Abstract API interface for evaluation resources.""" + + resource_type: ResourceType = ResourceType.EVALUATION + + @abstractmethod + def list( # noqa: PLR0913 + self, + limit: int, + offset: int, + *, + function: str, + tasks: list | None = None, + setups: list | None = None, + flows: list | None = None, + runs: list | None = None, + uploaders: list | None = None, + study: int | None = None, + sort_order: str | None = None, + **kwargs: Any, + ) -> list[OpenMLEvaluation]: ... + + +class FlowAPI(ResourceAPI): + """Abstract API interface for flow resources.""" + + resource_type: ResourceType = ResourceType.FLOW + + @abstractmethod + def get(self, flow_id: int, *, reset_cache: bool = False) -> OpenMLFlow: ... + + @abstractmethod + def list( + self, + limit: int | None = None, + offset: int | None = None, + tag: str | None = None, + uploader: str | None = None, + ) -> pd.DataFrame: ... + + @abstractmethod + def exists(self, name: str, external_version: str) -> int | bool: ... + + +class StudyAPI(ResourceAPI): + """Abstract API interface for study resources.""" + + resource_type: ResourceType = ResourceType.STUDY + + @abstractmethod + def list( # noqa: PLR0913,PLR0917 + self, + limit: int | None = None, + offset: int | None = None, + status: str | None = None, + main_entity_type: str | None = None, + uploader: list[int] | None = None, + benchmark_suite: int | None = None, + ) -> pd.DataFrame: ... + + +class RunAPI(ResourceAPI): + """Abstract API interface for run resources.""" + + resource_type: ResourceType = ResourceType.RUN + + @abstractmethod + def get( + self, + run_id: int, + *, + reset_cache: bool = False, + ) -> OpenMLRun: ... + + @abstractmethod + def list( # type: ignore[valid-type] # noqa: PLR0913 + self, + limit: int, + offset: int, + *, + ids: builtins.list[int] | None = None, + task: builtins.list[int] | None = None, + setup: builtins.list[int] | None = None, + flow: builtins.list[int] | None = None, + uploader: builtins.list[int] | None = None, + study: int | None = None, + tag: str | None = None, + display_errors: bool = False, + task_type: TaskType | int | None = None, + ) -> pd.DataFrame: ... + + @abstractmethod + def download_text_file( + self, + source: str, + *, + md5_checksum: str | None = None, + ) -> str: ... + + @abstractmethod + def file_id_to_url( + self, + file_id: int, + filename: str | None = None, + ) -> str: ... + + +class SetupAPI(ResourceAPI): + """Abstract API interface for setup resources.""" + + resource_type: ResourceType = ResourceType.SETUP + + @abstractmethod + def list( + self, + limit: int, + offset: int, + *, + setup: Iterable[int] | None = None, + flow: int | None = None, + tag: str | None = None, + ) -> list[OpenMLSetup]: ... + + @abstractmethod + def get(self, setup_id: int) -> OpenMLSetup: ... + + @abstractmethod + def exists( + self, + flow: OpenMLFlow, + param_settings: builtins.list[dict[str, Any]], + ) -> int | bool: ... diff --git a/openml/_api/resources/base/versions.py b/openml/_api/resources/base/versions.py new file mode 100644 index 000000000..d04194d66 --- /dev/null +++ b/openml/_api/resources/base/versions.py @@ -0,0 +1,280 @@ +from __future__ import annotations + +import xml.parsers.expat +from collections.abc import Mapping +from typing import Any, cast + +import xmltodict + +from openml.enums import APIVersion, ResourceType +from openml.exceptions import ( + OpenMLServerError, + OpenMLServerException, +) + +from .base import ResourceAPI + +_LEGAL_RESOURCES_DELETE = [ + ResourceType.DATASET, + ResourceType.TASK, + ResourceType.FLOW, + ResourceType.STUDY, + ResourceType.RUN, + ResourceType.USER, +] + +_LEGAL_RESOURCES_TAG = [ + ResourceType.DATASET, + ResourceType.TASK, + ResourceType.FLOW, + ResourceType.STUDY, + ResourceType.SETUP, + ResourceType.RUN, +] + + +class ResourceV1API(ResourceAPI): + """ + Version 1 implementation of the OpenML resource API. + + This class provides XML-based implementations for publishing, + deleting, tagging, and untagging resources using the V1 API + endpoints. Responses are parsed using ``xmltodict``. + + Notes + ----- + V1 endpoints expect and return XML. Error handling follows the + legacy OpenML server behavior and maps specific error codes to + more descriptive exceptions where appropriate. + """ + + api_version: APIVersion = APIVersion.V1 + + def publish(self, path: str, files: Mapping[str, Any] | None) -> int: + """ + Publish a new resource using the V1 API. + + Parameters + ---------- + path : str + API endpoint path for the upload. + files : Mapping of str to Any or None + Files to upload as part of the request payload. + + Returns + ------- + int + Identifier of the newly created resource. + + Raises + ------ + ValueError + If the server response does not contain a valid resource ID. + OpenMLServerException + If the server returns an error during upload. + """ + response = self._http.post(path, files=files) + try: + parsed_response = xmltodict.parse(response.content) + except xml.parsers.expat.ExpatError as e: + msg = f"Unexpected response to {path}Could not parse: {response.content!r}" + raise OpenMLServerError(msg) from e + return self._extract_id_from_upload(parsed_response) + + def delete(self, resource_id: int) -> bool: + """ + Delete a resource using the V1 API. + + Parameters + ---------- + resource_id : int + Identifier of the resource to delete. + + Returns + ------- + bool + ``True`` if the server confirms successful deletion. + + Raises + ------ + ValueError + If the resource type is not supported for deletion. + OpenMLNotAuthorizedError + If the user is not permitted to delete the resource. + OpenMLServerError + If deletion fails for an unknown reason. + OpenMLServerException + For other server-side errors. + """ + if self.resource_type not in _LEGAL_RESOURCES_DELETE: + raise ValueError(f"Can't delete a {self.resource_type.value}") + + endpoint_name = self._get_endpoint_name() + path = f"{endpoint_name}/{resource_id}" + try: + response = self._http.delete(path) + try: + parsed_response = xmltodict.parse(response.content) + except xml.parsers.expat.ExpatError as e: + msg = f"Unexpected response to {path}Could not parse: {response.content!r}" + raise OpenMLServerError(msg) from e + return f"oml:{endpoint_name}_delete" in parsed_response + except OpenMLServerException as e: + self._handle_delete_exception(endpoint_name, e) + raise + + def tag(self, resource_id: int, tag: str) -> list[str]: + """ + Add a tag to a resource using the V1 API. + + Parameters + ---------- + resource_id : int + Identifier of the resource to tag. + tag : str + Tag to associate with the resource. + + Returns + ------- + list of str + Updated list of tags assigned to the resource. + + Raises + ------ + ValueError + If the resource type does not support tagging. + OpenMLServerException + If the server returns an error. + """ + if self.resource_type not in _LEGAL_RESOURCES_TAG: + raise ValueError(f"Can't tag a {self.resource_type.value}") + + endpoint_name = self._get_endpoint_name() + path = f"{endpoint_name}/tag" + data = {f"{endpoint_name}_id": resource_id, "tag": tag} + response = self._http.post(path, data=data) + + try: + parsed_response = xmltodict.parse(response.content, force_list={"oml:tag"}) + except xml.parsers.expat.ExpatError as e: + msg = f"Unexpected response to {path}Could not parse: {response.content!r}" + raise OpenMLServerError(msg) from e + result = parsed_response[f"oml:{endpoint_name}_tag"] + tags: list[str] = result.get("oml:tag", []) + + return tags + + def untag(self, resource_id: int, tag: str) -> list[str]: + """ + Remove a tag from a resource using the V1 API. + + Parameters + ---------- + resource_id : int + Identifier of the resource to untag. + tag : str + Tag to remove from the resource. + + Returns + ------- + list of str + Updated list of tags assigned to the resource. + + Raises + ------ + ValueError + If the resource type does not support tagging. + OpenMLServerException + If the server returns an error. + """ + if self.resource_type not in _LEGAL_RESOURCES_TAG: + raise ValueError(f"Can't untag a {self.resource_type.value}") + + endpoint_name = self._get_endpoint_name() + path = f"{endpoint_name}/untag" + data = {f"{endpoint_name}_id": resource_id, "tag": tag} + response = self._http.post(path, data=data) + + try: + parsed_response = xmltodict.parse(response.content, force_list={"oml:tag"}) + except xml.parsers.expat.ExpatError as e: + msg = f"Unexpected response to {path}Could not parse: {response.content!r}" + raise OpenMLServerError(msg) from e + result = parsed_response[f"oml:{endpoint_name}_untag"] + tags: list[str] = result.get("oml:tag", []) + + return tags + + def _get_endpoint_name(self) -> str: + if self.resource_type == ResourceType.DATASET: + return "data" + return cast("str", self.resource_type.value) + + def _extract_id_from_upload(self, parsed: Mapping[str, Any]) -> int: + """ + Extract the resource identifier from an XML upload response. + + Parameters + ---------- + parsed : Mapping of str to Any + Parsed XML response as returned by ``xmltodict.parse``. + + Returns + ------- + int + Extracted resource identifier. + + Raises + ------ + ValueError + If the response structure is unexpected or no identifier + can be found. + """ + # reads id from upload response + # actual parsed dict: {"oml:upload_flow": {"@xmlns:oml": "...", "oml:id": "42"}} + + # xmltodict always gives exactly one root key + ((_, root_value),) = parsed.items() + + if not isinstance(root_value, Mapping): + raise ValueError("Unexpected XML structure") + + # Look for oml:id directly in the root value + if "oml:id" in root_value: + id_value = root_value["oml:id"] + if isinstance(id_value, (str, int)): + return int(id_value) + + # Fallback: check all values for numeric/string IDs + for v in root_value.values(): + if isinstance(v, (str, int)): + return int(v) + + raise ValueError("No ID found in upload response") + + +class ResourceV2API(ResourceAPI): + """ + Version 2 implementation of the OpenML resource API. + + This class represents the V2 API for resources. Operations such as + publishing, deleting, tagging, and untagging are currently not + supported and will raise ``OpenMLNotSupportedError``. + """ + + api_version: APIVersion = APIVersion.V2 + + def publish(self, path: str, files: Mapping[str, Any] | None) -> int: # noqa: ARG002 + self._not_supported(method="publish") + + def delete(self, resource_id: int) -> bool: # noqa: ARG002 + self._not_supported(method="delete") + + def tag(self, resource_id: int, tag: str) -> list[str]: # noqa: ARG002 + self._not_supported(method="tag") + + def untag(self, resource_id: int, tag: str) -> list[str]: # noqa: ARG002 + self._not_supported(method="untag") + + def _get_endpoint_name(self) -> str: + return cast("str", self.resource_type.value) diff --git a/openml/_api/resources/dataset.py b/openml/_api/resources/dataset.py new file mode 100644 index 000000000..520594df9 --- /dev/null +++ b/openml/_api/resources/dataset.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from .base import DatasetAPI, ResourceV1API, ResourceV2API + + +class DatasetV1API(ResourceV1API, DatasetAPI): + """Version 1 API implementation for dataset resources.""" + + +class DatasetV2API(ResourceV2API, DatasetAPI): + """Version 2 API implementation for dataset resources.""" diff --git a/openml/_api/resources/estimation_procedure.py b/openml/_api/resources/estimation_procedure.py new file mode 100644 index 000000000..52663d2c6 --- /dev/null +++ b/openml/_api/resources/estimation_procedure.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import warnings + +import xmltodict + +from openml.estimation_procedures.estimation_procedure import OpenMLEstimationProcedure +from openml.tasks.task import TaskType + +from .base import EstimationProcedureAPI, ResourceV1API, ResourceV2API + + +class EstimationProcedureV1API(ResourceV1API, EstimationProcedureAPI): + """V1 API implementation for estimation procedures. + + Fetches estimation procedures from the v1 XML API endpoint. + """ + + def list(self) -> list[OpenMLEstimationProcedure]: + """Return a list of all estimation procedures which are on OpenML. + + Returns + ------- + procedures : list + A list of all estimation procedures. Every procedure is represented by + a dictionary containing the following information: id, task type id, + name, type, repeats, folds, stratified. + """ + path = "estimationprocedure/list" + response = self._http.get(path) + xml_content = response.text + + procs_dict = xmltodict.parse(xml_content) + + # Minimalistic check if the XML is useful + if "oml:estimationprocedures" not in procs_dict: + raise ValueError("Error in return XML, does not contain tag oml:estimationprocedures.") + + if "@xmlns:oml" not in procs_dict["oml:estimationprocedures"]: + raise ValueError( + "Error in return XML, does not contain tag " + "@xmlns:oml as a child of oml:estimationprocedures.", + ) + + if procs_dict["oml:estimationprocedures"]["@xmlns:oml"] != "http://openml.org/openml": + raise ValueError( + "Error in return XML, value of " + "oml:estimationprocedures/@xmlns:oml is not " + "http://openml.org/openml, but {}".format( + str(procs_dict["oml:estimationprocedures"]["@xmlns:oml"]) + ), + ) + + procs: list[OpenMLEstimationProcedure] = [] + for proc_ in procs_dict["oml:estimationprocedures"]["oml:estimationprocedure"]: + task_type_int = int(proc_["oml:ttid"]) + try: + task_type_id = TaskType(task_type_int) + procs.append( + OpenMLEstimationProcedure( + id=int(proc_["oml:id"]), + task_type_id=task_type_id, + name=proc_["oml:name"], + type=proc_["oml:type"], + ) + ) + except ValueError as e: + warnings.warn( + f"Could not create task type id for {task_type_int} due to error {e}", + RuntimeWarning, + stacklevel=2, + ) + + return procs + + +class EstimationProcedureV2API(ResourceV2API, EstimationProcedureAPI): + """V2 API implementation for estimation procedures. + + Fetches estimation procedures from the v2 JSON API endpoint. + """ + + def list(self) -> list[OpenMLEstimationProcedure]: + self._not_supported(method="list") diff --git a/openml/_api/resources/evaluation.py b/openml/_api/resources/evaluation.py new file mode 100644 index 000000000..dc768a81f --- /dev/null +++ b/openml/_api/resources/evaluation.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any + +import xmltodict + +from openml.evaluations import OpenMLEvaluation + +from .base import EvaluationAPI, ResourceV1API, ResourceV2API + +if TYPE_CHECKING: + import builtins + + +class EvaluationV1API(ResourceV1API, EvaluationAPI): + """V1 API implementation for evaluations. + Fetches evaluations from the v1 XML API endpoint. + """ + + def list( # noqa: PLR0913 + self, + limit: int, + offset: int, + *, + function: str, + tasks: builtins.list | None = None, + setups: builtins.list | None = None, + flows: builtins.list | None = None, + runs: builtins.list | None = None, + uploaders: builtins.list | None = None, + study: int | None = None, + sort_order: str | None = None, + **kwargs: Any, + ) -> builtins.list[OpenMLEvaluation]: + """Retrieve evaluations from the OpenML v1 XML API. + + This method builds an evaluation query URL based on the provided + filters, sends a request to the OpenML v1 endpoint, parses the XML + response into a dictionary, and enriches the result with uploader + usernames. + + Parameters + ---------- + The arguments that are lists are separated from the single value + ones which are put into the kwargs. + + limit : int + the number of evaluations to return + offset : int + the number of evaluations to skip, starting from the first + function : str + the evaluation function. e.g., predictive_accuracy + + tasks : list[int,str], optional + the list of task IDs + setups: list[int,str], optional + the list of setup IDs + flows : list[int,str], optional + the list of flow IDs + runs :list[int,str], optional + the list of run IDs + uploaders : list[int,str], optional + the list of uploader IDs + + study : int, optional + + kwargs: dict, optional + Legal filter operators: tag, per_fold + + sort_order : str, optional + order of sorting evaluations, ascending ("asc") or descending ("desc") + + Returns + ------- + list of OpenMLEvaluation objects + + Notes + ----- + This method performs two API calls: + 1. Fetches evaluation data from the specified endpoint + 2. Fetches user information for all uploaders in the evaluation data + + The user information is used to map uploader IDs to usernames. + """ + api_call = self._build_url( + limit, + offset, + function=function, + tasks=tasks, + setups=setups, + flows=flows, + runs=runs, + uploaders=uploaders, + study=study, + sort_order=sort_order, + **kwargs, + ) + + eval_response = self._http.get(api_call) + xml_content = eval_response.text + + return self._parse_list_xml(xml_content) + + def _build_url( # noqa: PLR0913, C901 + self, + limit: int, + offset: int, + *, + function: str, + tasks: builtins.list | None = None, + setups: builtins.list | None = None, + flows: builtins.list | None = None, + runs: builtins.list | None = None, + uploaders: builtins.list | None = None, + study: int | None = None, + sort_order: str | None = None, + **kwargs: Any, + ) -> str: + """ + Construct an OpenML evaluation API URL with filtering parameters. + + Parameters + ---------- + The arguments that are lists are separated from the single value + ones which are put into the kwargs. + + limit : int + the number of evaluations to return + offset : int + the number of evaluations to skip, starting from the first + function : str + the evaluation function. e.g., predictive_accuracy + + tasks : list[int,str], optional + the list of task IDs + setups: list[int,str], optional + the list of setup IDs + flows : list[int,str], optional + the list of flow IDs + runs :list[int,str], optional + the list of run IDs + uploaders : list[int,str], optional + the list of uploader IDs + + study : int, optional + + kwargs: dict, optional + Legal filter operators: tag, per_fold + + sort_order : str, optional + order of sorting evaluations, ascending ("asc") or descending ("desc") + + Returns + ------- + str + A relative API path suitable for an OpenML HTTP request. + """ + api_call = f"evaluation/list/function/{function}" + if limit is not None: + api_call += f"/limit/{limit}" + if offset is not None: + api_call += f"/offset/{offset}" + if kwargs is not None: + for operator, value in kwargs.items(): + if value is not None: + api_call += f"/{operator}/{value}" + if tasks is not None: + api_call += f"/task/{','.join([str(int(i)) for i in tasks])}" + if setups is not None: + api_call += f"/setup/{','.join([str(int(i)) for i in setups])}" + if flows is not None: + api_call += f"/flow/{','.join([str(int(i)) for i in flows])}" + if runs is not None: + api_call += f"/run/{','.join([str(int(i)) for i in runs])}" + if uploaders is not None: + api_call += f"/uploader/{','.join([str(int(i)) for i in uploaders])}" + if study is not None: + api_call += f"/study/{study}" + if sort_order is not None: + api_call += f"/sort_order/{sort_order}" + + return api_call + + def _parse_list_xml(self, xml_content: str) -> builtins.list[OpenMLEvaluation]: + """Helper function to parse API calls which are lists of runs""" + evals_dict: dict[str, Any] = xmltodict.parse(xml_content, force_list=("oml:evaluation",)) + # Minimalistic check if the XML is useful + if "oml:evaluations" not in evals_dict: + raise ValueError( + f'Error in return XML, does not contain "oml:evaluations": {evals_dict!s}', + ) + + assert isinstance(evals_dict["oml:evaluations"]["oml:evaluation"], list), ( + "Expected 'oml:evaluation' to be a list, but got " + f"{type(evals_dict['oml:evaluations']['oml:evaluation']).__name__}. " + ) + + uploader_ids = list( + {eval_["oml:uploader"] for eval_ in evals_dict["oml:evaluations"]["oml:evaluation"]}, + ) + user_dict = self._get_users(uploader_ids) + + evals = [] + for eval_ in evals_dict["oml:evaluations"]["oml:evaluation"]: + run_id = int(eval_["oml:run_id"]) + value = float(eval_["oml:value"]) if "oml:value" in eval_ else None + values = json.loads(eval_["oml:values"]) if eval_.get("oml:values", None) else None + array_data = eval_.get("oml:array_data") + + evals.append( + OpenMLEvaluation( + run_id=run_id, + task_id=int(eval_["oml:task_id"]), + setup_id=int(eval_["oml:setup_id"]), + flow_id=int(eval_["oml:flow_id"]), + flow_name=eval_["oml:flow_name"], + data_id=int(eval_["oml:data_id"]), + data_name=eval_["oml:data_name"], + function=eval_["oml:function"], + upload_time=eval_["oml:upload_time"], + uploader=int(eval_["oml:uploader"]), + uploader_name=user_dict[eval_["oml:uploader"]], + value=value, + values=values, + array_data=array_data, + ) + ) + + return evals + + def _get_users(self, uploader_ids: builtins.list[str]) -> dict: + """ + Retrieve usernames for a list of OpenML user IDs. + + Parameters + ---------- + uploader_ids : list[str] + List of OpenML user IDs. + + Returns + ------- + dict + A mapping from user ID (str) to username (str). + """ + api_users = "user/list/user_id/" + ",".join(uploader_ids) + user_response = self._http.get(api_users) + xml_content_user = user_response.text + + users = xmltodict.parse(xml_content_user, force_list=("oml:user",)) + return {user["oml:id"]: user["oml:username"] for user in users["oml:users"]["oml:user"]} + + +class EvaluationV2API(ResourceV2API, EvaluationAPI): + """V2 API implementation for evaluations. + Fetches evaluations from the v2 json API endpoint. + """ + + def list( # noqa: PLR0913 + self, + limit: int, # noqa: ARG002 + offset: int, # noqa: ARG002 + *, + function: str, # noqa: ARG002 + tasks: builtins.list | None = None, # noqa: ARG002 + setups: builtins.list | None = None, # noqa: ARG002 + flows: builtins.list | None = None, # noqa: ARG002 + runs: builtins.list | None = None, # noqa: ARG002 + uploaders: builtins.list | None = None, # noqa: ARG002 + study: int | None = None, # noqa: ARG002 + sort_order: str | None = None, # noqa: ARG002 + **kwargs: Any, # noqa: ARG002 + ) -> builtins.list[OpenMLEvaluation]: + """ + Retrieve evaluation results from the OpenML v2 JSON API. + + Notes + ----- + This method is not yet implemented. + """ + self._not_supported(method="list") diff --git a/openml/_api/resources/evaluation_measure.py b/openml/_api/resources/evaluation_measure.py new file mode 100644 index 000000000..e3cf2681f --- /dev/null +++ b/openml/_api/resources/evaluation_measure.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import xmltodict + +from .base import EvaluationMeasureAPI, ResourceV1API, ResourceV2API + + +class EvaluationMeasureV1API(ResourceV1API, EvaluationMeasureAPI): + """V1 API implementation for evaluation measures. + + Fetches evaluation measures from the v1 XML API endpoint. + """ + + def list(self) -> list[str]: + """List all evaluation measures available on OpenML. + + Returns + ------- + list[str] + A list of evaluation measure names. + """ + path = "evaluationmeasure/list" + response = self._http.get(path) + xml_content = response.text + + qualities = xmltodict.parse(xml_content, force_list=("oml:measures")) + # Minimalistic check if the XML is useful + if "oml:evaluation_measures" not in qualities: + raise ValueError('Error in return XML, does not contain "oml:evaluation_measures"') + + if not isinstance( + qualities["oml:evaluation_measures"]["oml:measures"][0]["oml:measure"], list + ): + raise TypeError('Error in return XML, does not contain "oml:measure" as a list') + + return qualities["oml:evaluation_measures"]["oml:measures"][0]["oml:measure"] + + +class EvaluationMeasureV2API(ResourceV2API, EvaluationMeasureAPI): + """V2 API implementation for evaluation measures. + + Fetches evaluation measures from the v2 JSON API endpoint. + """ + + def list(self) -> list[str]: + """List all evaluation measures available on OpenML. + + Returns + ------- + list[str] + A list of evaluation measure names. + """ + path = "evaluationmeasure/list" + response = self._http.get(path) + data = response.json() + + if not isinstance(data, list): + raise ValueError(f"Expected list, got {type(data)}") + + return data diff --git a/openml/_api/resources/flow.py b/openml/_api/resources/flow.py new file mode 100644 index 000000000..d80c14245 --- /dev/null +++ b/openml/_api/resources/flow.py @@ -0,0 +1,292 @@ +from __future__ import annotations + +from typing import Any, NoReturn +from urllib.parse import quote + +import pandas as pd +import xmltodict + +from openml.exceptions import OpenMLServerException, OpenMLServerNoResult +from openml.flows.flow import OpenMLFlow + +from .base import FlowAPI, ResourceV1API, ResourceV2API + + +class FlowV1API(ResourceV1API, FlowAPI): + def get( + self, + flow_id: int, + *, + reset_cache: bool = False, + ) -> OpenMLFlow: + """Get a flow from the OpenML server. + + Parameters + ---------- + flow_id : int + The ID of the flow to retrieve. + reset_cache : bool, optional (default=False) + Whether to reset the cache for this request. + + Returns + ------- + OpenMLFlow + The retrieved flow object. + """ + response = self._http.get( + f"flow/{flow_id}", + enable_cache=True, + refresh_cache=reset_cache, + ) + flow_xml = response.text + return OpenMLFlow._from_dict(xmltodict.parse(flow_xml)) + + def exists(self, name: str, external_version: str) -> int | bool: + """Check if a flow exists on the OpenML server. + + Parameters + ---------- + name : str + The name of the flow. + external_version : str + The external version of the flow. + + Returns + ------- + int | bool + The flow ID if the flow exists, False otherwise. + """ + if not (isinstance(name, str) and len(name) > 0): + raise ValueError("Argument 'name' should be a non-empty string") + if not (isinstance(external_version, str) and len(external_version) > 0): + raise ValueError("Argument 'version' should be a non-empty string") + + data: dict[str, str] = {"name": name, "external_version": external_version} + if self._http.api_key: + data["api_key"] = self._http.api_key + + xml_response = self._http.post("flow/exists", data=data, use_api_key=False).text + result_dict = xmltodict.parse(xml_response) + # Detect error payloads and raise + if "oml:error" in result_dict: + err = result_dict["oml:error"] + code = int(err.get("oml:code", 0)) if "oml:code" in err else None + message = err.get("oml:message", "Server returned an error") + raise OpenMLServerException(message=message, code=code) + + flow_id = int(result_dict["oml:flow_exists"]["oml:id"]) + return flow_id if flow_id > 0 else False + + def list( + self, + limit: int | None = None, + offset: int | None = None, + tag: str | None = None, + uploader: str | None = None, + ) -> pd.DataFrame: + """List flows on the OpenML server. + + Parameters + ---------- + limit : int, optional + The maximum number of flows to return. + By default, all flows are returned. + offset : int, optional + The number of flows to skip before starting to collect the result set. + By default, no flows are skipped. + tag : str, optional + The tag to filter flows by. + By default, no tag filtering is applied. + uploader : str, optional + The user to filter flows by. + By default, no user filtering is applied. + + Returns + ------- + pd.DataFrame + A DataFrame containing the list of flows. + """ + api_call = "flow/list" + if limit is not None: + api_call += f"/limit/{limit}" + if offset is not None: + api_call += f"/offset/{offset}" + if tag is not None: + api_call += f"/tag/{quote(str(tag), safe='')}" + if uploader is not None: + api_call += f"/uploader/{quote(str(uploader), safe='')}" + + response = self._http.get(api_call) + xml_string = response.text + flows_dict = xmltodict.parse(xml_string, force_list=("oml:flow",)) + + if "oml:error" in flows_dict: + err = flows_dict["oml:error"] + code = int(err.get("oml:code", 0)) if "oml:code" in err else None + message = err.get("oml:message", "Server returned an error") + raise OpenMLServerException(message=message, code=code) + + assert isinstance(flows_dict["oml:flows"]["oml:flow"], list), type(flows_dict["oml:flows"]) + assert flows_dict["oml:flows"]["@xmlns:oml"] == "http://openml.org/openml", flows_dict[ + "oml:flows" + ]["@xmlns:oml"] + + flows: dict[int, dict[str, Any]] = {} + for flow_ in flows_dict["oml:flows"]["oml:flow"]: + fid = int(flow_["oml:id"]) + flow_row = { + "id": fid, + "full_name": flow_["oml:full_name"], + "name": flow_["oml:name"], + "version": flow_["oml:version"], + "external_version": flow_["oml:external_version"], + "uploader": flow_["oml:uploader"], + } + flows[fid] = flow_row + + return pd.DataFrame.from_dict(flows, orient="index") + + +class FlowV2API(ResourceV2API, FlowAPI): + def get( + self, + flow_id: int, + *, + reset_cache: bool = False, + ) -> OpenMLFlow: + """Get a flow from the OpenML v2 server. + + Parameters + ---------- + flow_id : int + The ID of the flow to retrieve. + reset_cache : bool, optional (default=False) + Whether to reset the cache for this request. + + Returns + ------- + OpenMLFlow + The retrieved flow object. + """ + response = self._http.get( + f"flows/{flow_id}/", + enable_cache=True, + refresh_cache=reset_cache, + ) + flow_json = response.json() + + # Convert v2 JSON to v1-compatible dict for OpenMLFlow._from_dict() + flow_dict = self._convert_v2_to_v1_format(flow_json) + return OpenMLFlow._from_dict(flow_dict) + + def exists(self, name: str, external_version: str) -> int | bool: + """Check if a flow exists on the OpenML v2 server. + + Parameters + ---------- + name : str + The name of the flow. + external_version : str + The external version of the flow. + + Returns + ------- + int | bool + The flow ID if the flow exists, False otherwise. + """ + if not (isinstance(name, str) and len(name) > 0): + raise ValueError("Argument 'name' should be a non-empty string") + if not (isinstance(external_version, str) and len(external_version) > 0): + raise ValueError("Argument 'version' should be a non-empty string") + + name_path = quote(name, safe="") + version_path = quote(external_version, safe="") + + try: + response = self._http.get(f"flows/exists/{name_path}/{version_path}/") + result = response.json() + flow_id: int | bool = result.get("flow_id", False) + return flow_id + except OpenMLServerNoResult: + return False + except OpenMLServerException as err: + if err.code == 404: + return False + raise + + def list( + self, + limit: int | None = None, # noqa: ARG002 + offset: int | None = None, # noqa: ARG002 + tag: str | None = None, # noqa: ARG002 + uploader: str | None = None, # noqa: ARG002 + ) -> NoReturn: + self._not_supported(method="list") + + @staticmethod + def _convert_v2_to_v1_format(v2_json: dict[str, Any]) -> dict[str, Any]: + """Convert v2 JSON response to v1 XML-dict format for OpenMLFlow._from_dict(). + + Parameters + ---------- + v2_json : dict + The v2 JSON response from the server. + + Returns + ------- + dict + A dictionary matching the v1 XML structure expected by OpenMLFlow._from_dict(). + """ + # Map v2 JSON fields to v1 XML structure with oml: namespace + flow_dict = { + "oml:flow": { + "@xmlns:oml": "http://openml.org/openml", + "oml:id": str(v2_json.get("id", "0")), + "oml:uploader": str(v2_json.get("uploader", "")), + "oml:name": v2_json.get("name", ""), + "oml:version": str(v2_json.get("version", "")), + "oml:external_version": v2_json.get("external_version", ""), + "oml:description": v2_json.get("description", ""), + "oml:upload_date": ( + v2_json.get("upload_date", "").replace("T", " ") + if v2_json.get("upload_date") + else "" + ), + "oml:language": v2_json.get("language", ""), + "oml:dependencies": v2_json.get("dependencies", ""), + } + } + + # Add optional fields + if "class_name" in v2_json: + flow_dict["oml:flow"]["oml:class_name"] = v2_json["class_name"] + if "custom_name" in v2_json: + flow_dict["oml:flow"]["oml:custom_name"] = v2_json["custom_name"] + + # Convert parameters from v2 array to v1 format + if v2_json.get("parameter"): + flow_dict["oml:flow"]["oml:parameter"] = [ + { + "oml:name": param.get("name", ""), + "oml:data_type": param.get("data_type", ""), + "oml:default_value": str(param.get("default_value", "")), + "oml:description": param.get("description", ""), + } + for param in v2_json["parameter"] + ] + + # Convert subflows from v2 to v1 components format + if v2_json.get("subflows"): + flow_dict["oml:flow"]["oml:component"] = [ + { + "oml:identifier": subflow.get("identifier", ""), + "oml:flow": FlowV2API._convert_v2_to_v1_format(subflow["flow"])["oml:flow"], + } + for subflow in v2_json["subflows"] + ] + + # Convert tags from v2 array to v1 format + if v2_json.get("tag"): + flow_dict["oml:flow"]["oml:tag"] = v2_json["tag"] + + return flow_dict diff --git a/openml/_api/resources/run.py b/openml/_api/resources/run.py new file mode 100644 index 000000000..72224f397 --- /dev/null +++ b/openml/_api/resources/run.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any + +import pandas as pd +import xmltodict + +import openml +from openml._api.resources.base import ResourceV1API, ResourceV2API, RunAPI +from openml.tasks.task import TaskType + +if TYPE_CHECKING: + import builtins + + from openml.runs.run import OpenMLRun + + +class RunV1API(ResourceV1API, RunAPI): + def _extract_id_from_upload(self, parsed: Mapping[str, Any]) -> int: + """Extract the run ID from an XML upload response. + + Overrides the base implementation because run upload responses use + ``oml:run_id`` instead of the generic ``oml:id`` field. + + Parameters + ---------- + parsed : Mapping of str to Any + Parsed XML response as returned by ``xmltodict.parse``. + + Returns + ------- + int + Identifier of the newly created run. + """ + # xmltodict always gives exactly one root key + ((_, root_value),) = parsed.items() + + if not isinstance(root_value, Mapping): + raise ValueError("Unexpected XML structure in run upload response") + + # Run responses always use oml:run_id + if "oml:run_id" in root_value: + return int(root_value["oml:run_id"]) + + raise ValueError("No run ID found in upload response") + + def get( + self, + run_id: int, + *, + reset_cache: bool = False, + ) -> OpenMLRun: # type: ignore[override] + """Fetch a single run from the OpenML server. + + Parameters + ---------- + run_id : int + The ID of the run to fetch. + reset_cache : bool, default=False + Whether to reset the cache. + + Returns + ------- + OpenMLRun + The run object with all details populated. + + Raises + ------ + openml.exceptions.OpenMLServerException + If the run does not exist or server error occurs. + """ + path = f"run/{run_id}" + response = self._http.get( + path, + enable_cache=True, + refresh_cache=reset_cache, + ) + xml_content = response.text + return openml.runs.functions._create_run_from_xml(xml_content) + + def list( # type: ignore[valid-type] # noqa: PLR0913 + self, + limit: int, + offset: int, + *, + ids: builtins.list[int] | None = None, + task: builtins.list[int] | None = None, + setup: builtins.list[int] | None = None, + flow: builtins.list[int] | None = None, + uploader: builtins.list[int] | None = None, + study: int | None = None, + tag: str | None = None, + display_errors: bool = False, + task_type: TaskType | int | None = None, + ) -> pd.DataFrame: + """List runs from the OpenML server with optional filtering. + + Parameters + ---------- + limit : int + Maximum number of runs to return. + offset : int + Starting position for pagination. + ids : list of int, optional + List of run IDs to filter by. + task : list of int, optional + List of task IDs to filter by. + setup : list of int, optional + List of setup IDs to filter by. + flow : list of int, optional + List of flow IDs to filter by. + uploader : list of int, optional + List of uploader user IDs to filter by. + study : int, optional + Study ID to filter by. + tag : str, optional + Tag to filter by. + display_errors : bool, default=False + If True, include runs with error messages. + task_type : TaskType or int, optional + Task type ID to filter by. + + Returns + ------- + pd.DataFrame + DataFrame with columns: run_id, task_id, setup_id, flow_id, + uploader, task_type, upload_time, error_message. + + Raises + ------ + ValueError + If the server response is invalid or malformed. + """ + path = self._build_url( + limit=limit, + offset=offset, + ids=ids, + task=task, + setup=setup, + flow=flow, + uploader=uploader, + study=study, + tag=tag, + display_errors=display_errors, + task_type=task_type, + ) + xml_string = self._http.get(path).text + return self._parse_list_xml(xml_string) + + def _build_url( # noqa: PLR0913, C901 + self, + limit: int, + offset: int, + *, + ids: builtins.list[int] | None = None, + task: builtins.list[int] | None = None, + setup: builtins.list[int] | None = None, + flow: builtins.list[int] | None = None, + uploader: builtins.list[int] | None = None, + study: int | None = None, + tag: str | None = None, + display_errors: bool = False, + task_type: TaskType | int | None = None, + ) -> str: + path = "run/list" + if limit is not None: + path += f"/limit/{limit}" + if offset is not None: + path += f"/offset/{offset}" + if ids is not None: + path += f"/run/{','.join([str(int(i)) for i in ids])}" + if task is not None: + path += f"/task/{','.join([str(int(i)) for i in task])}" + if setup is not None: + path += f"/setup/{','.join([str(int(i)) for i in setup])}" + if flow is not None: + path += f"/flow/{','.join([str(int(i)) for i in flow])}" + if uploader is not None: + path += f"/uploader/{','.join([str(int(i)) for i in uploader])}" + if study is not None: + path += f"/study/{study}" + if display_errors: + path += "/show_errors/true" + if tag is not None: + path += f"/tag/{tag}" + if task_type is not None: + tvalue = task_type.value if isinstance(task_type, TaskType) else task_type + path += f"/task_type/{tvalue}" + return path + + def _parse_list_xml(self, xml_string: str) -> pd.DataFrame: + runs_dict = xmltodict.parse(xml_string, force_list=("oml:run",)) + # Minimalistic check if the XML is useful + if "oml:runs" not in runs_dict: + raise ValueError(f'Error in return XML, does not contain "oml:runs": {runs_dict}') + + if "@xmlns:oml" not in runs_dict["oml:runs"]: + raise ValueError( + f'Error in return XML, does not contain "oml:runs"/@xmlns:oml: {runs_dict}' + ) + + if runs_dict["oml:runs"]["@xmlns:oml"] != "http://openml.org/openml": + raise ValueError( + "Error in return XML, value of " + '"oml:runs"/@xmlns:oml is not ' + f'"http://openml.org/openml": {runs_dict}', + ) + + if not isinstance(runs_dict["oml:runs"]["oml:run"], list): + raise TypeError( + "Error in return XML, 'oml:runs/oml:run' is expected to be a list, " + f"but got {type(runs_dict['oml:runs']['oml:run']).__name__}: " + f"{runs_dict['oml:runs']['oml:run']}" + ) + + runs = { + int(r["oml:run_id"]): { + "run_id": int(r["oml:run_id"]), + "task_id": int(r["oml:task_id"]), + "setup_id": int(r["oml:setup_id"]), + "flow_id": int(r["oml:flow_id"]), + "uploader": int(r["oml:uploader"]), + "task_type": TaskType(int(r["oml:task_type_id"])), + "upload_time": str(r["oml:upload_time"]), + "error_message": str((r["oml:error_message"]) or ""), + } + for r in runs_dict["oml:runs"]["oml:run"] + } + return pd.DataFrame.from_dict(runs, orient="index") + + def download_text_file( + self, + source: str, + *, + md5_checksum: str | None = None, + ) -> str: + response = self._http.get( + source, + use_api_key=False, + md5_checksum=md5_checksum, + ) + return response.text + + def file_id_to_url( + self, + file_id: int, + filename: str | None = None, + ) -> str: + server_base = self._http.server.split("/api/", 1)[0].rstrip("/") + url = f"{server_base}/data/download/{file_id}" + if filename is not None: + url += f"/{filename}" + return url + + +class RunV2API(ResourceV2API, RunAPI): + """V2 API resource for runs. Currently read-only until V2 server supports POST.""" + + def get( + self, + run_id: int, # noqa: ARG002 + *, + reset_cache: bool = False, # noqa: ARG002 + ) -> OpenMLRun: # type: ignore[override] + """Fetch a single run from the V2 server. + + Parameters + ---------- + run_id : int + The ID of the run to fetch. + reset_cache : bool, default=False + Whether to reset the cache. + + Returns + ------- + OpenMLRun + The run object. + + Raises + ------ + OpenMLNotSupportedError + V2 server API not yet available for this operation. + """ + self._not_supported(method="get") + + def list( # type: ignore[valid-type] # noqa: PLR0913 + self, + limit: int, # noqa: ARG002 + offset: int, # noqa: ARG002 + *, + ids: builtins.list[int] | None = None, # noqa: ARG002 + task: builtins.list[int] | None = None, # noqa: ARG002 + setup: builtins.list[int] | None = None, # noqa: ARG002 + flow: builtins.list[int] | None = None, # noqa: ARG002 + uploader: builtins.list[int] | None = None, # noqa: ARG002 + study: int | None = None, # noqa: ARG002 + tag: str | None = None, # noqa: ARG002 + display_errors: bool = False, # noqa: ARG002 + task_type: TaskType | int | None = None, # noqa: ARG002 + ) -> pd.DataFrame: + """List runs from the V2 server. + + Raises + ------ + OpenMLNotSupportedError + V2 server API not yet available for this operation. + """ + self._not_supported(method="list") + + def download_text_file( + self, + source: str, # noqa: ARG002 + *, + md5_checksum: str | None = None, # noqa: ARG002 + ) -> str: + self._not_supported(method="download_text_file") + + def file_id_to_url( + self, + file_id: int, # noqa: ARG002 + filename: str | None = None, # noqa: ARG002 + ) -> str: + self._not_supported(method="file_id_to_url") diff --git a/openml/_api/resources/setup.py b/openml/_api/resources/setup.py new file mode 100644 index 000000000..266b50f3e --- /dev/null +++ b/openml/_api/resources/setup.py @@ -0,0 +1,281 @@ +from __future__ import annotations + +from collections import OrderedDict +from typing import TYPE_CHECKING, Any + +import xmltodict + +from openml.setups.setup import OpenMLParameter, OpenMLSetup + +from .base import ResourceV1API, ResourceV2API, SetupAPI + +if TYPE_CHECKING: + import builtins + from collections.abc import Iterable + + from openml.flows.flow import OpenMLFlow + + +class SetupV1API(ResourceV1API, SetupAPI): + """V1 XML API implementation for setups.""" + + @staticmethod + def _build_url( + limit: int, + offset: int, + *, + setup: Iterable[int] | None = None, + flow: int | None = None, + tag: str | None = None, + ) -> str: + """Construct an OpenML Setup API URL with filtering parameters. + + Parameters + ---------- + The setup argument that is a list is separated from the single value + filters which are put into the kwargs. + + limit : int + offset : int + setup : list(int), optional + flow : int, optional + tag : str, optional + + Returns + ------- + str + A relative API path suitable for an OpenML HTTP request. + """ + api_call = "setup/list" + if limit is not None: + api_call += f"/limit/{limit}" + if offset is not None: + api_call += f"/offset/{offset}" + if setup is not None: + api_call += f"/setup/{','.join([str(int(i)) for i in setup])}" + if flow is not None: + api_call += f"/flow/{flow}" + if tag is not None: + api_call += f"/tag/{tag}" + + return api_call + + @staticmethod + def _parse_list_xml(xml_content: str) -> builtins.list[OpenMLSetup]: + """Helper function to parse API calls which are lists of setups""" + setups_dict = xmltodict.parse(xml_content, force_list=("oml:setup",)) + openml_uri = "http://openml.org/openml" + # Minimalistic check if the XML is useful + if "oml:setups" not in setups_dict: + raise ValueError( + f'Error in return XML, does not contain "oml:setups": {setups_dict!s}', + ) + + if "@xmlns:oml" not in setups_dict["oml:setups"]: + raise ValueError( + f'Error in return XML, does not contain "oml:setups"/@xmlns:oml: {setups_dict!s}', + ) + + if setups_dict["oml:setups"]["@xmlns:oml"] != openml_uri: + raise ValueError( + "Error in return XML, value of " + '"oml:seyups"/@xmlns:oml is not ' + f'"{openml_uri}": {setups_dict!s}', + ) + + assert isinstance(setups_dict["oml:setups"]["oml:setup"], list), type( + setups_dict["oml:setups"] + ) + + return [ + SetupV1API._create_setup({"oml:setup_parameters": setup_}) + for setup_ in setups_dict["oml:setups"]["oml:setup"] + ] + + @staticmethod + def _create_setup(result_dict: dict) -> OpenMLSetup: + """Turns an API xml result into a OpenMLSetup object (or dict)""" + setup_id = int(result_dict["oml:setup_parameters"]["oml:setup_id"]) + flow_id = int(result_dict["oml:setup_parameters"]["oml:flow_id"]) + + if "oml:parameter" not in result_dict["oml:setup_parameters"]: + return OpenMLSetup(setup_id, flow_id, parameters=None) + + xml_parameters = result_dict["oml:setup_parameters"]["oml:parameter"] + if isinstance(xml_parameters, dict): + xml_parameters = [xml_parameters] + if not isinstance(xml_parameters, list): + raise ValueError( + f"Expected None, list or dict, received something else: {type(xml_parameters)!s}", + ) + + parameters = { + int(xml_parameter["oml:id"]): SetupV1API._create_setup_parameter_from_xml(xml_parameter) + for xml_parameter in xml_parameters + } + return OpenMLSetup(setup_id, flow_id, parameters) + + @staticmethod + def _create_setup_parameter_from_xml(result_dict: dict[str, str]) -> OpenMLParameter: + """Create an OpenMLParameter object or a dictionary from an API xml result.""" + return OpenMLParameter( + input_id=int(result_dict["oml:id"]), + flow_id=int(result_dict["oml:flow_id"]), + flow_name=result_dict["oml:flow_name"], + full_name=result_dict["oml:full_name"], + parameter_name=result_dict["oml:parameter_name"], + data_type=result_dict["oml:data_type"], + default_value=result_dict["oml:default_value"], + value=result_dict["oml:value"], + ) + + @staticmethod + def _to_dict( + flow_id: int, openml_parameter_settings: builtins.list[dict[str, Any]] + ) -> OrderedDict: + """Convert a flow ID and a list of OpenML parameter settings to + a dictionary representation that can be serialized to XML. + + Parameters + ---------- + flow_id : int + ID of the flow. + openml_parameter_settings : list[dict[str, Any]] + A list of OpenML parameter settings. + + Returns + ------- + OrderedDict + A dictionary representation of the flow ID and parameter settings. + """ + # for convenience, this function (ab)uses the run object. + xml: OrderedDict = OrderedDict() + xml["oml:run"] = OrderedDict() + xml["oml:run"]["@xmlns:oml"] = "http://openml.org/openml" + xml["oml:run"]["oml:flow_id"] = flow_id + xml["oml:run"]["oml:parameter_setting"] = openml_parameter_settings + + return xml + + def list( + self, + limit: int, + offset: int, + *, + setup: Iterable[int] | None = None, + flow: int | None = None, + tag: str | None = None, + ) -> builtins.list[OpenMLSetup]: + """Perform API call `/setup/list/{filters}` + + Parameters + ---------- + The setup argument that is a list is separated from the single value + filters which are put into the kwargs. + + limit : int + offset : int + setup : list(int), optional + flow : int, optional + tag : str, optional + + Returns + ------- + list + setups that match the filters, going from id to the OpenMLSetup object. + """ + api_call = SetupV1API._build_url(limit, offset, setup=setup, flow=flow, tag=tag) + setup_response = self._http.get(api_call) + xml_content = setup_response.text + + return SetupV1API._parse_list_xml(xml_content) + + def get(self, setup_id: int) -> OpenMLSetup: + """ + Downloads the setup (configuration) description from OpenML + and returns a structured object + + Parameters + ---------- + setup_id : int + The Openml setup_id + + Returns + ------- + OpenMLSetup + An initialized OpenMLSetup object parsed from the XML + """ + url_suffix = f"setup/{setup_id}" + setup_response = self._http.get(url_suffix, enable_cache=True) + xml_content = setup_response.text + result_dict = xmltodict.parse(xml_content) + + return SetupV1API._create_setup(result_dict) + + def exists( + self, + flow: OpenMLFlow, + param_settings: builtins.list[dict[str, Any]], + ) -> int | bool: + """ + Checks whether a hyperparameter configuration already exists on the server. + + Parameters + ---------- + flow : OpenMLFlow + The openml flow object. Should have flow id present for the main flow + and all subflows (i.e., it should be downloaded from the server by + means of flow.get, and not instantiated locally) + + list : + A list of dicts, where each dict has the following entries: + oml:name : str: The OpenML parameter name + oml:value : mixed: A representation of the parameter value + oml:component : int: flow id to which the parameter belongs + + Returns + ------- + setup_id : int + setup id iff exists, False otherwise + """ + if flow.flow_id is None: + raise ValueError("Flow must have a flow_id") + description = xmltodict.unparse( + SetupV1API._to_dict(flow.flow_id, param_settings), pretty=True + ) + file_elements = { + "description": ("description.arff", description), + } + + api_call = "setup/exists/" + setup_response = self._http.post(api_call, files=file_elements) + xml_content = setup_response.text + result_dict = xmltodict.parse(xml_content) + + setup_id = int(result_dict["oml:setup_exists"]["oml:id"]) + return setup_id if setup_id > 0 else False + + +class SetupV2API(ResourceV2API, SetupAPI): + """V2 JSoN API implementation for setups.""" + + def list( + self, + limit: int, # noqa: ARG002 + offset: int, # noqa: ARG002 + *, + setup: Iterable[int] | None = None, # noqa: ARG002 + flow: int | None = None, # noqa: ARG002 + tag: str | None = None, # noqa: ARG002 + ) -> builtins.list[OpenMLSetup]: + self._not_supported(method="list") + + def get(self, setup_id: int) -> OpenMLSetup: # noqa: ARG002 + self._not_supported(method="get") + + def exists( + self, + flow: OpenMLFlow, # noqa: ARG002 + param_settings: builtins.list[dict[str, Any]], # noqa: ARG002 + ) -> int | bool: + self._not_supported(method="exists") diff --git a/openml/_api/resources/study.py b/openml/_api/resources/study.py new file mode 100644 index 000000000..28c5ed753 --- /dev/null +++ b/openml/_api/resources/study.py @@ -0,0 +1,165 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pandas as pd +import xmltodict + +from openml._api.resources.base import ResourceV1API, ResourceV2API, StudyAPI + +if TYPE_CHECKING: + import builtins + + +class StudyV1API(ResourceV1API, StudyAPI): + def list( # noqa: PLR0913,PLR0917 + self, + limit: int | None = None, + offset: int | None = None, + status: str | None = None, + main_entity_type: str | None = None, + uploader: builtins.list[int] | None = None, + benchmark_suite: int | None = None, + ) -> pd.DataFrame: + """List studies using V1 API. + + Parameters + ---------- + limit : int, optional + Maximum number of studies to return. + offset : int, optional + Number of studies to skip. + status : str, optional + Filter by status (active, in_preparation, deactivated, all). + main_entity_type : str, optional + Filter by main entity type (run, task). + uploader : list[int], optional + Filter by uploader IDs. + benchmark_suite : int, optional + Filter by benchmark suite ID. + + Returns + ------- + pd.DataFrame + DataFrame containing study information. + """ + api_call = self._build_url( + limit=limit, + offset=offset, + status=status, + main_entity_type=main_entity_type, + uploader=uploader, + benchmark_suite=benchmark_suite, + ) + response = self._http.get(api_call) + xml_string = response.content.decode("utf-8") + return self._parse_list_xml(xml_string) + + @staticmethod + def _build_url( # noqa: PLR0913,PLR0917 + limit: int | None = None, + offset: int | None = None, + status: str | None = None, + main_entity_type: str | None = None, + uploader: builtins.list[int] | None = None, + benchmark_suite: int | None = None, + ) -> str: + """Build the V1 API URL for listing studies. + + Parameters + ---------- + limit : int, optional + Maximum number of studies to return. + offset : int, optional + Number of studies to skip. + status : str, optional + Filter by status (active, in_preparation, deactivated, all). + main_entity_type : str, optional + Filter by main entity type (run, task). + uploader : list[int], optional + Filter by uploader IDs. + benchmark_suite : int, optional + Filter by benchmark suite ID. + + Returns + ------- + str + The API call string with all filters applied. + """ + api_call = "study/list" + + if limit is not None: + api_call += f"/limit/{limit}" + if offset is not None: + api_call += f"/offset/{offset}" + if status is not None: + api_call += f"/status/{status}" + if main_entity_type is not None: + api_call += f"/main_entity_type/{main_entity_type}" + if uploader is not None: + api_call += f"/uploader/{','.join(str(u) for u in uploader)}" + if benchmark_suite is not None: + api_call += f"/benchmark_suite/{benchmark_suite}" + + return api_call + + @staticmethod + def _parse_list_xml(xml_string: str) -> pd.DataFrame: + """Parse the XML response from study list API. + + Parameters + ---------- + xml_string : str + The XML response from the API. + + Returns + ------- + pd.DataFrame + DataFrame containing study information. + """ + study_dict = xmltodict.parse(xml_string, force_list=("oml:study",)) + + # Minimalistic check if the XML is useful + assert isinstance(study_dict["oml:study_list"]["oml:study"], list), type( + study_dict["oml:study_list"], + ) + assert study_dict["oml:study_list"]["@xmlns:oml"] == "http://openml.org/openml", study_dict[ + "oml:study_list" + ]["@xmlns:oml"] + + studies = {} + for study_ in study_dict["oml:study_list"]["oml:study"]: + # maps from xml name to a tuple of (dict name, casting fn) + expected_fields = { + "oml:id": ("id", int), + "oml:alias": ("alias", str), + "oml:main_entity_type": ("main_entity_type", str), + "oml:benchmark_suite": ("benchmark_suite", int), + "oml:name": ("name", str), + "oml:status": ("status", str), + "oml:creation_date": ("creation_date", str), + "oml:creator": ("creator", int), + } + study_id = int(study_["oml:id"]) + current_study = {} + for oml_field_name, (real_field_name, cast_fn) in expected_fields.items(): + if oml_field_name in study_: + current_study[real_field_name] = cast_fn(study_[oml_field_name]) + current_study["id"] = int(current_study["id"]) + studies[study_id] = current_study + + return pd.DataFrame.from_dict(studies, orient="index") + + +class StudyV2API(ResourceV2API, StudyAPI): + def list( # noqa: PLR0913,PLR0917 + self, + limit: int | None = None, # noqa: ARG002 + offset: int | None = None, # noqa: ARG002 + status: str | None = None, # noqa: ARG002 + main_entity_type: str | None = None, # noqa: ARG002 + uploader: builtins.list[int] | None = None, # noqa: ARG002 + benchmark_suite: int | None = None, # noqa: ARG002 + ) -> pd.DataFrame: + """V2 API for listing studies is not yet available.""" + self._not_supported(method="list") diff --git a/openml/_api/resources/task.py b/openml/_api/resources/task.py new file mode 100644 index 000000000..1f62aa3f3 --- /dev/null +++ b/openml/_api/resources/task.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from .base import ResourceV1API, ResourceV2API, TaskAPI + + +class TaskV1API(ResourceV1API, TaskAPI): + """Version 1 API implementation for task resources.""" + + +class TaskV2API(ResourceV2API, TaskAPI): + """Version 2 API implementation for task resources.""" diff --git a/openml/_api/setup/__init__.py b/openml/_api/setup/__init__.py new file mode 100644 index 000000000..80545824f --- /dev/null +++ b/openml/_api/setup/__init__.py @@ -0,0 +1,10 @@ +from .backend import APIBackend +from .builder import APIBackendBuilder + +_backend = APIBackend.get_instance() + +__all__ = [ + "APIBackend", + "APIBackendBuilder", + "_backend", +] diff --git a/openml/_api/setup/backend.py b/openml/_api/setup/backend.py new file mode 100644 index 000000000..1604fd074 --- /dev/null +++ b/openml/_api/setup/backend.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar, cast + +import openml + +from .builder import APIBackendBuilder + +if TYPE_CHECKING: + from openml._api.clients import HTTPClient, MinIOClient + from openml._api.resources import ( + DatasetAPI, + EstimationProcedureAPI, + EvaluationAPI, + EvaluationMeasureAPI, + FlowAPI, + RunAPI, + SetupAPI, + StudyAPI, + TaskAPI, + ) + + +class APIBackend: + """ + Central backend for accessing all OpenML API resource interfaces. + + This class provides a singleton interface to dataset, task, flow, + evaluation, run, setup, study, and other resource APIs. It also + manages configuration through a nested ``Config`` object and + allows dynamic retrieval and updating of configuration values. + + Parameters + ---------- + config : Config, optional + Optional configuration object. If not provided, a default + ``Config`` instance is created. + + Attributes + ---------- + dataset : DatasetAPI + Interface for dataset-related API operations. + task : TaskAPI + Interface for task-related API operations. + evaluation_measure : EvaluationMeasureAPI + Interface for evaluation measure-related API operations. + estimation_procedure : EstimationProcedureAPI + Interface for estimation procedure-related API operations. + evaluation : EvaluationAPI + Interface for evaluation-related API operations. + flow : FlowAPI + Interface for flow-related API operations. + study : StudyAPI + Interface for study-related API operations. + run : RunAPI + Interface for run-related API operations. + setup : SetupAPI + Interface for setup-related API operations. + """ + + _instance: ClassVar[APIBackend | None] = None + _backends: ClassVar[dict[str, APIBackendBuilder]] = {} + + @property + def _backend(self) -> APIBackendBuilder: + api_version = openml.config.api_version + fallback_api_version = openml.config.fallback_api_version + key = f"{api_version}_{fallback_api_version}" + + if key not in self._backends: + _backend = APIBackendBuilder( + api_version=api_version, + fallback_api_version=fallback_api_version, + ) + self._backends[key] = _backend + + return self._backends[key] + + @property + def dataset(self) -> DatasetAPI: + return cast("DatasetAPI", self._backend.dataset) + + @property + def task(self) -> TaskAPI: + return cast("TaskAPI", self._backend.task) + + @property + def evaluation_measure(self) -> EvaluationMeasureAPI: + return cast("EvaluationMeasureAPI", self._backend.evaluation_measure) + + @property + def estimation_procedure(self) -> EstimationProcedureAPI: + return cast("EstimationProcedureAPI", self._backend.estimation_procedure) + + @property + def evaluation(self) -> EvaluationAPI: + return cast("EvaluationAPI", self._backend.evaluation) + + @property + def flow(self) -> FlowAPI: + return cast("FlowAPI", self._backend.flow) + + @property + def study(self) -> StudyAPI: + return cast("StudyAPI", self._backend.study) + + @property + def run(self) -> RunAPI: + return cast("RunAPI", self._backend.run) + + @property + def setup(self) -> SetupAPI: + return cast("SetupAPI", self._backend.setup) + + @property + def http_client(self) -> HTTPClient: + return cast("HTTPClient", self._backend.http_client) + + @property + def fallback_http_client(self) -> HTTPClient | None: + return cast("HTTPClient | None", self._backend.fallback_http_client) + + @property + def minio_client(self) -> MinIOClient: + return cast("MinIOClient", self._backend.minio_client) + + @classmethod + def get_instance(cls) -> APIBackend: + """ + Get the singleton instance of the APIBackend. + + Returns + ------- + APIBackend + Singleton instance of the backend. + """ + if cls._instance is None: + cls._instance = cls() + return cls._instance diff --git a/openml/_api/setup/builder.py b/openml/_api/setup/builder.py new file mode 100644 index 000000000..76d6e0970 --- /dev/null +++ b/openml/_api/setup/builder.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from openml._api.clients import HTTPClient, MinIOClient +from openml._api.resources import ( + API_REGISTRY, + FallbackProxy, +) +from openml.enums import ResourceType + +if TYPE_CHECKING: + from openml._api.resources import ResourceAPI + from openml.enums import APIVersion + + +class APIBackendBuilder: + """ + Builder for constructing API backend instances with all resource-specific APIs. + + This class organizes resource-specific API objects (datasets, tasks, + flows, evaluations, runs, setups, studies, etc.) and provides a + centralized access point for both the primary API version and an + optional fallback API version. + + The constructor automatically initializes: + + - HTTPClient for the primary API version + - Optional HTTPClient for a fallback API version + - MinIOClient for file storage operations + - Resource-specific API instances, optionally wrapped with fallback proxies + + Parameters + ---------- + api_version : APIVersion + The primary API version to use for all resource APIs and HTTP communication. + fallback_api_version : APIVersion | None, default=None + Optional fallback API version to wrap resource APIs with a FallbackProxy. + + Attributes + ---------- + dataset : ResourceAPI | FallbackProxy + API interface for dataset resources. + task : ResourceAPI | FallbackProxy + API interface for task resources. + evaluation_measure : ResourceAPI | FallbackProxy + API interface for evaluation measure resources. + estimation_procedure : ResourceAPI | FallbackProxy + API interface for estimation procedure resources. + evaluation : ResourceAPI | FallbackProxy + API interface for evaluation resources. + flow : ResourceAPI | FallbackProxy + API interface for flow resources. + study : ResourceAPI | FallbackProxy + API interface for study resources. + run : ResourceAPI | FallbackProxy + API interface for run resources. + setup : ResourceAPI | FallbackProxy + API interface for setup resources. + http_client : HTTPClient + Client for HTTP communication using the primary API version. + fallback_http_client : HTTPClient | None + Client for HTTP communication using the fallback API version, if provided. + minio_client : MinIOClient + Client for file storage operations (MinIO/S3). + """ + + dataset: ResourceAPI | FallbackProxy + task: ResourceAPI | FallbackProxy + evaluation_measure: ResourceAPI | FallbackProxy + estimation_procedure: ResourceAPI | FallbackProxy + evaluation: ResourceAPI | FallbackProxy + flow: ResourceAPI | FallbackProxy + study: ResourceAPI | FallbackProxy + run: ResourceAPI | FallbackProxy + setup: ResourceAPI | FallbackProxy + http_client: HTTPClient + fallback_http_client: HTTPClient | None + minio_client: MinIOClient + + def __init__(self, api_version: APIVersion, fallback_api_version: APIVersion | None = None): + # initialize clients and resource APIs in-place + self._build(api_version, fallback_api_version) + + def _build(self, api_version: APIVersion, fallback_api_version: APIVersion | None) -> None: + """ + Construct an APIBackendBuilder instance from a configuration. + + This method initializes HTTP and MinIO clients, creates resource-specific + API instances for the primary API version, and optionally wraps them + with fallback proxies if a fallback API version is configured. + + Parameters + ---------- + config : Config + Configuration object containing API versions, endpoints, cache + settings, and connection parameters. + + Returns + ------- + APIBackendBuilder + Builder instance with all resource API interfaces initialized. + """ + minio_client = MinIOClient() + primary_http_client = HTTPClient(api_version=api_version) + + self.http_client = primary_http_client + self.minio_client = minio_client + self.fallback_http_client = None + + resource_apis: dict[ResourceType, ResourceAPI | FallbackProxy] = {} + for resource_type, resource_api_cls in API_REGISTRY[api_version].items(): + resource_apis[resource_type] = resource_api_cls(primary_http_client, minio_client) + + if fallback_api_version is not None: + fallback_http_client = HTTPClient(api_version=fallback_api_version) + self.fallback_http_client = fallback_http_client + + fallback_resource_apis: dict[ResourceType, ResourceAPI | FallbackProxy] = {} + for resource_type, resource_api_cls in API_REGISTRY[fallback_api_version].items(): + fallback_resource_apis[resource_type] = resource_api_cls( + fallback_http_client, minio_client + ) + + resource_apis = { + name: FallbackProxy(resource_apis[name], fallback_resource_apis[name]) + for name in resource_apis + } + + self.dataset = resource_apis[ResourceType.DATASET] + self.task = resource_apis[ResourceType.TASK] + self.evaluation_measure = resource_apis[ResourceType.EVALUATION_MEASURE] + self.estimation_procedure = resource_apis[ResourceType.ESTIMATION_PROCEDURE] + self.evaluation = resource_apis[ResourceType.EVALUATION] + self.flow = resource_apis[ResourceType.FLOW] + self.study = resource_apis[ResourceType.STUDY] + self.run = resource_apis[ResourceType.RUN] + self.setup = resource_apis[ResourceType.SETUP] diff --git a/openml/_api_calls.py b/openml/_api_calls.py index 81296b3da..aee7c9b10 100644 --- a/openml/_api_calls.py +++ b/openml/_api_calls.py @@ -9,10 +9,10 @@ import shutil import time import urllib.parse -import xml +import xml.parsers.expat import zipfile from pathlib import Path -from typing import Dict, Tuple, Union +from typing import cast import minio import requests @@ -20,11 +20,12 @@ import xmltodict from urllib3 import ProxyManager -from . import config +import openml + from .__version__ import __version__ from .exceptions import ( + OpenMLAuthenticationError, OpenMLHashException, - OpenMLNotAuthorizedError, OpenMLServerError, OpenMLServerException, OpenMLServerNoResult, @@ -33,8 +34,8 @@ _HEADERS = {"user-agent": f"openml-python/{__version__}"} -DATA_TYPE = Dict[str, Union[str, int]] -FILE_ELEMENTS_TYPE = Dict[str, Union[str, Tuple[str, str]]] +DATA_TYPE = dict[str, str | int] +FILE_ELEMENTS_TYPE = dict[str, str | tuple[str, str]] DATABASE_CONNECTION_ERRCODE = 107 API_TOKEN_HELP_LINK = "https://openml.github.io/openml-python/latest/examples/Basics/introduction_tutorial/#authentication" # noqa: S105 @@ -71,7 +72,7 @@ def resolve_env_proxies(url: str) -> str | None: def _create_url_from_endpoint(endpoint: str) -> str: - url = config.server + url = cast("str", openml.config.server) if not url.endswith("/"): url += "/" url += endpoint @@ -133,7 +134,7 @@ def _perform_api_call( def _download_minio_file( source: str, destination: str | Path, - exists_ok: bool = True, # noqa: FBT001, FBT002 + exists_ok: bool = True, # noqa: FBT002 proxy: str | None = "auto", ) -> None: """Download file ``source`` from a MinIO Bucket and store it at ``destination``. @@ -172,7 +173,7 @@ def _download_minio_file( bucket_name=bucket, object_name=object_name, file_path=str(destination), - progress=ProgressBar() if config.show_progress else None, + progress=ProgressBar() if openml.config.show_progress else None, request_headers=_HEADERS, ) if destination.is_file() and destination.suffix == ".zip": @@ -239,7 +240,7 @@ def _download_text_file( source: str, output_path: str | Path | None = None, md5_checksum: str | None = None, - exists_ok: bool = True, # noqa: FBT001, FBT002 + exists_ok: bool = True, # noqa: FBT002 encoding: str = "utf8", ) -> str | None: """Download the text file at `source` and store it in `output_path`. @@ -301,7 +302,8 @@ def _file_id_to_url(file_id: int, filename: str | None = None) -> str: Presents the URL how to download a given file id filename is optional """ - openml_url = config.server.split("/api/") + openml_server = cast("str", openml.config.server) + openml_url = openml_server.split("/api/") url = openml_url[0] + f"/data/download/{file_id!s}" if filename is not None: url += "/" + filename @@ -317,7 +319,7 @@ def _read_url_files( and sending file_elements as files """ data = {} if data is None else data - data["api_key"] = config.apikey + data["api_key"] = openml.config.apikey if file_elements is None: file_elements = {} # Using requests.post sets header 'Accept-encoding' automatically to @@ -337,8 +339,8 @@ def __read_url( md5_checksum: str | None = None, ) -> requests.Response: data = {} if data is None else data - if config.apikey: - data["api_key"] = config.apikey + if openml.config.apikey: + data["api_key"] = openml.config.apikey return _send_request( request_method=request_method, url=url, @@ -363,10 +365,10 @@ def _send_request( # noqa: C901, PLR0912 files: FILE_ELEMENTS_TYPE | None = None, md5_checksum: str | None = None, ) -> requests.Response: - n_retries = max(1, config.connection_n_retries) + n_retries = max(1, openml.config.connection_n_retries) response: requests.Response | None = None - delay_method = _human_delay if config.retry_policy == "human" else _robot_delay + delay_method = _human_delay if openml.config.retry_policy == "human" else _robot_delay # Error to raise in case of retrying too often. Will be set to the last observed exception. retry_raise_e: Exception | None = None @@ -516,11 +518,7 @@ def __parse_server_exception( 400, # run/42 delete 460, # task/42 delete ]: - msg = ( - f"The API call {url} requires authentication via an API key.\nPlease configure " - "OpenML-Python to use your API as described in this example:" - "\nhttps://openml.github.io/openml-python/latest/examples/Basics/introduction_tutorial/#authentication" - ) - return OpenMLNotAuthorizedError(message=msg) + msg = f"The API call {url} requires authentication via an API key." + return OpenMLAuthenticationError(message=msg) return OpenMLServerException(code=code, message=full_message, url=url) diff --git a/openml/_config.py b/openml/_config.py new file mode 100644 index 000000000..73d154acc --- /dev/null +++ b/openml/_config.py @@ -0,0 +1,576 @@ +"""Store module level information like the API key, cache directory and the server""" + +# License: BSD 3-Clause +from __future__ import annotations + +import configparser +import logging +import logging.handlers +import os +import platform +import shutil +import warnings +from contextlib import contextmanager +from copy import deepcopy +from dataclasses import dataclass, field, fields, replace +from io import StringIO +from pathlib import Path +from typing import TYPE_CHECKING, Any, ClassVar, Literal, cast +from urllib.parse import urlparse + +if TYPE_CHECKING: + from collections.abc import Iterator +from openml.enums import APIVersion, ServerMode + +from .__version__ import __version__ + +logger = logging.getLogger(__name__) +openml_logger = logging.getLogger("openml") + + +_PROD_SERVERS: dict[APIVersion, dict[str, str | None]] = { + APIVersion.V1: { + "server": "https://www.openml.org/api/v1/xml/", + "apikey": None, + }, + APIVersion.V2: { + "server": None, + "apikey": None, + }, +} + +_TEST_SERVERS: dict[APIVersion, dict[str, str | None]] = { + APIVersion.V1: { + "server": "https://test.openml.org/api/v1/xml/", + "apikey": "normaluser", + }, + APIVersion.V2: { + "server": None, + "apikey": None, + }, +} + +_TEST_SERVERS_LOCAL: dict[APIVersion, dict[str, str | None]] = { + APIVersion.V1: { + "server": "http://localhost:8000/api/v1/xml/", + "apikey": "normaluser", + }, + APIVersion.V2: { + "server": "http://localhost:8082/", + "apikey": "normaluser", + }, +} + +_SERVERS_REGISTRY: dict[ServerMode, dict[APIVersion, dict[str, str | None]]] = { + ServerMode.PRODUCTION: _PROD_SERVERS, + ServerMode.TEST: ( + _TEST_SERVERS_LOCAL if os.getenv("OPENML_USE_LOCAL_SERVICES") == "true" else _TEST_SERVERS + ), +} + + +def _get_servers(mode: ServerMode) -> dict[APIVersion, dict[str, str | None]]: + if mode not in ServerMode._value2member_map_: + raise ValueError(f'invalid mode="{mode}" allowed modes: {", ".join(list(ServerMode))}') + return deepcopy(_SERVERS_REGISTRY[mode]) + + +def _resolve_default_cache_dir() -> Path: + user_defined_cache_dir = os.environ.get("OPENML_CACHE_DIR") + if user_defined_cache_dir is not None: + return Path(user_defined_cache_dir) + + if platform.system().lower() != "linux": + return Path("~", ".openml").expanduser() + + xdg_cache_home = os.environ.get("XDG_CACHE_HOME") + if xdg_cache_home is None: + return Path("~", ".cache", "openml").expanduser() + + cache_dir = Path(xdg_cache_home) / "openml" + if cache_dir.exists(): + return cache_dir + + heuristic_dir_for_backwards_compat = Path(xdg_cache_home) / "org" / "openml" + if not heuristic_dir_for_backwards_compat.exists(): + return cache_dir + + root_dir_to_delete = Path(xdg_cache_home) / "org" + openml_logger.warning( + "An old cache directory was found at '%s'. This directory is no longer used by " + "OpenML-Python. To silence this warning you would need to delete the old cache " + "directory. The cached files will then be located in '%s'.", + root_dir_to_delete, + cache_dir, + ) + return Path(xdg_cache_home) + + +@dataclass +class OpenMLConfig: + """Dataclass storing the OpenML configuration.""" + + servers: dict[APIVersion, dict[str, str | None]] = field( + default_factory=lambda: _get_servers(ServerMode.PRODUCTION) + ) + api_version: APIVersion = APIVersion.V1 + fallback_api_version: APIVersion | None = None + cachedir: Path = field(default_factory=_resolve_default_cache_dir) + avoid_duplicate_runs: bool = False + retry_policy: Literal["human", "robot"] = "human" + connection_n_retries: int = 5 + show_progress: bool = False + + @property + def server(self) -> str: + server = self.servers[self.api_version]["server"] + if server is None: + servers_repr = {k.value: v for k, v in self.servers.items()} + raise ValueError( + f'server found to be None for api_version="{self.api_version}" in {servers_repr}' + ) + return server + + @server.setter + def server(self, value: str | None) -> None: + self.servers[self.api_version]["server"] = value + + @property + def apikey(self) -> str | None: + return self.servers[self.api_version]["apikey"] + + @apikey.setter + def apikey(self, value: str | None) -> None: + self.servers[self.api_version]["apikey"] = value + + +class OpenMLConfigManager: + """The OpenMLConfigManager manages the configuration of the openml-python package.""" + + def __init__(self) -> None: + self.console_handler: logging.StreamHandler | None = None + self.file_handler: logging.handlers.RotatingFileHandler | None = None + + self.OPENML_CACHE_DIR_ENV_VAR = "OPENML_CACHE_DIR" + self.OPENML_SKIP_PARQUET_ENV_VAR = "OPENML_SKIP_PARQUET" + self.OPENML_TEST_SERVER_ADMIN_KEY_ENV_VAR = "OPENML_TEST_SERVER_ADMIN_KEY" + self._HEADERS: dict[str, str] = {"user-agent": f"openml-python/{__version__}"} + + self._config: OpenMLConfig = OpenMLConfig() + # for legacy test `test_non_writable_home` + self._defaults: dict[str, Any] = OpenMLConfig().__dict__.copy() + self._root_cache_directory: Path = self._config.cachedir + + self.logger = logger + self.openml_logger = openml_logger + + self._examples = ConfigurationForExamples(self) + + self._setup() + + def __getattr__(self, name: str) -> Any: + if hasattr(self._config, name): + return getattr(self._config, name) + raise AttributeError(f"{type(self).__name__!r} object has no attribute {name!r}") + + _FIELDS: ClassVar[set[str]] = {f.name for f in fields(OpenMLConfig)} + + def __setattr__(self, name: str, value: Any) -> None: + # during __init__ before _config exists + if name in { + "_config", + "_root_cache_directory", + "console_handler", + "file_handler", + "logger", + "openml_logger", + "_examples", + "OPENML_CACHE_DIR_ENV_VAR", + "OPENML_SKIP_PARQUET_ENV_VAR", + "_HEADERS", + }: + return object.__setattr__(self, name, value) + + if name in self._FIELDS: + # write into dataclass, not manager (prevents shadowing) + if name == "cachedir": + object.__setattr__(self, "_root_cache_directory", Path(value)) + object.__setattr__(self, "_config", replace(self._config, **{name: value})) + return None + + if name in ["server", "apikey"]: + setattr(self._config, name, value) + return None + + object.__setattr__(self, name, value) + return None + + def _create_log_handlers(self, create_file_handler: bool = True) -> None: # noqa: FBT002 + if self.console_handler is not None or self.file_handler is not None: + self.logger.debug("Requested to create log handlers, but they are already created.") + return + + message_format = "[%(levelname)s] [%(asctime)s:%(name)s] %(message)s" + output_formatter = logging.Formatter(message_format, datefmt="%H:%M:%S") + + self.console_handler = logging.StreamHandler() + self.console_handler.setFormatter(output_formatter) + + if create_file_handler: + one_mb = 2**20 + log_path = self._root_cache_directory / "openml_python.log" + self.file_handler = logging.handlers.RotatingFileHandler( + log_path, + maxBytes=one_mb, + backupCount=1, + delay=True, + ) + self.file_handler.setFormatter(output_formatter) + + def _convert_log_levels(self, log_level: int) -> tuple[int, int]: + openml_to_python = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG} + python_to_openml = { + logging.DEBUG: 2, + logging.INFO: 1, + logging.WARNING: 0, + logging.CRITICAL: 0, + logging.ERROR: 0, + } + openml_level = python_to_openml.get(log_level, log_level) + python_level = openml_to_python.get(log_level, log_level) + return openml_level, python_level + + def _set_level_register_and_store(self, handler: logging.Handler, log_level: int) -> None: + _oml_level, py_level = self._convert_log_levels(log_level) + handler.setLevel(py_level) + + if self.openml_logger.level > py_level or self.openml_logger.level == logging.NOTSET: + self.openml_logger.setLevel(py_level) + + if handler not in self.openml_logger.handlers: + self.openml_logger.addHandler(handler) + + def set_console_log_level(self, console_output_level: int) -> None: + """Set the log level for console output.""" + assert self.console_handler is not None + self._set_level_register_and_store(self.console_handler, console_output_level) + + def set_file_log_level(self, file_output_level: int) -> None: + """Set the log level for file output.""" + assert self.file_handler is not None + self._set_level_register_and_store(self.file_handler, file_output_level) + + def get_server_base_url(self) -> str: + """Get the base URL of the OpenML server (i.e., without /api).""" + domain, _ = self._config.server.split("/api", maxsplit=1) + return domain.replace("api", "www") + + def _get_servers(self, mode: ServerMode) -> dict[APIVersion, dict[str, str | None]]: + return _get_servers(mode) + + def _set_servers(self, mode: ServerMode) -> None: + servers = self._get_servers(mode) + self._config = replace(self._config, servers=servers) + + def get_production_servers(self) -> dict[APIVersion, dict[str, str | None]]: + return self._get_servers(mode=ServerMode.PRODUCTION) + + def get_test_servers(self) -> dict[APIVersion, dict[str, str | None]]: + return self._get_servers(mode=ServerMode.TEST) + + def use_production_servers(self) -> None: + self._set_servers(mode=ServerMode.PRODUCTION) + + def use_test_servers(self) -> None: + self._set_servers(mode=ServerMode.TEST) + + def set_api_version( + self, + api_version: APIVersion, + fallback_api_version: APIVersion | None = None, + ) -> None: + if api_version not in APIVersion._value2member_map_: + raise ValueError( + f'invalid api_version="{api_version}" ' + f"allowed versions: {', '.join(list(APIVersion))}" + ) + + if ( + fallback_api_version is not None + and fallback_api_version not in APIVersion._value2member_map_ + ): + raise ValueError( + f'invalid fallback_api_version="{fallback_api_version}" ' + f"allowed versions: {', '.join(list(APIVersion))}" + ) + + self._config = replace( + self._config, + api_version=api_version, + fallback_api_version=fallback_api_version, + ) + + def set_retry_policy( + self, value: Literal["human", "robot"], n_retries: int | None = None + ) -> None: + """Set the retry policy for server connections.""" + default_retries_by_policy = {"human": 5, "robot": 50} + + if value not in default_retries_by_policy: + raise ValueError( + f"Detected retry_policy '{value}' but must be one of " + f"{list(default_retries_by_policy.keys())}", + ) + if n_retries is not None and not isinstance(n_retries, int): + raise TypeError( + f"`n_retries` must be of type `int` or `None` but is `{type(n_retries)}`." + ) + + if isinstance(n_retries, int) and n_retries < 1: + raise ValueError(f"`n_retries` is '{n_retries}' but must be positive.") + + self._config = replace( + self._config, + retry_policy=value, + connection_n_retries=( + default_retries_by_policy[value] if n_retries is None else n_retries + ), + ) + + def _handle_xdg_config_home_backwards_compatibility(self, xdg_home: str) -> Path: + config_dir = Path(xdg_home) / "openml" + + backwards_compat_config_file = Path(xdg_home) / "config" + if not backwards_compat_config_file.exists(): + return config_dir + + try: + self._parse_config(backwards_compat_config_file) + except Exception: # noqa: BLE001 + return config_dir + + correct_config_location = config_dir / "config" + try: + shutil.copy(backwards_compat_config_file, correct_config_location) + self.openml_logger.warning( + "An openml configuration file was found at the old location " + f"at {backwards_compat_config_file}. We have copied it to the new " + f"location at {correct_config_location}. " + "\nTo silence this warning please verify that the configuration file " + f"at {correct_config_location} is correct and delete the file at " + f"{backwards_compat_config_file}." + ) + return config_dir + except Exception as e: # noqa: BLE001 + self.openml_logger.warning( + "While attempting to perform a backwards compatible fix, we " + f"failed to copy the openml config file at " + f"{backwards_compat_config_file}' to {correct_config_location}" + f"\n{type(e)}: {e}", + "\n\nTo silence this warning, please copy the file " + "to the new location and delete the old file at " + f"{backwards_compat_config_file}.", + ) + return backwards_compat_config_file + + def determine_config_file_path(self) -> Path: + """Determine the path to the openml configuration file.""" + if platform.system().lower() == "linux": + xdg_home = os.environ.get("XDG_CONFIG_HOME") + if xdg_home is not None: + config_dir = self._handle_xdg_config_home_backwards_compatibility(xdg_home) + else: + config_dir = Path("~", ".config", "openml") + else: + config_dir = Path("~") / ".openml" + + config_dir = Path(config_dir).expanduser().resolve() + return config_dir / "config" + + def _parse_config(self, config_file: str | Path) -> dict[str, Any]: + config_file = Path(config_file) + config = configparser.RawConfigParser(defaults=OpenMLConfig().__dict__) # type: ignore + + config_file_ = StringIO() + config_file_.write("[FAKE_SECTION]\n") + try: + with config_file.open("r") as fh: + for line in fh: + config_file_.write(line) + except FileNotFoundError: + self.logger.info( + "No config file found at %s, using default configuration.", config_file + ) + except OSError as e: + self.logger.info("Error opening file %s: %s", config_file, e.args[0]) + config_file_.seek(0) + config.read_file(config_file_) + configuration = dict(config.items("FAKE_SECTION")) + for boolean_field in ["avoid_duplicate_runs", "show_progress"]: + if isinstance(config["FAKE_SECTION"][boolean_field], str): + configuration[boolean_field] = config["FAKE_SECTION"].getboolean(boolean_field) # type: ignore + return configuration # type: ignore + + def start_using_configuration_for_example(self) -> None: + """Sets the configuration to connect to the test server with valid apikey.""" + return self._examples.start_using_configuration_for_example() + + def stop_using_configuration_for_example(self) -> None: + """Store the configuration as it was before `start_use_example_configuration`.""" + return self._examples.stop_using_configuration_for_example() + + def _setup(self, config: dict[str, Any] | None = None) -> None: + config_file = self.determine_config_file_path() + config_dir = config_file.parent + + try: + if not config_dir.exists(): + config_dir.mkdir(exist_ok=True, parents=True) + except PermissionError: + self.openml_logger.warning( + f"No permission to create OpenML directory at {config_dir}!" + " This can result in OpenML-Python not working properly." + ) + + if config is None: + config = self._parse_config(config_file) + + self._config = replace( + self._config, + servers=config["servers"], + api_version=config["api_version"], + fallback_api_version=config["fallback_api_version"], + show_progress=config["show_progress"], + avoid_duplicate_runs=config["avoid_duplicate_runs"], + retry_policy=config["retry_policy"], + connection_n_retries=int(config["connection_n_retries"]), + ) + if "server" in config: + self._config.server = config["server"] + if "apikey" in config: + self._config.apikey = config["apikey"] + + user_defined_cache_dir = os.environ.get(self.OPENML_CACHE_DIR_ENV_VAR) + if user_defined_cache_dir is not None: + short_cache_dir = Path(user_defined_cache_dir) + else: + short_cache_dir = Path(config["cachedir"]) + + self._root_cache_directory = short_cache_dir.expanduser().resolve() + self._config = replace(self._config, cachedir=self._root_cache_directory) + + try: + cache_exists = self._root_cache_directory.exists() + if not cache_exists: + self._root_cache_directory.mkdir(exist_ok=True, parents=True) + self._create_log_handlers() + except PermissionError: + self.openml_logger.warning( + f"No permission to create OpenML directory at {self._root_cache_directory}!" + " This can result in OpenML-Python not working properly." + ) + self._create_log_handlers(create_file_handler=False) + + def set_field_in_config_file(self, field: str, value: Any) -> None: + """Set a field in the configuration file.""" + if not hasattr(OpenMLConfig(), field): + raise ValueError( + f"Field '{field}' is not valid and must be one of " + f"'{OpenMLConfig().__dict__.keys()}'." + ) + + self._config = replace(self._config, **{field: value}) + config_file = self.determine_config_file_path() + existing = self._parse_config(config_file) + with config_file.open("w") as fh: + for f in OpenMLConfig().__dict__: + v = value if f == field else existing.get(f) + if v is not None: + fh.write(f"{f} = {v}\n") + + def get_config_as_dict(self) -> dict[str, Any]: + """Get the current configuration as a dictionary.""" + return self._config.__dict__.copy() + + def get_cache_directory(self) -> str: + """Get the cache directory for the current server.""" + url_suffix = urlparse(self._config.server).netloc + url_parts = url_suffix.replace(":", "_").split(".")[::-1] + reversed_url_suffix = os.sep.join(url_parts) # noqa: PTH118 + return os.path.join(self._root_cache_directory, reversed_url_suffix) # noqa: PTH118 + + def set_root_cache_directory(self, root_cache_directory: str | Path) -> None: + """Set the root cache directory.""" + self._root_cache_directory = Path(root_cache_directory) + self._config = replace(self._config, cachedir=self._root_cache_directory) + + @contextmanager + def overwrite_config_context(self, config: dict[str, Any]) -> Iterator[dict[str, Any]]: + """Overwrite the current configuration within a context manager.""" + existing_config = self.get_config_as_dict() + merged_config = {**existing_config, **config} + + self._setup(merged_config) + yield merged_config + self._setup(existing_config) + + +class ConfigurationForExamples: + """Allows easy switching to and from a test configuration, used for examples.""" + + _last_used_servers = None + _start_last_called = False + + def __init__(self, manager: OpenMLConfigManager): + self._manager = manager + self._test_servers = manager.get_test_servers() + + def start_using_configuration_for_example(self) -> None: + """Sets the configuration to connect to the test server with valid apikey. + + To configuration as was before this call is stored, and can be recovered + by using the `stop_use_example_configuration` method. + """ + if self._start_last_called and self._manager._config.servers == self._test_servers: + # Method is called more than once in a row without modifying the server or apikey. + # We don't want to save the current test configuration as a last used configuration. + return + + self._last_used_servers = self._manager._config.servers + type(self)._start_last_called = True + + # Test server key for examples + self._manager._config = replace( + self._manager._config, + servers=self._test_servers, + ) + test_server = self._test_servers[self._manager._config.api_version]["server"] + warnings.warn( + f"Switching to the test server {test_server} to not upload results to " + "the live server. Using the test server may result in reduced performance of the " + "API!", + stacklevel=2, + ) + + def stop_using_configuration_for_example(self) -> None: + """Return to configuration as it was before `start_use_example_configuration`.""" + if not type(self)._start_last_called: + # We don't want to allow this because it will (likely) result in the `server` and + # `apikey` variables being set to None. + raise RuntimeError( + "`stop_use_example_configuration` called without a saved config." + "`start_use_example_configuration` must be called first.", + ) + + self._manager._config = replace( + self._manager._config, + servers=cast("dict[APIVersion, dict[str, str | None]]", self._last_used_servers), + ) + type(self)._start_last_called = False + + +__config = OpenMLConfigManager() + + +def __getattr__(name: str) -> Any: + return getattr(__config, name) diff --git a/openml/base.py b/openml/base.py index fbfb9dfc8..76fe78aff 100644 --- a/openml/base.py +++ b/openml/base.py @@ -1,25 +1,24 @@ # License: BSD 3-Clause from __future__ import annotations -import re import webbrowser from abc import ABC, abstractmethod -from typing import Iterable, Sequence +from typing import TYPE_CHECKING import xmltodict +import openml import openml._api_calls -import openml.config +from openml.utils import ReprMixin from .utils import _get_rest_api_type_alias, _tag_openml_base +if TYPE_CHECKING: + from collections.abc import Sequence -class OpenMLBase(ABC): - """Base object for functionality that is shared across entities.""" - def __repr__(self) -> str: - body_fields = self._get_repr_body_fields() - return self._apply_repr_template(body_fields) +class OpenMLBase(ReprMixin, ABC): + """Base object for functionality that is shared across entities.""" @property @abstractmethod @@ -60,34 +59,6 @@ def _get_repr_body_fields(self) -> Sequence[tuple[str, str | int | list[str] | N """ # Should be implemented in the base class. - def _apply_repr_template( - self, - body_fields: Iterable[tuple[str, str | int | list[str] | None]], - ) -> str: - """Generates the header and formats the body for string representation of the object. - - Parameters - ---------- - body_fields: List[Tuple[str, str]] - A list of (name, value) pairs to display in the body of the __repr__. - """ - # We add spaces between capitals, e.g. ClassificationTask -> Classification Task - name_with_spaces = re.sub( - r"(\w)([A-Z])", - r"\1 \2", - self.__class__.__name__[len("OpenML") :], - ) - header_text = f"OpenML {name_with_spaces}" - header = f"{header_text}\n{'=' * len(header_text)}\n" - - _body_fields: list[tuple[str, str | int | list[str]]] = [ - (k, "None" if v is None else v) for k, v in body_fields - ] - longest_field_name_length = max(len(name) for name, _ in _body_fields) - field_line_format = f"{{:.<{longest_field_name_length}}}: {{}}" - body = "\n".join(field_line_format.format(name, value) for name, value in _body_fields) - return header + body - @abstractmethod def _to_dict(self) -> dict[str, dict]: """Creates a dictionary representation of self. diff --git a/openml/cli.py b/openml/cli.py index d0a46e498..0f277865b 100644 --- a/openml/cli.py +++ b/openml/cli.py @@ -5,11 +5,18 @@ import argparse import string import sys +from collections.abc import Callable +from dataclasses import fields from pathlib import Path -from typing import Callable +from typing import TYPE_CHECKING, cast from urllib.parse import urlparse -from openml import config +import openml +from openml.__version__ import __version__ +from openml.enums import APIVersion + +if TYPE_CHECKING: + from collections.abc import Callable def is_hex(string_: str) -> bool: @@ -58,17 +65,17 @@ def wait_until_valid_input( def print_configuration() -> None: - file = config.determine_config_file_path() + file = openml.config.determine_config_file_path() header = f"File '{file}' contains (or defaults to):" print(header) - max_key_length = max(map(len, config.get_config_as_dict())) - for field, value in config.get_config_as_dict().items(): + max_key_length = max(map(len, openml.config.get_config_as_dict())) + for field, value in openml.config.get_config_as_dict().items(): print(f"{field.ljust(max_key_length)}: {value}") def verbose_set(field: str, value: str) -> None: - config.set_field_in_config_file(field, value) + openml.config.set_field_in_config_file(field, value) print(f"{field} set to '{value}'.") @@ -81,7 +88,7 @@ def check_apikey(apikey: str) -> str: return "" instructions = ( - f"Your current API key is set to: '{config.apikey}'. " + f"Your current API key is set to: '{openml.config.apikey}'. " "You can get an API key at https://new.openml.org. " "You must create an account if you don't have one yet:\n" " 1. Log in with the account.\n" @@ -101,16 +108,16 @@ def check_apikey(apikey: str) -> str: def configure_server(value: str) -> None: def check_server(server: str) -> str: - is_shorthand = server in ["test", "production"] + is_shorthand = server in ["test", "production_server"] if is_shorthand or looks_like_url(server): return "" - return "Must be 'test', 'production' or a url." + return "Must be 'test', 'production_server' or a url." def replace_shorthand(server: str) -> str: if server == "test": - return "https://test.openml.org/api/v1/xml" - if server == "production": - return "https://www.openml.org/api/v1/xml" + return cast("str", openml.config.get_test_servers()[APIVersion.V1]["server"]) + if server == "production_server": + return cast("str", openml.config.get_production_servers()[APIVersion.V1]["server"]) return server configure_field( @@ -118,7 +125,7 @@ def replace_shorthand(server: str) -> str: value=value, check_with_message=check_server, intro_message="Specify which server you wish to connect to.", - input_message="Specify a url or use 'test' or 'production' as a shorthand: ", + input_message="Specify a url or use 'test' or 'production_server' as a shorthand: ", sanitize=replace_shorthand, ) @@ -248,9 +255,9 @@ def autocomplete_policy(policy: str) -> str: ) -def configure_field( # noqa: PLR0913 +def configure_field( # noqa: PLR0913,PLR0917 field: str, - value: None | str, + value: str | None, check_with_message: Callable[[str], str], intro_message: str, input_message: str, @@ -331,6 +338,13 @@ def main() -> None: subroutines = {"configure": configure} parser = argparse.ArgumentParser() + # Add a global --version flag to display installed version and exit + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {__version__}", + help="Show the OpenML version and exit", + ) subparsers = parser.add_subparsers(dest="subroutine") parser_configure = subparsers.add_parser( @@ -339,7 +353,9 @@ def main() -> None: "'https://openml.github.io/openml-python/main/usage.html#configuration'.", ) - configurable_fields = [f for f in config._defaults if f not in ["max_retries"]] + configurable_fields = [ + f.name for f in fields(openml._config.OpenMLConfig) if f.name not in ["max_retries"] + ] parser_configure.add_argument( "field", diff --git a/openml/config.py b/openml/config.py deleted file mode 100644 index 3dde45bdd..000000000 --- a/openml/config.py +++ /dev/null @@ -1,523 +0,0 @@ -"""Store module level information like the API key, cache directory and the server""" - -# License: BSD 3-Clause -from __future__ import annotations - -import configparser -import logging -import logging.handlers -import os -import platform -import shutil -import warnings -from contextlib import contextmanager -from io import StringIO -from pathlib import Path -from typing import Any, Iterator, cast -from typing_extensions import Literal, TypedDict -from urllib.parse import urlparse - -logger = logging.getLogger(__name__) -openml_logger = logging.getLogger("openml") -console_handler: logging.StreamHandler | None = None -file_handler: logging.handlers.RotatingFileHandler | None = None - -OPENML_CACHE_DIR_ENV_VAR = "OPENML_CACHE_DIR" -OPENML_SKIP_PARQUET_ENV_VAR = "OPENML_SKIP_PARQUET" - - -class _Config(TypedDict): - apikey: str - server: str - cachedir: Path - avoid_duplicate_runs: bool - retry_policy: Literal["human", "robot"] - connection_n_retries: int - show_progress: bool - - -def _create_log_handlers(create_file_handler: bool = True) -> None: # noqa: FBT001, FBT002 - """Creates but does not attach the log handlers.""" - global console_handler, file_handler # noqa: PLW0603 - if console_handler is not None or file_handler is not None: - logger.debug("Requested to create log handlers, but they are already created.") - return - - message_format = "[%(levelname)s] [%(asctime)s:%(name)s] %(message)s" - output_formatter = logging.Formatter(message_format, datefmt="%H:%M:%S") - - console_handler = logging.StreamHandler() - console_handler.setFormatter(output_formatter) - - if create_file_handler: - one_mb = 2**20 - log_path = _root_cache_directory / "openml_python.log" - file_handler = logging.handlers.RotatingFileHandler( - log_path, - maxBytes=one_mb, - backupCount=1, - delay=True, - ) - file_handler.setFormatter(output_formatter) - - -def _convert_log_levels(log_level: int) -> tuple[int, int]: - """Converts a log level that's either defined by OpenML/Python to both specifications.""" - # OpenML verbosity level don't match Python values directly: - openml_to_python = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG} - python_to_openml = { - logging.DEBUG: 2, - logging.INFO: 1, - logging.WARNING: 0, - logging.CRITICAL: 0, - logging.ERROR: 0, - } - # Because the dictionaries share no keys, we use `get` to convert as necessary: - openml_level = python_to_openml.get(log_level, log_level) - python_level = openml_to_python.get(log_level, log_level) - return openml_level, python_level - - -def _set_level_register_and_store(handler: logging.Handler, log_level: int) -> None: - """Set handler log level, register it if needed, save setting to config file if specified.""" - _oml_level, py_level = _convert_log_levels(log_level) - handler.setLevel(py_level) - - if openml_logger.level > py_level or openml_logger.level == logging.NOTSET: - openml_logger.setLevel(py_level) - - if handler not in openml_logger.handlers: - openml_logger.addHandler(handler) - - -def set_console_log_level(console_output_level: int) -> None: - """Set console output to the desired level and register it with openml logger if needed.""" - global console_handler # noqa: PLW0602 - assert console_handler is not None - _set_level_register_and_store(console_handler, console_output_level) - - -def set_file_log_level(file_output_level: int) -> None: - """Set file output to the desired level and register it with openml logger if needed.""" - global file_handler # noqa: PLW0602 - assert file_handler is not None - _set_level_register_and_store(file_handler, file_output_level) - - -# Default values (see also https://github.com/openml/OpenML/wiki/Client-API-Standards) -_user_path = Path("~").expanduser().absolute() - - -def _resolve_default_cache_dir() -> Path: - user_defined_cache_dir = os.environ.get(OPENML_CACHE_DIR_ENV_VAR) - if user_defined_cache_dir is not None: - return Path(user_defined_cache_dir) - - if platform.system().lower() != "linux": - return _user_path / ".openml" - - xdg_cache_home = os.environ.get("XDG_CACHE_HOME") - if xdg_cache_home is None: - return Path("~", ".cache", "openml") - - # This is the proper XDG_CACHE_HOME directory, but - # we unfortunately had a problem where we used XDG_CACHE_HOME/org, - # we check heuristically if this old directory still exists and issue - # a warning if it does. There's too much data to move to do this for the user. - - # The new cache directory exists - cache_dir = Path(xdg_cache_home) / "openml" - if cache_dir.exists(): - return cache_dir - - # The old cache directory *does not* exist - heuristic_dir_for_backwards_compat = Path(xdg_cache_home) / "org" / "openml" - if not heuristic_dir_for_backwards_compat.exists(): - return cache_dir - - root_dir_to_delete = Path(xdg_cache_home) / "org" - openml_logger.warning( - "An old cache directory was found at '%s'. This directory is no longer used by " - "OpenML-Python. To silence this warning you would need to delete the old cache " - "directory. The cached files will then be located in '%s'.", - root_dir_to_delete, - cache_dir, - ) - return Path(xdg_cache_home) - - -_defaults: _Config = { - "apikey": "", - "server": "https://www.openml.org/api/v1/xml", - "cachedir": _resolve_default_cache_dir(), - "avoid_duplicate_runs": False, - "retry_policy": "human", - "connection_n_retries": 5, - "show_progress": False, -} - -# Default values are actually added here in the _setup() function which is -# called at the end of this module -server = _defaults["server"] - - -def get_server_base_url() -> str: - """Return the base URL of the currently configured server. - - Turns ``"https://api.openml.org/api/v1/xml"`` in ``"https://www.openml.org/"`` - and ``"https://test.openml.org/api/v1/xml"`` in ``"https://test.openml.org/"`` - - Returns - ------- - str - """ - domain, path = server.split("/api", maxsplit=1) - return domain.replace("api", "www") - - -apikey: str = _defaults["apikey"] -show_progress: bool = _defaults["show_progress"] -# The current cache directory (without the server name) -_root_cache_directory: Path = Path(_defaults["cachedir"]) -avoid_duplicate_runs = _defaults["avoid_duplicate_runs"] - -retry_policy: Literal["human", "robot"] = _defaults["retry_policy"] -connection_n_retries: int = _defaults["connection_n_retries"] - - -def set_retry_policy(value: Literal["human", "robot"], n_retries: int | None = None) -> None: - global retry_policy # noqa: PLW0603 - global connection_n_retries # noqa: PLW0603 - default_retries_by_policy = {"human": 5, "robot": 50} - - if value not in default_retries_by_policy: - raise ValueError( - f"Detected retry_policy '{value}' but must be one of " - f"{list(default_retries_by_policy.keys())}", - ) - if n_retries is not None and not isinstance(n_retries, int): - raise TypeError(f"`n_retries` must be of type `int` or `None` but is `{type(n_retries)}`.") - - if isinstance(n_retries, int) and n_retries < 1: - raise ValueError(f"`n_retries` is '{n_retries}' but must be positive.") - - retry_policy = value - connection_n_retries = default_retries_by_policy[value] if n_retries is None else n_retries - - -class ConfigurationForExamples: - """Allows easy switching to and from a test configuration, used for examples.""" - - _last_used_server = None - _last_used_key = None - _start_last_called = False - _test_server = "https://test.openml.org/api/v1/xml" - _test_apikey = "c0c42819af31e706efe1f4b88c23c6c1" - - @classmethod - def start_using_configuration_for_example(cls) -> None: - """Sets the configuration to connect to the test server with valid apikey. - - To configuration as was before this call is stored, and can be recovered - by using the `stop_use_example_configuration` method. - """ - global server # noqa: PLW0603 - global apikey # noqa: PLW0603 - - if cls._start_last_called and server == cls._test_server and apikey == cls._test_apikey: - # Method is called more than once in a row without modifying the server or apikey. - # We don't want to save the current test configuration as a last used configuration. - return - - cls._last_used_server = server - cls._last_used_key = apikey - cls._start_last_called = True - - # Test server key for examples - server = cls._test_server - apikey = cls._test_apikey - warnings.warn( - f"Switching to the test server {server} to not upload results to the live server. " - "Using the test server may result in reduced performance of the API!", - stacklevel=2, - ) - - @classmethod - def stop_using_configuration_for_example(cls) -> None: - """Return to configuration as it was before `start_use_example_configuration`.""" - if not cls._start_last_called: - # We don't want to allow this because it will (likely) result in the `server` and - # `apikey` variables being set to None. - raise RuntimeError( - "`stop_use_example_configuration` called without a saved config." - "`start_use_example_configuration` must be called first.", - ) - - global server # noqa: PLW0603 - global apikey # noqa: PLW0603 - - server = cast(str, cls._last_used_server) - apikey = cast(str, cls._last_used_key) - cls._start_last_called = False - - -def _handle_xdg_config_home_backwards_compatibility( - xdg_home: str, -) -> Path: - # NOTE(eddiebergman): A previous bug results in the config - # file being located at `${XDG_CONFIG_HOME}/config` instead - # of `${XDG_CONFIG_HOME}/openml/config`. As to maintain backwards - # compatibility, where users may already may have had a configuration, - # we copy it over an issue a warning until it's deleted. - # As a heurisitic to ensure that it's "our" config file, we try parse it first. - config_dir = Path(xdg_home) / "openml" - - backwards_compat_config_file = Path(xdg_home) / "config" - if not backwards_compat_config_file.exists(): - return config_dir - - # If it errors, that's a good sign it's not ours and we can - # safely ignore it, jumping out of this block. This is a heurisitc - try: - _parse_config(backwards_compat_config_file) - except Exception: # noqa: BLE001 - return config_dir - - # Looks like it's ours, lets try copy it to the correct place - correct_config_location = config_dir / "config" - try: - # We copy and return the new copied location - shutil.copy(backwards_compat_config_file, correct_config_location) - openml_logger.warning( - "An openml configuration file was found at the old location " - f"at {backwards_compat_config_file}. We have copied it to the new " - f"location at {correct_config_location}. " - "\nTo silence this warning please verify that the configuration file " - f"at {correct_config_location} is correct and delete the file at " - f"{backwards_compat_config_file}." - ) - return config_dir - except Exception as e: # noqa: BLE001 - # We failed to copy and its ours, return the old one. - openml_logger.warning( - "While attempting to perform a backwards compatible fix, we " - f"failed to copy the openml config file at " - f"{backwards_compat_config_file}' to {correct_config_location}" - f"\n{type(e)}: {e}", - "\n\nTo silence this warning, please copy the file " - "to the new location and delete the old file at " - f"{backwards_compat_config_file}.", - ) - return backwards_compat_config_file - - -def determine_config_file_path() -> Path: - if platform.system().lower() == "linux": - xdg_home = os.environ.get("XDG_CONFIG_HOME") - if xdg_home is not None: - config_dir = _handle_xdg_config_home_backwards_compatibility(xdg_home) - else: - config_dir = Path("~", ".config", "openml") - else: - config_dir = Path("~") / ".openml" - - # Still use os.path.expanduser to trigger the mock in the unit test - config_dir = Path(config_dir).expanduser().resolve() - return config_dir / "config" - - -def _setup(config: _Config | None = None) -> None: - """Setup openml package. Called on first import. - - Reads the config file and sets up apikey, server, cache appropriately. - key and server can be set by the user simply using - openml.config.apikey = THEIRKEY - openml.config.server = SOMESERVER - We could also make it a property but that's less clear. - """ - global apikey # noqa: PLW0603 - global server # noqa: PLW0603 - global _root_cache_directory # noqa: PLW0603 - global avoid_duplicate_runs # noqa: PLW0603 - global show_progress # noqa: PLW0603 - - config_file = determine_config_file_path() - config_dir = config_file.parent - - # read config file, create directory for config file - try: - if not config_dir.exists(): - config_dir.mkdir(exist_ok=True, parents=True) - except PermissionError: - openml_logger.warning( - f"No permission to create OpenML directory at {config_dir}!" - " This can result in OpenML-Python not working properly." - ) - - if config is None: - config = _parse_config(config_file) - - avoid_duplicate_runs = config["avoid_duplicate_runs"] - apikey = config["apikey"] - server = config["server"] - show_progress = config["show_progress"] - n_retries = int(config["connection_n_retries"]) - - set_retry_policy(config["retry_policy"], n_retries) - - user_defined_cache_dir = os.environ.get(OPENML_CACHE_DIR_ENV_VAR) - if user_defined_cache_dir is not None: - short_cache_dir = Path(user_defined_cache_dir) - else: - short_cache_dir = Path(config["cachedir"]) - _root_cache_directory = short_cache_dir.expanduser().resolve() - - try: - cache_exists = _root_cache_directory.exists() - # create the cache subdirectory - if not cache_exists: - _root_cache_directory.mkdir(exist_ok=True, parents=True) - _create_log_handlers() - except PermissionError: - openml_logger.warning( - f"No permission to create OpenML directory at {_root_cache_directory}!" - " This can result in OpenML-Python not working properly." - ) - _create_log_handlers(create_file_handler=False) - - -def set_field_in_config_file(field: str, value: Any) -> None: - """Overwrites the `field` in the configuration file with the new `value`.""" - if field not in _defaults: - raise ValueError(f"Field '{field}' is not valid and must be one of '{_defaults.keys()}'.") - - # TODO(eddiebergman): This use of globals has gone too far - globals()[field] = value - config_file = determine_config_file_path() - config = _parse_config(config_file) - with config_file.open("w") as fh: - for f in _defaults: - # We can't blindly set all values based on globals() because when the user - # sets it through config.FIELD it should not be stored to file. - # There doesn't seem to be a way to avoid writing defaults to file with configparser, - # because it is impossible to distinguish from an explicitly set value that matches - # the default value, to one that was set to its default because it was omitted. - value = globals()[f] if f == field else config.get(f) # type: ignore - if value is not None: - fh.write(f"{f} = {value}\n") - - -def _parse_config(config_file: str | Path) -> _Config: - """Parse the config file, set up defaults.""" - config_file = Path(config_file) - config = configparser.RawConfigParser(defaults=_defaults) # type: ignore - - # The ConfigParser requires a [SECTION_HEADER], which we do not expect in our config file. - # Cheat the ConfigParser module by adding a fake section header - config_file_ = StringIO() - config_file_.write("[FAKE_SECTION]\n") - try: - with config_file.open("r") as fh: - for line in fh: - config_file_.write(line) - except FileNotFoundError: - logger.info("No config file found at %s, using default configuration.", config_file) - except OSError as e: - logger.info("Error opening file %s: %s", config_file, e.args[0]) - config_file_.seek(0) - config.read_file(config_file_) - configuration = dict(config.items("FAKE_SECTION")) - for boolean_field in ["avoid_duplicate_runs", "show_progress"]: - if isinstance(config["FAKE_SECTION"][boolean_field], str): - configuration[boolean_field] = config["FAKE_SECTION"].getboolean(boolean_field) # type: ignore - return configuration # type: ignore - - -def get_config_as_dict() -> _Config: - return { - "apikey": apikey, - "server": server, - "cachedir": _root_cache_directory, - "avoid_duplicate_runs": avoid_duplicate_runs, - "connection_n_retries": connection_n_retries, - "retry_policy": retry_policy, - "show_progress": show_progress, - } - - -# NOTE: For backwards compatibility, we keep the `str` -def get_cache_directory() -> str: - """Get the current cache directory. - - This gets the cache directory for the current server relative - to the root cache directory that can be set via - ``set_root_cache_directory()``. The cache directory is the - ``root_cache_directory`` with additional information on which - subdirectory to use based on the server name. By default it is - ``root_cache_directory / org / openml / www`` for the standard - OpenML.org server and is defined as - ``root_cache_directory / top-level domain / second-level domain / - hostname`` - ``` - - Returns - ------- - cachedir : string - The current cache directory. - - """ - url_suffix = urlparse(server).netloc - reversed_url_suffix = os.sep.join(url_suffix.split(".")[::-1]) # noqa: PTH118 - return os.path.join(_root_cache_directory, reversed_url_suffix) # noqa: PTH118 - - -def set_root_cache_directory(root_cache_directory: str | Path) -> None: - """Set module-wide base cache directory. - - Sets the root cache directory, wherin the cache directories are - created to store content from different OpenML servers. For example, - by default, cached data for the standard OpenML.org server is stored - at ``root_cache_directory / org / openml / www``, and the general - pattern is ``root_cache_directory / top-level domain / second-level - domain / hostname``. - - Parameters - ---------- - root_cache_directory : string - Path to use as cache directory. - - See Also - -------- - get_cache_directory - """ - global _root_cache_directory # noqa: PLW0603 - _root_cache_directory = Path(root_cache_directory) - - -start_using_configuration_for_example = ( - ConfigurationForExamples.start_using_configuration_for_example -) -stop_using_configuration_for_example = ConfigurationForExamples.stop_using_configuration_for_example - - -@contextmanager -def overwrite_config_context(config: dict[str, Any]) -> Iterator[_Config]: - """A context manager to temporarily override variables in the configuration.""" - existing_config = get_config_as_dict() - merged_config = {**existing_config, **config} - - _setup(merged_config) # type: ignore - yield merged_config # type: ignore - - _setup(existing_config) - - -__all__ = [ - "get_cache_directory", - "set_root_cache_directory", - "start_using_configuration_for_example", - "stop_using_configuration_for_example", - "get_config_as_dict", -] - -_setup() diff --git a/openml/datasets/__init__.py b/openml/datasets/__init__.py index 480dd9576..eb0932652 100644 --- a/openml/datasets/__init__.py +++ b/openml/datasets/__init__.py @@ -17,17 +17,17 @@ ) __all__ = [ + "OpenMLDataFeature", + "OpenMLDataset", "attributes_arff_from_df", "check_datasets_active", "create_dataset", + "delete_dataset", + "edit_dataset", + "fork_dataset", "get_dataset", "get_datasets", "list_datasets", - "OpenMLDataset", - "OpenMLDataFeature", - "status_update", "list_qualities", - "edit_dataset", - "fork_dataset", - "delete_dataset", + "status_update", ] diff --git a/openml/datasets/data_feature.py b/openml/datasets/data_feature.py index 218b0066d..9ee6031eb 100644 --- a/openml/datasets/data_feature.py +++ b/openml/datasets/data_feature.py @@ -1,13 +1,17 @@ # License: BSD 3-Clause from __future__ import annotations -from typing import TYPE_CHECKING, Any, ClassVar, Sequence +from typing import TYPE_CHECKING, Any, ClassVar if TYPE_CHECKING: + from collections.abc import Sequence + from IPython.lib import pretty +from openml.utils import ReprMixin + -class OpenMLDataFeature: +class OpenMLDataFeature(ReprMixin): """ Data Feature (a.k.a. Attribute) object. @@ -31,7 +35,7 @@ class OpenMLDataFeature: LEGAL_DATA_TYPES: ClassVar[Sequence[str]] = ["nominal", "numeric", "string", "date"] - def __init__( # noqa: PLR0913 + def __init__( # noqa: PLR0913,PLR0917 self, index: int, name: str, @@ -51,8 +55,7 @@ def __init__( # noqa: PLR0913 if data_type == "nominal": if nominal_values is None: raise TypeError( - "Dataset features require attribute `nominal_values` for nominal " - "feature type.", + "Dataset features require attribute `nominal_values` for nominal feature type.", ) if not isinstance(nominal_values, list): @@ -74,11 +77,29 @@ def __init__( # noqa: PLR0913 self.number_missing_values = number_missing_values self.ontologies = ontologies - def __repr__(self) -> str: - return "[%d - %s (%s)]" % (self.index, self.name, self.data_type) + def _get_repr_body_fields(self) -> Sequence[tuple[str, str | int | list[str] | None]]: + """Collect all information to display in the __repr__ body.""" + fields: dict[str, int | str | None] = { + "Index": self.index, + "Name": self.name, + "Data Type": self.data_type, + } + return list(fields.items()) def __eq__(self, other: Any) -> bool: return isinstance(other, OpenMLDataFeature) and self.__dict__ == other.__dict__ - def _repr_pretty_(self, pp: pretty.PrettyPrinter, cycle: bool) -> None: # noqa: FBT001, ARG002 + def __hash__(self) -> int: + return hash( + ( + self.index, + self.name, + self.data_type, + tuple(self.nominal_values) if self.nominal_values is not None else None, + self.number_missing_values, + tuple(self.ontologies) if self.ontologies is not None else None, + ) + ) + + def _repr_pretty_(self, pp: pretty.PrettyPrinter, cycle: bool) -> None: # noqa: ARG002 pp.text(str(self)) diff --git a/openml/datasets/dataset.py b/openml/datasets/dataset.py index fa83d2b8a..c3d4a71c6 100644 --- a/openml/datasets/dataset.py +++ b/openml/datasets/dataset.py @@ -1,15 +1,16 @@ # License: BSD 3-Clause from __future__ import annotations +import contextlib import gzip import logging import os import pickle import re import warnings +from collections.abc import Iterable, Sequence from pathlib import Path -from typing import Any, Iterable, Sequence -from typing_extensions import Literal +from typing import Any, Literal import arff import numpy as np @@ -17,8 +18,8 @@ import scipy.sparse import xmltodict +import openml from openml.base import OpenMLBase -from openml.config import OPENML_SKIP_PARQUET_ENV_VAR from .data_feature import OpenMLDataFeature @@ -41,7 +42,7 @@ def _ensure_dataframe( raise TypeError(f"Data type {type(data)} not supported.") -class OpenMLDataset(OpenMLBase): +class OpenMLDataset(OpenMLBase): # noqa: PLW1641, RUF100 """Dataset object. Allows fetching and uploading datasets to OpenML. @@ -123,7 +124,7 @@ class OpenMLDataset(OpenMLBase): Path to the local file. """ - def __init__( # noqa: C901, PLR0912, PLR0913, PLR0915 + def __init__( # noqa: C901, PLR0912, PLR0913,PLR0917, PLR0915 self, name: str, description: str | None, @@ -295,13 +296,6 @@ def id(self) -> int | None: def _get_repr_body_fields(self) -> Sequence[tuple[str, str | int | None]]: """Collect all information to display in the __repr__ body.""" - # Obtain number of features in accordance with lazy loading. - n_features: int | None = None - if self._qualities is not None and self._qualities["NumberOfFeatures"] is not None: - n_features = int(self._qualities["NumberOfFeatures"]) - elif self._features is not None: - n_features = len(self._features) - fields: dict[str, int | str | None] = { "Name": self.name, "Version": self.version, @@ -312,14 +306,23 @@ def _get_repr_body_fields(self) -> Sequence[tuple[str, str | int | None]]: "Pickle file": ( str(self.data_pickle_file) if self.data_pickle_file is not None else None ), - "# of features": n_features, } + + # We don't consider these fields critical to display, so we use the internal + # attribute to avoid downloading extra files just to generate the repr. + try: + fields["# of features"] = int(self._qualities["NumberOfFeatures"]) # type: ignore[index] + except (TypeError, KeyError, ValueError): + if self._features is not None: + fields["# of features"] = len(self._features) + + with contextlib.suppress(TypeError, KeyError, ValueError): + fields["# of instances"] = int(self._qualities["NumberOfInstances"]) # type: ignore[index] + if self.upload_date is not None: fields["Upload Date"] = self.upload_date.replace("T", " ") if self.dataset_id is not None: fields["OpenML URL"] = self.openml_url - if self._qualities is not None and self._qualities["NumberOfInstances"] is not None: - fields["# of instances"] = int(self._qualities["NumberOfInstances"]) # determines the order in which the information will be printed order = [ @@ -375,7 +378,9 @@ def _download_data(self) -> None: # import required here to avoid circular import. from .functions import _get_dataset_arff, _get_dataset_parquet - skip_parquet = os.environ.get(OPENML_SKIP_PARQUET_ENV_VAR, "false").casefold() == "true" + skip_parquet = ( + os.environ.get(openml.config.OPENML_SKIP_PARQUET_ENV_VAR, "false").casefold() == "true" + ) if self._parquet_url is not None and not skip_parquet: parquet_file = _get_dataset_parquet(self) self.parquet_file = None if parquet_file is None else str(parquet_file) @@ -420,7 +425,11 @@ def _get_arff(self, format: str) -> dict: # noqa: A002 file_size = filepath.stat().st_size if file_size > MB_120: raise NotImplementedError( - f"File {filename} too big for {file_size}-bit system ({bits} bytes).", + f"File '{filename}' ({file_size / 1e6:.1f} MB)" + f"exceeds the maximum supported size of 120 MB. " + f"This limitation applies to {bits}-bit systems. " + f"Large dataset handling is currently not fully supported. " + f"Please consider using a smaller dataset" ) if format.lower() == "arff": @@ -484,7 +493,7 @@ def _parse_data_from_arff( # noqa: C901, PLR0912, PLR0915 try: # checks if the strings which should be the class labels # can be encoded into integers - pd.factorize(type_)[0] + pd.factorize(np.array(type_))[0] except ValueError as e: raise ValueError( "Categorical data needs to be numeric when using sparse ARFF." @@ -716,11 +725,11 @@ def valid_category(cat: Any) -> bool: raw_cat = pd.Categorical(col, ordered=True, categories=filtered_categories) return pd.Series(raw_cat, index=series.index, name=series.name) - def get_data( # noqa: C901 + def get_data( # noqa: C901, PLR0912 self, target: list[str] | str | None = None, - include_row_id: bool = False, # noqa: FBT001, FBT002 - include_ignore_attribute: bool = False, # noqa: FBT001, FBT002 + include_row_id: bool = False, # noqa: FBT002 + include_ignore_attribute: bool = False, # noqa: FBT002 ) -> tuple[pd.DataFrame, pd.Series | None, list[bool], list[str]]: """Returns dataset content as dataframes. @@ -766,8 +775,8 @@ def get_data( # noqa: C901 logger.info(f"Going to remove the following attributes: {to_exclude}") keep = np.array([column not in to_exclude for column in attribute_names]) data = data.drop(columns=to_exclude) - categorical_mask = [cat for cat, k in zip(categorical_mask, keep) if k] - attribute_names = [att for att, k in zip(attribute_names, keep) if k] + categorical_mask = [cat for cat, k in zip(categorical_mask, keep, strict=False) if k] + attribute_names = [att for att, k in zip(attribute_names, keep, strict=False) if k] if target is None: return data, None, categorical_mask, attribute_names @@ -780,9 +789,30 @@ def get_data( # noqa: C901 # All the assumptions below for the target are dependant on the number of targets being 1 n_targets = len(target_names) if n_targets > 1: - raise NotImplementedError(f"Number of targets {n_targets} not implemented.") + raise NotImplementedError( + f"Multi-target prediction is not yet supported." + f"Found {n_targets} target columns: {target_names}. " + f"Currently, only single-target datasets are supported. " + f"Please select a single target column." + ) target_name = target_names[0] + available_columns = list(data.columns) + if target_name in to_exclude: + msg = ( + f"Target column '{target_name}' was removed because it is listed as a " + f"row_id or ignore attribute. " + f"Available columns after filtering: {available_columns}" + ) + raise ValueError(msg) + + if target_name not in data.columns: + msg = ( + f"Target column '{target_name}' does not exist in this dataset. " + f"Available columns: {available_columns}" + ) + raise ValueError(msg) + x = data.drop(columns=[target_name]) y = data[target_name].squeeze() @@ -827,7 +857,7 @@ def _load_qualities(self) -> None: else: self._qualities = _read_qualities(qualities_file) - def retrieve_class_labels(self, target_name: str = "class") -> None | list[str]: + def retrieve_class_labels(self, target_name: str = "class") -> list[str] | None: """Reads the datasets arff to determine the class-labels. If the task has no class labels (for example a regression problem) @@ -863,8 +893,8 @@ def get_features_by_type( # noqa: C901 self, data_type: str, exclude: list[str] | None = None, - exclude_ignore_attribute: bool = True, # noqa: FBT002, FBT001 - exclude_row_id_attribute: bool = True, # noqa: FBT002, FBT001 + exclude_ignore_attribute: bool = True, # noqa: FBT002 + exclude_row_id_attribute: bool = True, # noqa: FBT002 ) -> list[int]: """ Return indices of features of a given type, e.g. all nominal features. diff --git a/openml/datasets/functions.py b/openml/datasets/functions.py index ac5466a44..12ad42784 100644 --- a/openml/datasets/functions.py +++ b/openml/datasets/functions.py @@ -1,5 +1,5 @@ # License: BSD 3-Clause -# ruff: noqa: PLR0913 +# ruff: noqa: PLR0913,PLR0917 from __future__ import annotations import logging @@ -9,8 +9,7 @@ from functools import partial from pathlib import Path from pyexpat import ExpatError -from typing import TYPE_CHECKING, Any -from typing_extensions import Literal +from typing import TYPE_CHECKING, Any, Literal import arff import minio.error @@ -20,9 +19,9 @@ import xmltodict from scipy.sparse import coo_matrix +import openml import openml._api_calls import openml.utils -from openml.config import OPENML_SKIP_PARQUET_ENV_VAR from openml.exceptions import ( OpenMLHashException, OpenMLPrivateDatasetError, @@ -259,7 +258,7 @@ def _validated_data_attributes( def check_datasets_active( dataset_ids: list[int], - raise_error_if_not_exist: bool = True, # noqa: FBT001, FBT002 + raise_error_if_not_exist: bool = True, # noqa: FBT002 ) -> dict[int, bool]: """ Check if the dataset ids provided are active. @@ -293,7 +292,7 @@ def check_datasets_active( def _name_to_id( dataset_name: str, version: int | None = None, - error_if_multiple: bool = False, # noqa: FBT001, FBT002 + error_if_multiple: bool = False, # noqa: FBT002 ) -> int: """Attempt to find the dataset id of the dataset with the given name. @@ -341,8 +340,8 @@ def _name_to_id( def get_datasets( dataset_ids: list[str | int], - download_data: bool = False, # noqa: FBT001, FBT002 - download_qualities: bool = False, # noqa: FBT001, FBT002 + download_data: bool = False, # noqa: FBT002 + download_qualities: bool = False, # noqa: FBT002 ) -> list[OpenMLDataset]: """Download datasets. @@ -365,6 +364,11 @@ def get_datasets( ------- datasets : list of datasets A list of dataset objects. + + Examples + -------- + >>> import openml + >>> datasets = openml.datasets.get_datasets([1, 2, 3]) # doctest: +SKIP """ datasets = [] for dataset_id in dataset_ids: @@ -377,14 +381,14 @@ def get_datasets( @openml.utils.thread_safe_if_oslo_installed def get_dataset( # noqa: C901, PLR0912 dataset_id: int | str, - download_data: bool = False, # noqa: FBT002, FBT001 + download_data: bool = False, # noqa: FBT002 version: int | None = None, - error_if_multiple: bool = False, # noqa: FBT002, FBT001 + error_if_multiple: bool = False, # noqa: FBT002 cache_format: Literal["pickle", "feather"] = "pickle", - download_qualities: bool = False, # noqa: FBT002, FBT001 - download_features_meta_data: bool = False, # noqa: FBT002, FBT001 - download_all_files: bool = False, # noqa: FBT002, FBT001 - force_refresh_cache: bool = False, # noqa: FBT001, FBT002 + download_qualities: bool = False, # noqa: FBT002 + download_features_meta_data: bool = False, # noqa: FBT002 + download_all_files: bool = False, # noqa: FBT002 + force_refresh_cache: bool = False, # noqa: FBT002 ) -> OpenMLDataset: """Download the OpenML dataset representation, optionally also download actual data file. @@ -447,6 +451,13 @@ def get_dataset( # noqa: C901, PLR0912 ------- dataset : :class:`openml.OpenMLDataset` The downloaded dataset. + + Examples + -------- + >>> import openml + >>> dataset = openml.datasets.get_dataset(1) # doctest: +SKIP + >>> dataset = openml.datasets.get_dataset("iris", version=1) # doctest: +SKIP + >>> dataset = openml.datasets.get_dataset(1, download_data=True) # doctest: +SKIP """ if download_all_files: warnings.warn( @@ -493,7 +504,9 @@ def get_dataset( # noqa: C901, PLR0912 qualities_file = _get_dataset_qualities_file(did_cache_dir, dataset_id) parquet_file = None - skip_parquet = os.environ.get(OPENML_SKIP_PARQUET_ENV_VAR, "false").casefold() == "true" + skip_parquet = ( + os.environ.get(openml.config.OPENML_SKIP_PARQUET_ENV_VAR, "false").casefold() == "true" + ) download_parquet = "oml:parquet_url" in description and not skip_parquet if download_parquet and (download_data or download_all_files): try: @@ -599,8 +612,7 @@ def create_dataset( # noqa: C901, PLR0912, PLR0915 # Edit: Found it could also be like ["True", "False"] attributes: list[tuple[str, str | list[str]]] | dict[str, str | list[str]] | Literal["auto"], data: pd.DataFrame | np.ndarray | scipy.sparse.coo_matrix, - # TODO(eddiebergman): Function requires `default_target_attribute` exist but API allows None - default_target_attribute: str, + default_target_attribute: str | None, ignore_attribute: str | list[str] | None, citation: str, row_id_attribute: str | None = None, @@ -644,7 +656,8 @@ def create_dataset( # noqa: C901, PLR0912, PLR0915 passing ``attributes='auto'``. The target feature is indicated as meta-data of the dataset. default_target_attribute : str - The default target attribute, if it exists. + The default target attribute, if it exists. Use ``None`` for unsupervised datasets + (e.g. clustering, anomaly detection) where no natural target column exists. Can have multiple values, comma separated. ignore_attribute : str | list Attributes that should be excluded in modelling, @@ -892,6 +905,29 @@ def edit_dataset( if not isinstance(data_id, int): raise TypeError(f"`data_id` must be of type `int`, not {type(data_id)}.") + if not any( + field + for field in [ + description, + creator, + contributor, + collection_date, + language, + default_target_attribute, + ignore_attribute, + citation, + row_id_attribute, + original_data_url, + paper_url, + ] + ): + raise ValueError( + "Please provide atleast one field among description, creator, " + "contributor, collection_date, language, citation, " + "original_data_url, default_target_attribute, row_id_attribute, " + "ignore_attribute or paper_url to edit.", + ) + # compose data edit parameters as xml form_data = {"data_id": data_id} # type: openml._api_calls.DATA_TYPE xml = OrderedDict() # type: 'OrderedDict[str, OrderedDict]' @@ -1116,7 +1152,7 @@ def _get_dataset_description(did_cache_dir: Path, dataset_id: int) -> dict[str, def _get_dataset_parquet( description: dict | OpenMLDataset, cache_directory: Path | None = None, - download_all_files: bool = False, # noqa: FBT001, FBT002 + download_all_files: bool = False, # noqa: FBT002 ) -> Path | None: """Return the path to the local parquet file of the dataset. If is not cached, it is downloaded. @@ -1418,7 +1454,7 @@ def _get_online_dataset_arff(dataset_id: int) -> str | None: str or None A string representation of an ARFF file. Or None if file already exists. """ - dataset_xml = openml._api_calls._perform_api_call("data/%d" % dataset_id, "get") + dataset_xml = openml._api_calls._perform_api_call(f"data/{dataset_id}", "get") # build a dict from the xml. # use the url from the dataset description and return the ARFF string return openml._api_calls._download_text_file( @@ -1439,7 +1475,7 @@ def _get_online_dataset_format(dataset_id: int) -> str: str Dataset format. """ - dataset_xml = openml._api_calls._perform_api_call("data/%d" % dataset_id, "get") + dataset_xml = openml._api_calls._perform_api_call(f"data/{dataset_id}", "get") # build a dict from the xml and get the format from the dataset description return xmltodict.parse(dataset_xml)["oml:data_set_description"]["oml:format"].lower() # type: ignore diff --git a/openml/enums.py b/openml/enums.py new file mode 100644 index 000000000..8c8048e07 --- /dev/null +++ b/openml/enums.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from enum import Enum + + +class ServerMode(str, Enum): + """Supported modes in server.""" + + PRODUCTION = "production" + TEST = "test" + + +class APIVersion(str, Enum): + """Supported OpenML API versions.""" + + V1 = "v1" + V2 = "v2" + + +class ResourceType(str, Enum): + """Canonical resource types exposed by the OpenML API.""" + + DATASET = "dataset" + TASK = "task" + TASK_TYPE = "task_type" + EVALUATION_MEASURE = "evaluation_measure" + ESTIMATION_PROCEDURE = "estimation_procedure" + EVALUATION = "evaluation" + FLOW = "flow" + STUDY = "study" + RUN = "run" + SETUP = "setup" + USER = "user" + + +class RetryPolicy(str, Enum): + """Retry behavior for failed API requests.""" + + HUMAN = "human" + ROBOT = "robot" diff --git a/openml/estimation_procedures/__init__.py b/openml/estimation_procedures/__init__.py new file mode 100644 index 000000000..5dbd793a4 --- /dev/null +++ b/openml/estimation_procedures/__init__.py @@ -0,0 +1,5 @@ +# License: BSD 3-Clause + +from .estimation_procedure import OpenMLEstimationProcedure + +__all__ = ["OpenMLEstimationProcedure"] diff --git a/openml/estimation_procedures/estimation_procedure.py b/openml/estimation_procedures/estimation_procedure.py new file mode 100644 index 000000000..f4ffc7b7a --- /dev/null +++ b/openml/estimation_procedures/estimation_procedure.py @@ -0,0 +1,50 @@ +# License: BSD 3-Clause +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from openml.tasks import TaskType + + +@dataclass +class OpenMLEstimationProcedure: + """ + Contains all meta-information about a run / evaluation combination, + according to the evaluation/list function + + Parameters + ---------- + id : int + ID of estimation procedure + task_type_id : TaskType + Assosiated task type + name : str + Name of estimation procedure + type : str + Type of estimation procedure + """ + + id: int + task_type_id: TaskType + name: str + type: str + + def _to_dict(self) -> dict: + return asdict(self) + + def __repr__(self) -> str: + header = "OpenML Estimation Procedure" + header = f"{header}\n{'=' * len(header)}\n" + + fields = { + "ID": self.id, + "Name": self.name, + "Type": self.type, + "Task Type": self.task_type_id, + } + longest_field_name_length = max(len(name) for name in fields) + field_line_format = f"{{:.<{longest_field_name_length}}}: {{}}" + body = "\n".join(field_line_format.format(name, value) for name, value in fields.items()) + return header + body diff --git a/openml/evaluations/__init__.py b/openml/evaluations/__init__.py index dbff47037..b56d0c2d5 100644 --- a/openml/evaluations/__init__.py +++ b/openml/evaluations/__init__.py @@ -5,7 +5,7 @@ __all__ = [ "OpenMLEvaluation", - "list_evaluations", "list_evaluation_measures", + "list_evaluations", "list_evaluations_setups", ] diff --git a/openml/evaluations/evaluation.py b/openml/evaluations/evaluation.py index 6d69d377e..87df8454a 100644 --- a/openml/evaluations/evaluation.py +++ b/openml/evaluations/evaluation.py @@ -1,15 +1,15 @@ # License: BSD 3-Clause from __future__ import annotations -import openml.config +from dataclasses import asdict, dataclass + import openml.datasets import openml.flows import openml.runs import openml.tasks -# TODO(eddiebergman): A lot of this class is automatically -# handled by a dataclass +@dataclass class OpenMLEvaluation: """ Contains all meta-information about a run / evaluation combination, @@ -48,55 +48,23 @@ class OpenMLEvaluation: (e.g., in case of precision, auroc, recall) """ - def __init__( # noqa: PLR0913 - self, - run_id: int, - task_id: int, - setup_id: int, - flow_id: int, - flow_name: str, - data_id: int, - data_name: str, - function: str, - upload_time: str, - uploader: int, - uploader_name: str, - value: float | None, - values: list[float] | None, - array_data: str | None = None, - ): - self.run_id = run_id - self.task_id = task_id - self.setup_id = setup_id - self.flow_id = flow_id - self.flow_name = flow_name - self.data_id = data_id - self.data_name = data_name - self.function = function - self.upload_time = upload_time - self.uploader = uploader - self.uploader_name = uploader_name - self.value = value - self.values = values - self.array_data = array_data + run_id: int + task_id: int + setup_id: int + flow_id: int + flow_name: str + data_id: int + data_name: str + function: str + upload_time: str + uploader: int + uploader_name: str + value: float | None + values: list[float] | None + array_data: str | None = None def _to_dict(self) -> dict: - return { - "run_id": self.run_id, - "task_id": self.task_id, - "setup_id": self.setup_id, - "flow_id": self.flow_id, - "flow_name": self.flow_name, - "data_id": self.data_id, - "data_name": self.data_name, - "function": self.function, - "upload_time": self.upload_time, - "uploader": self.uploader, - "uploader_name": self.uploader_name, - "value": self.value, - "values": self.values, - "array_data": self.array_data, - } + return asdict(self) def __repr__(self) -> str: header = "OpenML Evaluation" @@ -119,11 +87,12 @@ def __repr__(self) -> str: } order = [ - "Uploader Date", + "Upload Date", "Run ID", "OpenML Run URL", "Task ID", - "OpenML Task URL" "Flow ID", + "OpenML Task URL", + "Flow ID", "OpenML Flow URL", "Setup ID", "Data ID", diff --git a/openml/evaluations/functions.py b/openml/evaluations/functions.py index 7747294d7..4f4c3baea 100644 --- a/openml/evaluations/functions.py +++ b/openml/evaluations/functions.py @@ -1,21 +1,21 @@ # License: BSD 3-Clause -# ruff: noqa: PLR0913 +# ruff: noqa: PLR0913,PLR0917 from __future__ import annotations -import json from functools import partial from itertools import chain -from typing import Any -from typing_extensions import Literal, overload +from typing import TYPE_CHECKING, Literal +from typing_extensions import overload import numpy as np import pandas as pd -import xmltodict import openml import openml._api_calls import openml.utils -from openml.evaluations import OpenMLEvaluation + +if TYPE_CHECKING: + from openml.evaluations import OpenMLEvaluation @overload @@ -120,7 +120,7 @@ def list_evaluations( per_fold_str = str(per_fold).lower() listing_call = partial( - _list_evaluations, + openml._backend.evaluation.list, function=function, tasks=tasks, setups=setups, @@ -142,137 +142,6 @@ def list_evaluations( return {e.run_id: e for e in flattened} -def _list_evaluations( # noqa: C901 - limit: int, - offset: int, - *, - function: str, - tasks: list | None = None, - setups: list | None = None, - flows: list | None = None, - runs: list | None = None, - uploaders: list | None = None, - study: int | None = None, - sort_order: str | None = None, - **kwargs: Any, -) -> list[OpenMLEvaluation]: - """ - Perform API call ``/evaluation/function{function}/{filters}`` - - Parameters - ---------- - The arguments that are lists are separated from the single value - ones which are put into the kwargs. - - limit : int - the number of evaluations to return - offset : int - the number of evaluations to skip, starting from the first - function : str - the evaluation function. e.g., predictive_accuracy - - tasks : list[int,str], optional - the list of task IDs - setups: list[int,str], optional - the list of setup IDs - flows : list[int,str], optional - the list of flow IDs - runs :list[int,str], optional - the list of run IDs - uploaders : list[int,str], optional - the list of uploader IDs - - study : int, optional - - kwargs: dict, optional - Legal filter operators: tag, per_fold - - sort_order : str, optional - order of sorting evaluations, ascending ("asc") or descending ("desc") - - Returns - ------- - list of OpenMLEvaluation objects - """ - api_call = f"evaluation/list/function/{function}" - if limit is not None: - api_call += f"/limit/{limit}" - if offset is not None: - api_call += f"/offset/{offset}" - if kwargs is not None: - for operator, value in kwargs.items(): - if value is not None: - api_call += f"/{operator}/{value}" - if tasks is not None: - api_call += f"/task/{','.join([str(int(i)) for i in tasks])}" - if setups is not None: - api_call += f"/setup/{','.join([str(int(i)) for i in setups])}" - if flows is not None: - api_call += f"/flow/{','.join([str(int(i)) for i in flows])}" - if runs is not None: - api_call += f"/run/{','.join([str(int(i)) for i in runs])}" - if uploaders is not None: - api_call += f"/uploader/{','.join([str(int(i)) for i in uploaders])}" - if study is not None: - api_call += f"/study/{study}" - if sort_order is not None: - api_call += f"/sort_order/{sort_order}" - - return __list_evaluations(api_call) - - -def __list_evaluations(api_call: str) -> list[OpenMLEvaluation]: - """Helper function to parse API calls which are lists of runs""" - xml_string = openml._api_calls._perform_api_call(api_call, "get") - evals_dict = xmltodict.parse(xml_string, force_list=("oml:evaluation",)) - # Minimalistic check if the XML is useful - if "oml:evaluations" not in evals_dict: - raise ValueError( - "Error in return XML, does not contain " f'"oml:evaluations": {evals_dict!s}', - ) - - assert isinstance(evals_dict["oml:evaluations"]["oml:evaluation"], list), type( - evals_dict["oml:evaluations"], - ) - - uploader_ids = list( - {eval_["oml:uploader"] for eval_ in evals_dict["oml:evaluations"]["oml:evaluation"]}, - ) - api_users = "user/list/user_id/" + ",".join(uploader_ids) - xml_string_user = openml._api_calls._perform_api_call(api_users, "get") - - users = xmltodict.parse(xml_string_user, force_list=("oml:user",)) - user_dict = {user["oml:id"]: user["oml:username"] for user in users["oml:users"]["oml:user"]} - - evals = [] - for eval_ in evals_dict["oml:evaluations"]["oml:evaluation"]: - run_id = int(eval_["oml:run_id"]) - value = float(eval_["oml:value"]) if "oml:value" in eval_ else None - values = json.loads(eval_["oml:values"]) if eval_.get("oml:values", None) else None - array_data = eval_.get("oml:array_data") - - evals.append( - OpenMLEvaluation( - run_id=run_id, - task_id=int(eval_["oml:task_id"]), - setup_id=int(eval_["oml:setup_id"]), - flow_id=int(eval_["oml:flow_id"]), - flow_name=eval_["oml:flow_name"], - data_id=int(eval_["oml:data_id"]), - data_name=eval_["oml:data_name"], - function=eval_["oml:function"], - upload_time=eval_["oml:upload_time"], - uploader=int(eval_["oml:uploader"]), - uploader_name=user_dict[eval_["oml:uploader"]], - value=value, - values=values, - array_data=array_data, - ) - ) - - return evals - - def list_evaluation_measures() -> list[str]: """Return list of evaluation measures available. @@ -284,17 +153,7 @@ def list_evaluation_measures() -> list[str]: list """ - api_call = "evaluationmeasure/list" - xml_string = openml._api_calls._perform_api_call(api_call, "get") - qualities = xmltodict.parse(xml_string, force_list=("oml:measures")) - # Minimalistic check if the XML is useful - if "oml:evaluation_measures" not in qualities: - raise ValueError('Error in return XML, does not contain "oml:evaluation_measures"') - - if not isinstance(qualities["oml:evaluation_measures"]["oml:measures"][0]["oml:measure"], list): - raise TypeError('Error in return XML, does not contain "oml:measure" as a list') - - return qualities["oml:evaluation_measures"]["oml:measures"][0]["oml:measure"] + return openml._backend.evaluation_measure.list() def list_estimation_procedures() -> list[str]: @@ -307,24 +166,8 @@ def list_estimation_procedures() -> list[str]: ------- list """ - api_call = "estimationprocedure/list" - xml_string = openml._api_calls._perform_api_call(api_call, "get") - api_results = xmltodict.parse(xml_string) - - # Minimalistic check if the XML is useful - if "oml:estimationprocedures" not in api_results: - raise ValueError('Error in return XML, does not contain "oml:estimationprocedures"') - - if "oml:estimationprocedure" not in api_results["oml:estimationprocedures"]: - raise ValueError('Error in return XML, does not contain "oml:estimationprocedure"') - - if not isinstance(api_results["oml:estimationprocedures"]["oml:estimationprocedure"], list): - raise TypeError('Error in return XML, does not contain "oml:estimationprocedure" as a list') - - return [ - prod["oml:name"] - for prod in api_results["oml:estimationprocedures"]["oml:estimationprocedure"] - ] + result = openml._backend.estimation_procedure.list() + return [i.name for i in result] def list_evaluations_setups( @@ -339,7 +182,7 @@ def list_evaluations_setups( tag: str | None = None, per_fold: bool | None = None, sort_order: str | None = None, - parameters_in_separate_columns: bool = False, # noqa: FBT001, FBT002 + parameters_in_separate_columns: bool = False, # noqa: FBT002 ) -> pd.DataFrame: """List all run-evaluation pairs matching all of the given filters and their hyperparameter settings. diff --git a/openml/exceptions.py b/openml/exceptions.py index fe63b8a58..2f5eab027 100644 --- a/openml/exceptions.py +++ b/openml/exceptions.py @@ -22,7 +22,7 @@ class OpenMLServerException(OpenMLServerError): # noqa: N818 """ # Code needs to be optional to allow the exception to be picklable: - # https://stackoverflow.com/questions/16244923/how-to-make-a-custom-exception-class-with-multiple-init-args-pickleable # noqa: E501 + # https://stackoverflow.com/questions/16244923/how-to-make-a-custom-exception-class-with-multiple-init-args-pickleable def __init__(self, message: str, code: int | None = None, url: str | None = None): self.message = message self.code = code @@ -63,5 +63,32 @@ class OpenMLNotAuthorizedError(OpenMLServerError): """Indicates an authenticated user is not authorized to execute the requested action.""" +class OpenMLAuthenticationError(OpenMLServerError): + """Exception raised when API authentication fails. + + This typically occurs when: + - No API key is configured + - The API key is invalid or expired + - The API key format is incorrect + + This is different from authorization (OpenMLNotAuthorizedError), which occurs + when a valid API key lacks permissions for the requested operation. + """ + + def __init__(self, message: str): + help_text = ( + "\n\nTo fix this:\n" + "1. Get your API key from https://www.openml.org/\n" + " (you'll need to register for a free account if you don't have one)\n" + "2. Configure your API key by following the authentication guide:\n" + " https://openml.github.io/openml-python/latest/examples/Basics/introduction_tutorial/#authentication" + ) + super().__init__(message + help_text) + + class ObjectNotPublishedError(PyOpenMLError): """Indicates an object has not been published yet.""" + + +class OpenMLNotSupportedError(PyOpenMLError): + """Raised when an API operation is not supported for a resource/version.""" diff --git a/openml/extensions/__init__.py b/openml/extensions/__init__.py index b49865e0e..979986182 100644 --- a/openml/extensions/__init__.py +++ b/openml/extensions/__init__.py @@ -1,16 +1,15 @@ # License: BSD 3-Clause -from typing import List, Type # noqa: F401 from .extension_interface import Extension from .functions import get_extension_by_flow, get_extension_by_model, register_extension -extensions = [] # type: List[Type[Extension]] +extensions: list[type[Extension]] = [] __all__ = [ "Extension", - "register_extension", - "get_extension_by_model", "get_extension_by_flow", + "get_extension_by_model", + "register_extension", ] diff --git a/openml/extensions/extension_interface.py b/openml/extensions/extension_interface.py index 2a336eb52..85dcd1179 100644 --- a/openml/extensions/extension_interface.py +++ b/openml/extensions/extension_interface.py @@ -2,11 +2,12 @@ from __future__ import annotations from abc import ABC, abstractmethod -from collections import OrderedDict from typing import TYPE_CHECKING, Any # Avoid import cycles: https://mypy.readthedocs.io/en/latest/common_issues.html#import-cycles if TYPE_CHECKING: + from collections import OrderedDict + import numpy as np import scipy.sparse @@ -63,8 +64,8 @@ def can_handle_model(cls, model: Any) -> bool: def flow_to_model( self, flow: OpenMLFlow, - initialize_with_defaults: bool = False, # noqa: FBT001, FBT002 - strict_version: bool = True, # noqa: FBT002, FBT001 + initialize_with_defaults: bool = False, # noqa: FBT002 + strict_version: bool = True, # noqa: FBT002 ) -> Any: """Instantiate a model from the flow representation. @@ -156,7 +157,7 @@ def seed_model(self, model: Any, seed: int | None) -> Any: """ @abstractmethod - def _run_model_on_fold( # noqa: PLR0913 + def _run_model_on_fold( # noqa: PLR0913,PLR0917 self, model: Any, task: OpenMLTask, diff --git a/openml/extensions/functions.py b/openml/extensions/functions.py index 7a944c997..44df5ec69 100644 --- a/openml/extensions/functions.py +++ b/openml/extensions/functions.py @@ -1,6 +1,7 @@ # License: BSD 3-Clause from __future__ import annotations +import importlib.util from typing import TYPE_CHECKING, Any # Need to implement the following by its full path because otherwise it won't be possible to @@ -16,8 +17,9 @@ SKLEARN_HINT = ( "But it looks related to scikit-learn. " "Please install the OpenML scikit-learn extension (openml-sklearn) and try again. " + "You can use `pip install openml-sklearn` for installation." "For more information, see " - "https://github.com/openml/openml-sklearn?tab=readme-ov-file#installation" + "https://docs.openml.org/python/extensions/" ) @@ -40,7 +42,7 @@ def register_extension(extension: type[Extension]) -> None: def get_extension_by_flow( flow: OpenMLFlow, - raise_if_no_extension: bool = False, # noqa: FBT001, FBT002 + raise_if_no_extension: bool = False, # noqa: FBT002 ) -> Extension | None: """Get an extension which can handle the given flow. @@ -58,6 +60,10 @@ def get_extension_by_flow( ------- Extension or None """ + # import openml_sklearn to register SklearnExtension + if importlib.util.find_spec("openml_sklearn"): + import openml_sklearn # noqa: F401 + candidates = [] for extension_class in openml.extensions.extensions: if extension_class.can_handle_flow(flow): @@ -85,7 +91,7 @@ def get_extension_by_flow( def get_extension_by_model( model: Any, - raise_if_no_extension: bool = False, # noqa: FBT001, FBT002 + raise_if_no_extension: bool = False, # noqa: FBT002 ) -> Extension | None: """Get an extension which can handle the given flow. @@ -103,6 +109,10 @@ def get_extension_by_model( ------- Extension or None """ + # import openml_sklearn to register SklearnExtension + if importlib.util.find_spec("openml_sklearn"): + import openml_sklearn # noqa: F401 + candidates = [] for extension_class in openml.extensions.extensions: if extension_class.can_handle_model(model): diff --git a/openml/flows/__init__.py b/openml/flows/__init__.py index ce32fec7d..d455249de 100644 --- a/openml/flows/__init__.py +++ b/openml/flows/__init__.py @@ -12,10 +12,10 @@ __all__ = [ "OpenMLFlow", - "get_flow", - "list_flows", - "get_flow_id", - "flow_exists", "assert_flows_equal", "delete_flow", + "flow_exists", + "get_flow", + "get_flow_id", + "list_flows", ] diff --git a/openml/flows/flow.py b/openml/flows/flow.py index 02d24e78b..5b8b9a950 100644 --- a/openml/flows/flow.py +++ b/openml/flows/flow.py @@ -4,14 +4,19 @@ import logging from collections import OrderedDict from pathlib import Path -from typing import Any, Hashable, Sequence, cast +from typing import TYPE_CHECKING, Any, cast import xmltodict +import openml from openml.base import OpenMLBase +from openml.exceptions import ObjectNotPublishedError from openml.extensions import Extension, get_extension_by_flow from openml.utils import extract_xml_tags +if TYPE_CHECKING: + from collections.abc import Hashable, Sequence + class OpenMLFlow(OpenMLBase): """OpenML Flow. Stores machine learning models. @@ -88,7 +93,7 @@ class OpenMLFlow(OpenMLBase): OpenML version of the flow. Assigned by the server. """ - def __init__( # noqa: PLR0913 + def __init__( # noqa: PLR0913,PLR0917 self, name: str, description: str, @@ -169,7 +174,7 @@ def extension(self) -> Extension: """The extension of the flow (e.g., sklearn).""" if self._extension is None: self._extension = cast( - Extension, get_extension_by_flow(self, raise_if_no_extension=True) + "Extension", get_extension_by_flow(self, raise_if_no_extension=True) ) return self._extension @@ -408,7 +413,7 @@ def _parse_publish_response(self, xml_response: dict) -> None: """Parse the id from the xml_response and assign it to self.""" self.flow_id = int(xml_response["oml:upload_flow"]["oml:id"]) - def publish(self, raise_error_if_exists: bool = False) -> OpenMLFlow: # noqa: FBT001, FBT002 + def publish(self, raise_error_if_exists: bool = False) -> OpenMLFlow: # noqa: FBT002 """Publish this flow to OpenML server. Raises a PyOpenMLError if the flow exists on the server, but @@ -435,17 +440,22 @@ def publish(self, raise_error_if_exists: bool = False) -> OpenMLFlow: # noqa: F if not flow_id: if self.flow_id: raise openml.exceptions.PyOpenMLError( - "Flow does not exist on the server, " "but 'flow.flow_id' is not None.", + "Flow does not exist on the server, but 'flow.flow_id' is not None.", ) - super().publish() - assert self.flow_id is not None # for mypy - flow_id = self.flow_id + + file_elements = self._get_file_elements() + if "description" not in file_elements: + file_elements["description"] = self._to_xml() + + # Use openml._backend.flow.publish which internally calls ResourceV1.publish + flow_id = openml._backend.flow.publish(path="flow", files=file_elements) + self.flow_id = flow_id elif raise_error_if_exists: error_message = f"This OpenMLFlow already exists with id: {flow_id}." raise openml.exceptions.PyOpenMLError(error_message) elif self.flow_id is not None and self.flow_id != flow_id: raise openml.exceptions.PyOpenMLError( - "Local flow_id does not match server flow_id: " f"'{self.flow_id}' vs '{flow_id}'", + f"Local flow_id does not match server flow_id: '{self.flow_id}' vs '{flow_id}'", ) flow = openml.flows.functions.get_flow(flow_id) @@ -467,6 +477,38 @@ def publish(self, raise_error_if_exists: bool = False) -> OpenMLFlow: # noqa: F ) from e return self + def push_tag(self, tag: str) -> None: + """Annotates this flow with a tag on the server. + + Parameters + ---------- + tag : str + Tag to attach to the flow. + """ + if self.flow_id is None: + raise ObjectNotPublishedError( + "Cannot tag a flow that has not been published yet. " + "Please publish the object first before being able to tag it." + f"\n{self}", + ) + openml._backend.flow.tag(self.flow_id, tag) + + def remove_tag(self, tag: str) -> None: + """Removes a tag from this flow on the server. + + Parameters + ---------- + tag : str + Tag to remove from the flow. + """ + if self.flow_id is None: + raise ObjectNotPublishedError( + "Cannot untag a flow that has not been published yet. " + "Please publish the object first before being able to untag it." + f"\n{self}", + ) + openml._backend.flow.untag(self.flow_id, tag) + def get_structure(self, key_item: str) -> dict[str, list[str]]: """ Returns for each sub-component of the flow the path of identifiers @@ -517,7 +559,7 @@ def get_subflow(self, structure: list[str]) -> OpenMLFlow: sub_identifier = structure[0] if sub_identifier not in self.components: raise ValueError( - f"Flow {self.name} does not contain component with " f"identifier {sub_identifier}", + f"Flow {self.name} does not contain component with identifier {sub_identifier}", ) if len(structure) == 1: return self.components[sub_identifier] # type: ignore diff --git a/openml/flows/functions.py b/openml/flows/functions.py index 9906958e5..75d410f1a 100644 --- a/openml/flows/functions.py +++ b/openml/flows/functions.py @@ -1,96 +1,78 @@ # License: BSD 3-Clause from __future__ import annotations -import os -import re -from collections import OrderedDict from functools import partial -from typing import Any, Dict +from typing import Any import dateutil.parser import pandas as pd import xmltodict -import openml._api_calls +import openml import openml.utils -from openml.exceptions import OpenMLCacheException from . import OpenMLFlow -FLOWS_CACHE_DIR_NAME = "flows" - -def _get_cached_flows() -> OrderedDict: - """Return all the cached flows. - - Returns - ------- - flows : OrderedDict - Dictionary with flows. Each flow is an instance of OpenMLFlow. - """ - flows = OrderedDict() # type: 'OrderedDict[int, OpenMLFlow]' - - flow_cache_dir = openml.utils._create_cache_directory(FLOWS_CACHE_DIR_NAME) - directory_content = os.listdir(flow_cache_dir) - directory_content.sort() - # Find all flow ids for which we have downloaded - # the flow description - - for filename in directory_content: - if not re.match(r"[0-9]*", filename): - continue - - fid = int(filename) - flows[fid] = _get_cached_flow(fid) - - return flows - - -def _get_cached_flow(fid: int) -> OpenMLFlow: - """Get the cached flow with the given id. +@openml.utils.thread_safe_if_oslo_installed +def get_flow( + flow_id: int, + reinstantiate: bool = False, # noqa: FBT002 + strict_version: bool = True, # noqa: FBT002 +) -> OpenMLFlow: + """Fetch an OpenMLFlow by its server-assigned ID. + + Queries the OpenML REST API for the flow metadata and returns an + :class:`OpenMLFlow` instance. If the flow is already cached locally, + the cached copy is returned. Optionally the flow can be re-instantiated + into a concrete model instance using the registered extension. Parameters ---------- - fid : int - Flow id. + flow_id : int + The OpenML flow id. + reinstantiate : bool, optional (default=False) + If True, convert the flow description into a concrete model instance + using the flow's extension (e.g., sklearn). If conversion fails and + ``strict_version`` is True, an exception will be raised. + strict_version : bool, optional (default=True) + When ``reinstantiate`` is True, whether to enforce exact version + requirements for the extension/model. If False, a new flow may + be returned when versions differ. Returns ------- - OpenMLFlow. - """ - fid_cache_dir = openml.utils._create_cache_directory_for_id(FLOWS_CACHE_DIR_NAME, fid) - flow_file = fid_cache_dir / "flow.xml" - - try: - with flow_file.open(encoding="utf8") as fh: - return _create_flow_from_xml(fh.read()) - except OSError as e: - openml.utils._remove_cache_dir_for_id(FLOWS_CACHE_DIR_NAME, fid_cache_dir) - raise OpenMLCacheException("Flow file for fid %d not cached" % fid) from e - - -@openml.utils.thread_safe_if_oslo_installed -def get_flow(flow_id: int, reinstantiate: bool = False, strict_version: bool = True) -> OpenMLFlow: # noqa: FBT001, FBT002 - """Download the OpenML flow for a given flow ID. + OpenMLFlow + The flow object with metadata; ``model`` may be populated when + ``reinstantiate=True``. - Parameters - ---------- - flow_id : int - The OpenML flow id. + Raises + ------ + OpenMLServerException + When the REST API call fails. - reinstantiate: bool - Whether to reinstantiate the flow to a model instance. + Side Effects + ------------ + - Caches the retrieved flow using the HTTP client's caching mechanism. - strict_version : bool, default=True - Whether to fail if version requirements are not fulfilled. + Preconditions + ------------- + - Network access to the OpenML server is required unless the flow is cached. + - For private flows, ``openml.config.apikey`` must be set. - Returns - ------- - flow : OpenMLFlow - the flow + Notes + ----- + Results are cached to speed up subsequent calls. When ``reinstantiate`` is + True and version mismatches occur, a new flow may be returned to reflect + the converted model (only when ``strict_version`` is False). + + Examples + -------- + >>> import openml + >>> flow = openml.flows.get_flow(5) # doctest: +SKIP """ flow_id = int(flow_id) - flow = _get_flow_description(flow_id) + flow = openml._backend.flow.get(flow_id) if reinstantiate: flow.model = flow.extension.flow_to_model(flow, strict_version=strict_version) @@ -102,70 +84,55 @@ def get_flow(flow_id: int, reinstantiate: bool = False, strict_version: bool = T return flow -def _get_flow_description(flow_id: int) -> OpenMLFlow: - """Get the Flow for a given ID. - - Does the real work for get_flow. It returns a cached flow - instance if the flow exists locally, otherwise it downloads the - flow and returns an instance created from the xml representation. - - Parameters - ---------- - flow_id : int - The OpenML flow id. - - Returns - ------- - OpenMLFlow - """ - try: - return _get_cached_flow(flow_id) - except OpenMLCacheException: - xml_file = ( - openml.utils._create_cache_directory_for_id(FLOWS_CACHE_DIR_NAME, flow_id) / "flow.xml" - ) - flow_xml = openml._api_calls._perform_api_call("flow/%d" % flow_id, request_method="get") - - with xml_file.open("w", encoding="utf8") as fh: - fh.write(flow_xml) - - return _create_flow_from_xml(flow_xml) - - def list_flows( offset: int | None = None, size: int | None = None, tag: str | None = None, uploader: str | None = None, ) -> pd.DataFrame: - """ - Return a list of all flows which are on OpenML. - (Supports large amount of results) + """List flows available on the OpenML server. + + This function supports paging and filtering and returns a pandas + DataFrame with one row per flow and columns for id, name, version, + external_version, full_name and uploader. Parameters ---------- offset : int, optional - the number of flows to skip, starting from the first + Number of flows to skip, starting from the first (for paging). size : int, optional - the maximum number of flows to return + Maximum number of flows to return. tag : str, optional - the tag to include - kwargs: dict, optional - Legal filter operators: uploader. + Only return flows having this tag. + uploader : str, optional + Only return flows uploaded by this user. Returns ------- - flows : dataframe - Each row maps to a dataset - Each column contains the following information: - - flow id - - full name - - name - - version - - external version - - uploader + pandas.DataFrame + Rows correspond to flows. Columns include ``id``, ``full_name``, + ``name``, ``version``, ``external_version``, and ``uploader``. + + Raises + ------ + OpenMLServerException + When the API call fails. + + Side Effects + ------------ + - None: results are fetched and returned; Read-only operation. + + Preconditions + ------------- + - Network access is required to list flows unless cached mechanisms are + used by the underlying API helper. + + Examples + -------- + >>> import openml + >>> flows = openml.flows.list_flows(size=100) # doctest: +SKIP """ - listing_call = partial(_list_flows, tag=tag, uploader=uploader) + listing_call = partial(openml._backend.flow.list, tag=tag, uploader=uploader) batches = openml.utils._list_all(listing_call, offset=offset, limit=size) if len(batches) == 0: return pd.DataFrame() @@ -173,109 +140,102 @@ def list_flows( return pd.concat(batches) -def _list_flows(limit: int, offset: int, **kwargs: Any) -> pd.DataFrame: - """ - Perform the api call that return a list of all flows. - - Parameters - ---------- - limit : int - the maximum number of flows to return - offset : int - the number of flows to skip, starting from the first - kwargs: dict, optional - Legal filter operators: uploader, tag - - Returns - ------- - flows : dataframe - """ - api_call = "flow/list" - - if limit is not None: - api_call += f"/limit/{limit}" - if offset is not None: - api_call += f"/offset/{offset}" - - if kwargs is not None: - for operator, value in kwargs.items(): - if value is not None: - api_call += f"/{operator}/{value}" - - return __list_flows(api_call=api_call) - - def flow_exists(name: str, external_version: str) -> int | bool: - """Retrieves the flow id. + """Check whether a flow (name + external_version) exists on the server. - A flow is uniquely identified by name + external_version. + The OpenML server defines uniqueness of flows by the pair + ``(name, external_version)``. This helper queries the server and + returns the corresponding flow id when present. Parameters ---------- - name : string - Name of the flow - external_version : string + name : str + Flow name (e.g., ``sklearn.tree._classes.DecisionTreeClassifier(1)``). + external_version : str Version information associated with flow. Returns ------- - flow_exist : int or bool - flow id iff exists, False otherwise - - Notes - ----- - see https://www.openml.org/api_docs/#!/flow/get_flow_exists_name_version + int or bool + The flow id if the flow exists on the server, otherwise ``False``. + + Raises + ------ + ValueError + If ``name`` or ``external_version`` are empty or not strings. + OpenMLServerException + When the API request fails. + + Examples + -------- + >>> import openml + >>> openml.flows.flow_exists("weka.JRip", "Weka_3.9.0_10153") # doctest: +SKIP """ if not (isinstance(name, str) and len(name) > 0): raise ValueError("Argument 'name' should be a non-empty string") - if not (isinstance(name, str) and len(external_version) > 0): + if not (isinstance(external_version, str) and len(external_version) > 0): raise ValueError("Argument 'version' should be a non-empty string") - xml_response = openml._api_calls._perform_api_call( - "flow/exists", - "post", - data={"name": name, "external_version": external_version}, - ) - - result_dict = xmltodict.parse(xml_response) - flow_id = int(result_dict["oml:flow_exists"]["oml:id"]) - return flow_id if flow_id > 0 else False + return openml._backend.flow.exists(name=name, external_version=external_version) def get_flow_id( model: Any | None = None, name: str | None = None, - exact_version: bool = True, # noqa: FBT001, FBT002 + exact_version: bool = True, # noqa: FBT002 ) -> int | bool | list[int]: - """Retrieves the flow id for a model or a flow name. + """Retrieve flow id(s) for a model instance or a flow name. - Provide either a model or a name to this function. Depending on the input, it does + Provide either a concrete ``model`` (which will be converted to a flow by + the appropriate extension) or a flow ``name``. Behavior depends on + ``exact_version``: - * ``model`` and ``exact_version == True``: This helper function first queries for the necessary - extension. Second, it uses that extension to convert the model into a flow. Third, it - executes ``flow_exists`` to potentially obtain the flow id the flow is published to the - server. - * ``model`` and ``exact_version == False``: This helper function first queries for the - necessary extension. Second, it uses that extension to convert the model into a flow. Third - it calls ``list_flows`` and filters the returned values based on the flow name. - * ``name``: Ignores ``exact_version`` and calls ``list_flows``, then filters the returned - values based on the flow name. + - ``model`` + ``exact_version=True``: convert ``model`` to a flow and call + :func:`flow_exists` to get a single flow id (or False). + - ``model`` + ``exact_version=False``: convert ``model`` to a flow and + return all server flow ids with the same flow name. + - ``name``: ignore ``exact_version`` and return all server flow ids that + match ``name``. Parameters ---------- - model : object - Any model. Must provide either ``model`` or ``name``. - name : str - Name of the flow. Must provide either ``model`` or ``name``. - exact_version : bool - Whether to return the flow id of the exact version or all flow ids where the name - of the flow matches. This is only taken into account for a model where a version number - is available (requires ``model`` to be set). + model : object, optional + A model instance that can be handled by a registered extension. Either + ``model`` or ``name`` must be provided. + name : str, optional + Flow name to query for. Either ``model`` or ``name`` must be provided. + exact_version : bool, optional (default=True) + When True and ``model`` is provided, only return the id for the exact + external version. When False, return a list of matching ids. Returns ------- - int or bool, List - flow id iff exists, ``False`` otherwise, List if ``exact_version is False`` + int or bool or list[int] + If ``exact_version`` is True: the flow id if found, otherwise ``False``. + If ``exact_version`` is False: a list of matching flow ids (may be empty). + + Raises + ------ + ValueError + If neither ``model`` nor ``name`` is provided, or if both are provided. + OpenMLServerException + If underlying API calls fail. + + Side Effects + ------------ + - May call server APIs (``flow/exists``, ``flow/list``) and therefore + depends on network access and API keys for private flows. + + Examples + -------- + >>> import openml + >>> # Lookup by flow name + >>> openml.flows.get_flow_id(name="weka.JRip") # doctest: +SKIP + >>> # Lookup by model instance (requires a registered extension) + >>> import sklearn + >>> import openml_sklearn + >>> clf = sklearn.tree.DecisionTreeClassifier() + >>> openml.flows.get_flow_id(model=clf) # doctest: +SKIP """ if model is not None and name is not None: raise ValueError("Must provide either argument `model` or argument `name`, but not both.") @@ -308,44 +268,6 @@ def get_flow_id( return flows["id"].to_list() # type: ignore[no-any-return] -def __list_flows(api_call: str) -> pd.DataFrame: - """Retrieve information about flows from OpenML API - and parse it to a dictionary or a Pandas DataFrame. - - Parameters - ---------- - api_call: str - Retrieves the information about flows. - - Returns - ------- - The flows information in the specified output format. - """ - xml_string = openml._api_calls._perform_api_call(api_call, "get") - flows_dict = xmltodict.parse(xml_string, force_list=("oml:flow",)) - - # Minimalistic check if the XML is useful - assert isinstance(flows_dict["oml:flows"]["oml:flow"], list), type(flows_dict["oml:flows"]) - assert flows_dict["oml:flows"]["@xmlns:oml"] == "http://openml.org/openml", flows_dict[ - "oml:flows" - ]["@xmlns:oml"] - - flows = {} - for flow_ in flows_dict["oml:flows"]["oml:flow"]: - fid = int(flow_["oml:id"]) - flow = { - "id": fid, - "full_name": flow_["oml:full_name"], - "name": flow_["oml:name"], - "version": flow_["oml:version"], - "external_version": flow_["oml:external_version"], - "uploader": flow_["oml:uploader"], - } - flows[fid] = flow - - return pd.DataFrame.from_dict(flows, orient="index") - - def _check_flow_for_server_id(flow: OpenMLFlow) -> None: """Raises a ValueError if the flow or any of its subflows has no flow id.""" # Depth-first search to check if all components were uploaded to the @@ -360,13 +282,13 @@ def _check_flow_for_server_id(flow: OpenMLFlow) -> None: stack.append(component) -def assert_flows_equal( # noqa: C901, PLR0912, PLR0913, PLR0915 +def assert_flows_equal( # noqa: C901, PLR0912, PLR0913,PLR0917, PLR0915 flow1: OpenMLFlow, flow2: OpenMLFlow, ignore_parameter_values_on_older_children: str | None = None, - ignore_parameter_values: bool = False, # noqa: FBT001, FBT002 - ignore_custom_name_if_none: bool = False, # noqa: FBT001, FBT002 - check_description: bool = True, # noqa: FBT001, FBT002 + ignore_parameter_values: bool = False, # noqa: FBT002 + ignore_custom_name_if_none: bool = False, # noqa: FBT002 + check_description: bool = True, # noqa: FBT002 ) -> None: """Check equality of two flows. @@ -391,6 +313,21 @@ def assert_flows_equal( # noqa: C901, PLR0912, PLR0913, PLR0915 check_description : bool Whether to ignore matching of flow descriptions. + + Raises + ------ + TypeError + When either argument is not an :class:`OpenMLFlow`. + ValueError + When a relevant mismatch is found between the two flows. + + Examples + -------- + >>> import openml + >>> f1 = openml.flows.get_flow(5) # doctest: +SKIP + >>> f2 = openml.flows.get_flow(5) # doctest: +SKIP + >>> openml.flows.assert_flows_equal(f1, f2) # doctest: +SKIP + >>> # If flows differ, a ValueError is raised """ if not isinstance(flow1, OpenMLFlow): raise TypeError(f"Argument 1 must be of type OpenMLFlow, but is {type(flow1)}") @@ -417,7 +354,7 @@ def assert_flows_equal( # noqa: C901, PLR0912, PLR0913, PLR0915 attr1 = getattr(flow1, key, None) attr2 = getattr(flow2, key, None) if key == "components": - if not (isinstance(attr1, Dict) and isinstance(attr2, Dict)): + if not (isinstance(attr1, dict) and isinstance(attr2, dict)): raise TypeError("Cannot compare components because they are not dictionary.") for name in set(attr1.keys()).union(attr2.keys()): @@ -456,9 +393,9 @@ def assert_flows_equal( # noqa: C901, PLR0912, PLR0913, PLR0915 ) if ignore_parameter_values_on_older_children: - assert ( - flow1.upload_date is not None - ), "Flow1 has no upload date that allows us to compare age of children." + assert flow1.upload_date is not None, ( + "Flow1 has no upload date that allows us to compare age of children." + ) upload_date_current_flow = dateutil.parser.parse(flow1.upload_date) upload_date_parent_flow = dateutil.parser.parse( ignore_parameter_values_on_older_children, @@ -493,8 +430,8 @@ def assert_flows_equal( # noqa: C901, PLR0912, PLR0913, PLR0915 # iterating over the parameter's meta info list for param in params1: if ( - isinstance(flow1.parameters_meta_info[param], Dict) - and isinstance(flow2.parameters_meta_info[param], Dict) + isinstance(flow1.parameters_meta_info[param], dict) + and isinstance(flow2.parameters_meta_info[param], dict) and "data_type" in flow1.parameters_meta_info[param] and "data_type" in flow2.parameters_meta_info[param] ): @@ -550,5 +487,20 @@ def delete_flow(flow_id: int) -> bool: ------- bool True if the deletion was successful. False otherwise. + + Raises + ------ + OpenMLServerException + If the server-side deletion fails due to permissions or other errors. + + Side Effects + ------------ + - Removes the flow from the OpenML server (if permitted). + + Examples + -------- + >>> import openml + >>> # Deletes flow 23 if you are the uploader and it's not linked to runs + >>> openml.flows.delete_flow(23) # doctest: +SKIP """ - return openml.utils._delete_entity("flow", flow_id) + return openml._backend.flow.delete(flow_id) diff --git a/openml/runs/__init__.py b/openml/runs/__init__.py index 6d3dca504..2f068a2e6 100644 --- a/openml/runs/__init__.py +++ b/openml/runs/__init__.py @@ -19,14 +19,14 @@ "OpenMLRun", "OpenMLRunTrace", "OpenMLTraceIteration", - "run_model_on_task", - "run_flow_on_task", + "delete_run", "get_run", - "list_runs", - "get_runs", "get_run_trace", - "run_exists", + "get_runs", "initialize_model_from_run", "initialize_model_from_trace", - "delete_run", + "list_runs", + "run_exists", + "run_flow_on_task", + "run_model_on_task", ] diff --git a/openml/runs/functions.py b/openml/runs/functions.py index 666b75c37..41b0c5ba5 100644 --- a/openml/runs/functions.py +++ b/openml/runs/functions.py @@ -6,8 +6,7 @@ import warnings from collections import OrderedDict from functools import partial -from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import numpy as np import pandas as pd @@ -16,11 +15,8 @@ from joblib.parallel import Parallel, delayed import openml -import openml._api_calls import openml.utils -from openml import config from openml.exceptions import ( - OpenMLCacheException, OpenMLRunsExistError, OpenMLServerException, PyOpenMLError, @@ -45,26 +41,255 @@ # Avoid import cycles: https://mypy.readthedocs.io/en/latest/common_issues.html#import-cycles if TYPE_CHECKING: - from openml.config import _Config + from openml._config import _Config from openml.extensions.extension_interface import Extension # get_dict is in run.py to avoid circular imports -RUNS_CACHE_DIR_NAME = "runs" ERROR_CODE = 512 +def _validate_flow_and_task_inputs( + flow: OpenMLFlow | OpenMLTask, + task: OpenMLTask | OpenMLFlow, + flow_tags: list[str] | None, +) -> tuple[OpenMLFlow, OpenMLTask]: + """Validate and normalize inputs for flow and task execution. + + Parameters + ---------- + flow : OpenMLFlow or OpenMLTask + The flow object (may be swapped with task for backward compatibility). + task : OpenMLTask or OpenMLFlow + The task object (may be swapped with flow for backward compatibility). + flow_tags : List[str] or None + A list of tags that the flow should have at creation. + + Returns + ------- + Tuple[OpenMLFlow, OpenMLTask] + The validated flow and task. + + Raises + ------ + ValueError + If flow_tags is not a list or task is not published. + """ + if flow_tags is not None and not isinstance(flow_tags, list): + raise ValueError("flow_tags should be a list") + + # TODO: At some point in the future do not allow for arguments in old order (changed 6-2018). + # Flexibility currently still allowed due to code-snippet in OpenML100 paper (3-2019). + if isinstance(flow, OpenMLTask) and isinstance(task, OpenMLFlow): + # We want to allow either order of argument (to avoid confusion). + warnings.warn( + "run_flow_on_task: the old argument order (task, flow) is deprecated and " + "will not be supported in the future. Please use the " + "order (flow, task).", + DeprecationWarning, + stacklevel=3, + ) + task, flow = flow, task + + if not isinstance(flow, OpenMLFlow): + raise TypeError( + f"run_flow_on_task: expected argument 'flow' to be OpenMLFlow, " + f"got {type(flow).__name__}", + ) + + if not isinstance(task, OpenMLTask): + raise TypeError( + f"run_flow_on_task: expected argument 'task' to be OpenMLTask, " + f"got {type(task).__name__}", + ) + + if task.task_id is None: + raise ValueError( + "run_flow_on_task: argument 'task.task_id' is None; task must be published on OpenML" + ) + + return flow, task + + +def _sync_flow_with_server( + flow: OpenMLFlow, + task: OpenMLTask, + *, + upload_flow: bool, + avoid_duplicate_runs: bool, +) -> int | None: + """Synchronize flow with server and check if setup/task combination is already present. + + Parameters + ---------- + flow : OpenMLFlow + The flow to synchronize. + task : OpenMLTask + The task to check for duplicate runs. + upload_flow : bool + Whether to upload the flow if it doesn't exist. + avoid_duplicate_runs : bool + Whether to check for duplicate runs. + + Returns + ------- + int or None + The flow_id if synced with server, None otherwise. + + Raises + ------ + PyOpenMLError + If flow_id mismatch or flow doesn't exist when expected. + OpenMLRunsExistError + If duplicate runs exist and avoid_duplicate_runs is True. + """ + # We only need to sync with the server right now if we want to upload the flow, + # or ensure no duplicate runs exist. Otherwise it can be synced at upload time. + flow_id = None + if not upload_flow and not avoid_duplicate_runs: + return flow_id + + flow_id = flow_exists(flow.name, flow.external_version) + if isinstance(flow.flow_id, int) and flow_id != flow.flow_id: + if flow_id is not False: + raise PyOpenMLError( + f"Local flow_id does not match server flow_id: '{flow.flow_id}' vs '{flow_id}'", + ) + raise PyOpenMLError("Flow does not exist on the server, but 'flow.flow_id' is not None.") + + if upload_flow and flow_id is False: + flow.publish() + return flow.flow_id + + if flow_id: + flow_from_server = get_flow(flow_id) + _copy_server_fields(flow_from_server, flow) + if avoid_duplicate_runs: + flow_from_server.model = flow.model + setup_id = setup_exists(flow_from_server) + ids = run_exists(cast("int", task.task_id), setup_id) + if ids: + error_message = "One or more runs of this setup were already performed on the task." + raise OpenMLRunsExistError(ids, error_message) + return flow_id + + # Flow does not exist on server and we do not want to upload it. + # No sync with the server happens. + return None + + +def _prepare_run_environment(flow: OpenMLFlow) -> tuple[list[str], list[str]]: + """Prepare run environment information and tags. + + Parameters + ---------- + flow : OpenMLFlow + The flow to get version information from. + + Returns + ------- + Tuple[List[str], List[str]] + A tuple of (tags, run_environment). + """ + run_environment = flow.extension.get_version_information() + tags = ["openml-python", run_environment[1]] + return tags, run_environment + + +def _create_run_from_results( # noqa: PLR0913,PLR0917 + task: OpenMLTask, + flow: OpenMLFlow, + flow_id: int | None, + data_content: list[list], + trace: OpenMLRunTrace | None, + fold_evaluations: OrderedDict[str, OrderedDict], + sample_evaluations: OrderedDict[str, OrderedDict], + tags: list[str], + run_environment: list[str], + upload_flow: bool, + avoid_duplicate_runs: bool, +) -> OpenMLRun: + """Create an OpenMLRun object from execution results. + + Parameters + ---------- + task : OpenMLTask + The task that was executed. + flow : OpenMLFlow + The flow that was executed. + flow_id : int or None + The flow ID if synced with server. + data_content : List[List] + The prediction data content. + trace : OpenMLRunTrace or None + The execution trace if available. + fold_evaluations : OrderedDict + The fold-based evaluation measures. + sample_evaluations : OrderedDict + The sample-based evaluation measures. + tags : List[str] + Tags to attach to the run. + run_environment : List[str] + Environment information. + upload_flow : bool + Whether the flow was uploaded. + avoid_duplicate_runs : bool + Whether duplicate runs were checked. + + Returns + ------- + OpenMLRun + The created run object. + """ + dataset = task.get_dataset() + task_id = cast("int", task.task_id) + dataset_id = dataset.dataset_id + model = flow.model + flow_name = flow.name + setup_string = flow.extension.create_setup_string(flow.model) + fields = [*run_environment, time.strftime("%c"), "Created by run_flow_on_task"] + generated_description = "\n".join(fields) + + run = OpenMLRun( + task_id=task_id, + flow_id=flow_id, + dataset_id=dataset_id, + model=model, + flow_name=flow_name, + tags=tags, + trace=trace, + data_content=data_content, + flow=flow, + setup_string=setup_string, + description_text=generated_description, + ) + + if (upload_flow or avoid_duplicate_runs) and flow.flow_id is not None: + # We only extract the parameter settings if a sync happened with the server. + # I.e. when the flow was uploaded or we found it in the avoid_duplicate check. + # Otherwise, we will do this at upload time. + run.parameter_settings = flow.extension.obtain_parameter_values(flow) + + # now we need to attach the detailed evaluations + if task.task_type_id == TaskType.LEARNING_CURVE: + run.sample_evaluations = sample_evaluations + else: + run.fold_evaluations = fold_evaluations + + return run + + # TODO(eddiebergman): Could potentially overload this but # it seems very big to do so -def run_model_on_task( # noqa: PLR0913 +def run_model_on_task( # noqa: PLR0913,PLR0917 model: Any, task: int | str | OpenMLTask, avoid_duplicate_runs: bool | None = None, flow_tags: list[str] | None = None, seed: int | None = None, - add_local_measures: bool = True, # noqa: FBT001, FBT002 - upload_flow: bool = False, # noqa: FBT001, FBT002 - return_flow: bool = False, # noqa: FBT001, FBT002 + add_local_measures: bool = True, # noqa: FBT002 + upload_flow: bool = False, # noqa: FBT002 + return_flow: bool = False, # noqa: FBT002 n_jobs: int | None = None, ) -> OpenMLRun | tuple[OpenMLRun, OpenMLFlow]: """Run the model on the dataset defined by the task. @@ -104,10 +329,19 @@ def run_model_on_task( # noqa: PLR0913 Result of the run. flow : OpenMLFlow (optional, only if `return_flow` is True). Flow generated from the model. + + Examples + -------- + >>> import openml + >>> import openml_sklearn # doctest: +SKIP + >>> from sklearn.tree import DecisionTreeClassifier # doctest: +SKIP + >>> clf = DecisionTreeClassifier() # doctest: +SKIP + >>> task = openml.tasks.get_task(6) # doctest: +SKIP + >>> run = openml.runs.run_model_on_task(clf, task) # doctest: +SKIP """ if avoid_duplicate_runs is None: avoid_duplicate_runs = openml.config.avoid_duplicate_runs - if avoid_duplicate_runs and not config.apikey: + if avoid_duplicate_runs and not openml.config.apikey: warnings.warn( "avoid_duplicate_runs is set to True, but no API key is set. " "Please set your API key in the OpenML configuration file, see" @@ -175,14 +409,14 @@ def get_task_and_type_conversion(_task: int | str | OpenMLTask) -> OpenMLTask: return run -def run_flow_on_task( # noqa: C901, PLR0912, PLR0915, PLR0913 +def run_flow_on_task( # noqa: PLR0913,PLR0917 flow: OpenMLFlow, task: OpenMLTask, avoid_duplicate_runs: bool | None = None, flow_tags: list[str] | None = None, seed: int | None = None, - add_local_measures: bool = True, # noqa: FBT001, FBT002 - upload_flow: bool = False, # noqa: FBT001, FBT002 + add_local_measures: bool = True, # noqa: FBT002 + upload_flow: bool = False, # noqa: FBT002 n_jobs: int | None = None, ) -> OpenMLRun: """Run the model provided by the flow on the dataset defined by task. @@ -222,71 +456,29 @@ def run_flow_on_task( # noqa: C901, PLR0912, PLR0915, PLR0913 run : OpenMLRun Result of the run. """ - if flow_tags is not None and not isinstance(flow_tags, list): - raise ValueError("flow_tags should be a list") - if avoid_duplicate_runs is None: avoid_duplicate_runs = openml.config.avoid_duplicate_runs - # TODO: At some point in the future do not allow for arguments in old order (changed 6-2018). - # Flexibility currently still allowed due to code-snippet in OpenML100 paper (3-2019). - if isinstance(flow, OpenMLTask) and isinstance(task, OpenMLFlow): - # We want to allow either order of argument (to avoid confusion). - warnings.warn( - "The old argument order (Flow, model) is deprecated and " - "will not be supported in the future. Please use the " - "order (model, Flow).", - DeprecationWarning, - stacklevel=2, - ) - task, flow = flow, task - - if task.task_id is None: - raise ValueError("The task should be published at OpenML") + # 1. Validate inputs + flow, task = _validate_flow_and_task_inputs(flow, task, flow_tags) + # 2. Prepare the model if flow.model is None: flow.model = flow.extension.flow_to_model(flow) - flow.model = flow.extension.seed_model(flow.model, seed=seed) - # We only need to sync with the server right now if we want to upload the flow, - # or ensure no duplicate runs exist. Otherwise it can be synced at upload time. - flow_id = None - if upload_flow or avoid_duplicate_runs: - flow_id = flow_exists(flow.name, flow.external_version) - if isinstance(flow.flow_id, int) and flow_id != flow.flow_id: - if flow_id is not False: - raise PyOpenMLError( - f"Local flow_id does not match server flow_id: '{flow.flow_id}' vs '{flow_id}'", - ) - raise PyOpenMLError( - "Flow does not exist on the server, but 'flow.flow_id' is not None." - ) - if upload_flow and flow_id is False: - flow.publish() - flow_id = flow.flow_id - elif flow_id: - flow_from_server = get_flow(flow_id) - _copy_server_fields(flow_from_server, flow) - if avoid_duplicate_runs: - flow_from_server.model = flow.model - setup_id = setup_exists(flow_from_server) - ids = run_exists(task.task_id, setup_id) - if ids: - error_message = ( - "One or more runs of this setup were already performed on the task." - ) - raise OpenMLRunsExistError(ids, error_message) - else: - # Flow does not exist on server and we do not want to upload it. - # No sync with the server happens. - flow_id = None + # 3. Sync with server and check for duplicates + flow_id = _sync_flow_with_server( + flow, + task, + upload_flow=upload_flow, + avoid_duplicate_runs=avoid_duplicate_runs, + ) - dataset = task.get_dataset() - - run_environment = flow.extension.get_version_information() - tags = ["openml-python", run_environment[1]] + # 4. Prepare run environment + tags, run_environment = _prepare_run_environment(flow) + # 5. Check if model is already fitted if flow.extension.check_if_model_fitted(flow.model): warnings.warn( "The model is already fitted! This might cause inconsistency in comparison of results.", @@ -294,8 +486,8 @@ def run_flow_on_task( # noqa: C901, PLR0912, PLR0915, PLR0913 stacklevel=2, ) - # execute the run - res = _run_task_get_arffcontent( + # 6. Execute the run (parallel processing happens here) + data_content, trace, fold_evaluations, sample_evaluations = _run_task_get_arffcontent( model=flow.model, task=task, extension=flow.extension, @@ -303,40 +495,27 @@ def run_flow_on_task( # noqa: C901, PLR0912, PLR0915, PLR0913 n_jobs=n_jobs, ) - data_content, trace, fold_evaluations, sample_evaluations = res - fields = [*run_environment, time.strftime("%c"), "Created by run_flow_on_task"] - generated_description = "\n".join(fields) - run = OpenMLRun( - task_id=task.task_id, + # 7. Create run from results + run = _create_run_from_results( + task=task, + flow=flow, flow_id=flow_id, - dataset_id=dataset.dataset_id, - model=flow.model, - flow_name=flow.name, - tags=tags, - trace=trace, data_content=data_content, - flow=flow, - setup_string=flow.extension.create_setup_string(flow.model), - description_text=generated_description, + trace=trace, + fold_evaluations=fold_evaluations, + sample_evaluations=sample_evaluations, + tags=tags, + run_environment=run_environment, + upload_flow=upload_flow, + avoid_duplicate_runs=avoid_duplicate_runs, ) - if (upload_flow or avoid_duplicate_runs) and flow.flow_id is not None: - # We only extract the parameter settings if a sync happened with the server. - # I.e. when the flow was uploaded or we found it in the avoid_duplicate check. - # Otherwise, we will do this at upload time. - run.parameter_settings = flow.extension.obtain_parameter_values(flow) - - # now we need to attach the detailed evaluations - if task.task_type_id == TaskType.LEARNING_CURVE: - run.sample_evaluations = sample_evaluations - else: - run.fold_evaluations = fold_evaluations - + # 8. Log completion message if flow_id: message = f"Executed Task {task.task_id} with Flow id:{run.flow_id}" else: message = f"Executed Task {task.task_id} on local Flow with name {flow.name}." - config.logger.info(message) + openml.config.logger.info(message) return run @@ -353,7 +532,7 @@ def get_run_trace(run_id: int) -> OpenMLRunTrace: ------- openml.runs.OpenMLTrace """ - trace_xml = openml._api_calls._perform_api_call("run/trace/%d" % run_id, "get") + trace_xml = openml._backend.run.download_text_file(f"run/trace/{run_id}") return OpenMLRunTrace.trace_from_xml(trace_xml) @@ -376,7 +555,8 @@ def initialize_model_from_run(run_id: int, *, strict_version: bool = True) -> An run = get_run(run_id) # TODO(eddiebergman): I imagine this is None if it's not published, # might need to raise an explicit error for that - assert run.setup_id is not None + if run.setup_id is None: + raise ValueError(f"Run {run_id} has no associated setup_id. Cannot initialize model.") return initialize_model(setup_id=run.setup_id, strict_version=strict_version) @@ -416,7 +596,8 @@ def initialize_model_from_trace( run = get_run(run_id) # TODO(eddiebergman): I imagine this is None if it's not published, # might need to raise an explicit error for that - assert run.flow_id is not None + if run.flow_id is None: + raise ValueError(f"Run {run_id} has no associated flow_id. Cannot initialize model.") flow = get_flow(run.flow_id) run_trace = get_run_trace(run_id) @@ -528,7 +709,7 @@ def _run_task_get_arffcontent( # noqa: PLR0915, PLR0912, C901 # The forked child process may not copy the configuration state of OpenML from the parent. # Current configuration setup needs to be copied and passed to the child processes. - _config = config.get_config_as_dict() + _config = openml.config.get_config_as_dict() # Execute runs in parallel # assuming the same number of tasks as workers (n_jobs), the total compute time for this # statement will be similar to the slowest run @@ -557,9 +738,14 @@ def _run_task_get_arffcontent( # noqa: PLR0915, PLR0912, C901 ) # job_rvals contain the output of all the runs with one-to-one correspondence with `jobs` for n_fit, rep_no, fold_no, sample_no in jobs: - pred_y, proba_y, test_indices, test_y, inner_trace, user_defined_measures_fold = job_rvals[ - n_fit - 1 - ] + ( + pred_y, + proba_y, + test_indices, + test_y, + inner_trace, + user_defined_measures_fold, + ) = job_rvals[n_fit - 1] if inner_trace is not None: traces.append(inner_trace) @@ -576,8 +762,10 @@ def _calculate_local_measure( # type: ignore _user_defined_measures_fold[openml_name] = sklearn_fn(_test_y, _pred_y) if isinstance(task, (OpenMLClassificationTask, OpenMLLearningCurveTask)): - assert test_y is not None - assert proba_y is not None + if test_y is None: + raise ValueError("test_y cannot be None for classification tasks.") + if proba_y is None: + raise ValueError("proba_y cannot be None for classification tasks.") for i, tst_idx in enumerate(test_indices): if task.class_labels is not None: @@ -608,7 +796,7 @@ def _calculate_local_measure( # type: ignore index=tst_idx, prediction=prediction, truth=truth, - proba=dict(zip(task.class_labels, pred_prob)), + proba=dict(zip(task.class_labels, pred_prob, strict=False)), ) else: raise ValueError("The task has no class labels") @@ -622,7 +810,8 @@ def _calculate_local_measure( # type: ignore ) elif isinstance(task, OpenMLRegressionTask): - assert test_y is not None + if test_y is None: + raise ValueError("test_y cannot be None for regression tasks.") for i, _ in enumerate(test_indices): truth = test_y.iloc[i] if isinstance(test_y, pd.Series) else test_y[i] arff_line = format_prediction( @@ -687,7 +876,7 @@ def _calculate_local_measure( # type: ignore ) -def _run_task_get_arffcontent_parallel_helper( # noqa: PLR0913 +def _run_task_get_arffcontent_parallel_helper( # noqa: PLR0913,PLR0917 extension: Extension, fold_no: int, model: Any, @@ -733,7 +922,7 @@ def _run_task_get_arffcontent_parallel_helper( # noqa: PLR0913 """ # Sets up the OpenML instantiated in the child process to match that of the parent's # if configuration=None, loads the default - config._setup(configuration) + openml.config._setup(configuration) train_indices, test_indices = task.get_train_test_split_indices( repeat=rep_no, @@ -743,7 +932,8 @@ def _run_task_get_arffcontent_parallel_helper( # noqa: PLR0913 if isinstance(task, OpenMLSupervisedTask): x, y = task.get_X_and_y() - assert isinstance(y, (pd.Series, pd.DataFrame)) + if not isinstance(y, (pd.Series, pd.DataFrame)): + raise TypeError(f"y must be a pandas Series or DataFrame, got {type(y).__name__}") train_x = x.iloc[train_indices] train_y = y.iloc[train_indices] test_x = x.iloc[test_indices] @@ -755,9 +945,14 @@ def _run_task_get_arffcontent_parallel_helper( # noqa: PLR0913 test_x = None test_y = None else: - raise NotImplementedError(task.task_type) + raise NotImplementedError( + f"Task type '{task.task_type}' is not supported. " + f"Only OpenMLSupervisedTask and OpenMLClusteringTask are currently implemented. " + f"Task details: task_id={getattr(task, 'task_id', 'unknown')}, " + f"task_class={task.__class__.__name__}" + ) - config.logger.info( + openml.config.logger.info( f"Going to run model {model!s} on " f"dataset {openml.datasets.get_dataset(task.dataset_id).name} " f"for repeat {rep_no} fold {fold_no} sample {sample_no}" @@ -798,7 +993,7 @@ def get_runs(run_ids: list[int]) -> list[OpenMLRun]: @openml.utils.thread_safe_if_oslo_installed -def get_run(run_id: int, ignore_cache: bool = False) -> OpenMLRun: # noqa: FBT002, FBT001 +def get_run(run_id: int, ignore_cache: bool = False) -> OpenMLRun: # noqa: FBT002 """Gets run corresponding to run_id. Parameters @@ -809,33 +1004,21 @@ def get_run(run_id: int, ignore_cache: bool = False) -> OpenMLRun: # noqa: FBT0 Whether to ignore the cache. If ``true`` this will download and overwrite the run xml even if the requested run is already cached. - ignore_cache - Returns ------- run : OpenMLRun Run corresponding to ID, fetched from the server. """ - run_dir = Path(openml.utils._create_cache_directory_for_id(RUNS_CACHE_DIR_NAME, run_id)) - run_file = run_dir / "description.xml" - - run_dir.mkdir(parents=True, exist_ok=True) - - try: - if not ignore_cache: - return _get_cached_run(run_id) - - raise OpenMLCacheException(message="dummy") - - except OpenMLCacheException: - run_xml = openml._api_calls._perform_api_call("run/%d" % run_id, "get") - with run_file.open("w", encoding="utf8") as fh: - fh.write(run_xml) - - return _create_run_from_xml(run_xml) + return openml._backend.run.get( + run_id, + reset_cache=ignore_cache, + ) -def _create_run_from_xml(xml: str, from_server: bool = True) -> OpenMLRun: # noqa: PLR0915, PLR0912, C901, FBT001, FBT002 +def _create_run_from_xml( # noqa: PLR0915, PLR0912, C901 + xml: str, + from_server: bool = True, # noqa: FBT002 +) -> OpenMLRun: """Create a run object from xml returned from server. Parameters @@ -977,18 +1160,24 @@ def obtain_field(xml_obj, fieldname, from_server, cast=None): # type: ignore evaluations[key] = value if "description" not in files and from_server is True: - raise ValueError("No description file for run %d in run description XML" % run_id) + raise ValueError(f"No description file for run {run_id} in run description XML") if "predictions" not in files and from_server is True: task = openml.tasks.get_task(task_id) if task.task_type_id == TaskType.SUBGROUP_DISCOVERY: - raise NotImplementedError("Subgroup discovery tasks are not yet supported.") + raise NotImplementedError( + f"Subgroup discovery tasks are not yet supported. " + f"Task ID: {task_id}. Please check the OpenML documentation" + f"for supported task types. " + f"Currently supported task types: Classification, Regression," + f"Clustering, and Learning Curve." + ) # JvR: actually, I am not sure whether this error should be raised. # a run can consist without predictions. But for now let's keep it # Matthias: yes, it should stay as long as we do not really handle # this stuff - raise ValueError("No prediction files for run %d in run description XML" % run_id) + raise ValueError(f"No prediction files for run {run_id} in run description XML") tags = openml.utils.extract_xml_tags("oml:tag", run) @@ -1016,18 +1205,7 @@ def obtain_field(xml_obj, fieldname, from_server, cast=None): # type: ignore ) -def _get_cached_run(run_id: int) -> OpenMLRun: - """Load a run from the cache.""" - run_cache_dir = openml.utils._create_cache_directory_for_id(RUNS_CACHE_DIR_NAME, run_id) - run_file = run_cache_dir / "description.xml" - try: - with run_file.open(encoding="utf8") as fh: - return _create_run_from_xml(xml=fh.read()) - except OSError as e: - raise OpenMLCacheException(f"Run file for run id {run_id} not cached") from e - - -def list_runs( # noqa: PLR0913 +def list_runs( # noqa: PLR0913,PLR0917 offset: int | None = None, size: int | None = None, id: list | None = None, # noqa: A002 @@ -1037,7 +1215,7 @@ def list_runs( # noqa: PLR0913 uploader: list | None = None, tag: str | None = None, study: int | None = None, - display_errors: bool = False, # noqa: FBT001, FBT002 + display_errors: bool = False, # noqa: FBT002 task_type: TaskType | int | None = None, ) -> pd.DataFrame: """ @@ -1087,8 +1265,8 @@ def list_runs( # noqa: PLR0913 raise TypeError("uploader must be of type list.") listing_call = partial( - _list_runs, - id=id, + openml._backend.run.list, + ids=id, task=task, setup=setup, flow=flow, @@ -1105,122 +1283,7 @@ def list_runs( # noqa: PLR0913 return pd.concat(batches) -def _list_runs( # noqa: PLR0913, C901 - limit: int, - offset: int, - *, - id: list | None = None, # noqa: A002 - task: list | None = None, - setup: list | None = None, - flow: list | None = None, - uploader: list | None = None, - study: int | None = None, - tag: str | None = None, - display_errors: bool = False, - task_type: TaskType | int | None = None, -) -> pd.DataFrame: - """ - Perform API call `/run/list/{filters}' - ` - - Parameters - ---------- - The arguments that are lists are separated from the single value - ones which are put into the kwargs. - display_errors is also separated from the kwargs since it has a - default value. - - id : list, optional - - task : list, optional - - setup: list, optional - - flow : list, optional - - tag: str, optional - - uploader : list, optional - - study : int, optional - - display_errors : bool, optional (default=None) - Whether to list runs which have an error (for example a missing - prediction file). - - task_type : str, optional - - Returns - ------- - dict, or dataframe - List of found runs. - """ - api_call = "run/list" - if limit is not None: - api_call += f"/limit/{limit}" - if offset is not None: - api_call += f"/offset/{offset}" - if id is not None: - api_call += f"/run/{','.join([str(int(i)) for i in id])}" - if task is not None: - api_call += f"/task/{','.join([str(int(i)) for i in task])}" - if setup is not None: - api_call += f"/setup/{','.join([str(int(i)) for i in setup])}" - if flow is not None: - api_call += f"/flow/{','.join([str(int(i)) for i in flow])}" - if uploader is not None: - api_call += f"/uploader/{','.join([str(int(i)) for i in uploader])}" - if study is not None: - api_call += "/study/%d" % study - if display_errors: - api_call += "/show_errors/true" - if tag is not None: - api_call += f"/tag/{tag}" - if task_type is not None: - tvalue = task_type.value if isinstance(task_type, TaskType) else task_type - api_call += f"/task_type/{tvalue}" - return __list_runs(api_call=api_call) - - -def __list_runs(api_call: str) -> pd.DataFrame: - """Helper function to parse API calls which are lists of runs""" - xml_string = openml._api_calls._perform_api_call(api_call, "get") - runs_dict = xmltodict.parse(xml_string, force_list=("oml:run",)) - # Minimalistic check if the XML is useful - if "oml:runs" not in runs_dict: - raise ValueError(f'Error in return XML, does not contain "oml:runs": {runs_dict}') - - if "@xmlns:oml" not in runs_dict["oml:runs"]: - raise ValueError( - f'Error in return XML, does not contain "oml:runs"/@xmlns:oml: {runs_dict}' - ) - - if runs_dict["oml:runs"]["@xmlns:oml"] != "http://openml.org/openml": - raise ValueError( - "Error in return XML, value of " - '"oml:runs"/@xmlns:oml is not ' - f'"http://openml.org/openml": {runs_dict}', - ) - - assert isinstance(runs_dict["oml:runs"]["oml:run"], list), type(runs_dict["oml:runs"]) - - runs = { - int(r["oml:run_id"]): { - "run_id": int(r["oml:run_id"]), - "task_id": int(r["oml:task_id"]), - "setup_id": int(r["oml:setup_id"]), - "flow_id": int(r["oml:flow_id"]), - "uploader": int(r["oml:uploader"]), - "task_type": TaskType(int(r["oml:task_type_id"])), - "upload_time": str(r["oml:upload_time"]), - "error_message": str((r["oml:error_message"]) or ""), - } - for r in runs_dict["oml:runs"]["oml:run"] - } - return pd.DataFrame.from_dict(runs, orient="index") - - -def format_prediction( # noqa: PLR0913 +def format_prediction( # noqa: PLR0913,PLR0917 task: OpenMLSupervisedTask, repeat: int, fold: int, @@ -1282,7 +1345,12 @@ def format_prediction( # noqa: PLR0913 if isinstance(task, OpenMLRegressionTask): return [repeat, fold, index, prediction, truth] - raise NotImplementedError(f"Formatting for {type(task)} is not supported.") + raise NotImplementedError( + f"Formatting for {type(task)} is not supported." + f"Supported task types: OpenMLClassificationTask, OpenMLRegressionTask," + f"and OpenMLLearningCurveTask. " + f"Please ensure your task is one of these types." + ) def delete_run(run_id: int) -> bool: @@ -1300,4 +1368,4 @@ def delete_run(run_id: int) -> bool: bool True if the deletion was successful. False otherwise. """ - return openml.utils._delete_entity("run", run_id) + return openml._backend.run.delete(run_id) diff --git a/openml/runs/run.py b/openml/runs/run.py index 945264131..0f0fbb223 100644 --- a/openml/runs/run.py +++ b/openml/runs/run.py @@ -8,8 +8,6 @@ from typing import ( TYPE_CHECKING, Any, - Callable, - Sequence, ) import arff @@ -17,7 +15,6 @@ import pandas as pd import openml -import openml._api_calls from openml.base import OpenMLBase from openml.exceptions import PyOpenMLError from openml.flows import OpenMLFlow, get_flow @@ -32,6 +29,8 @@ ) if TYPE_CHECKING: + from collections.abc import Callable, Sequence + from openml.runs.trace import OpenMLRunTrace @@ -95,7 +94,7 @@ class OpenMLRun(OpenMLBase): Description of the run stored in the run meta-data. """ - def __init__( # noqa: PLR0913 + def __init__( # noqa: PLR0913,PLR0917 self, task_id: int, flow_id: int | None, @@ -155,10 +154,11 @@ def __init__( # noqa: PLR0913 def predictions(self) -> pd.DataFrame: """Return a DataFrame with predictions for this run""" if self._predictions is None: + arff_dict: dict[str, Any] if self.data_content: arff_dict = self._generate_arff_dict() elif self.predictions_url: - arff_text = openml._api_calls._download_text_file(self.predictions_url) + arff_text = openml._backend.run.download_text_file(self.predictions_url) arff_dict = arff.loads(arff_text) else: raise RuntimeError("Run has no predictions.") @@ -280,7 +280,7 @@ def _get_repr_body_fields(self) -> Sequence[tuple[str, str | int | list[str]]]: ] @classmethod - def from_filesystem(cls, directory: str | Path, expect_model: bool = True) -> OpenMLRun: # noqa: FBT001, FBT002 + def from_filesystem(cls, directory: str | Path, expect_model: bool = True) -> OpenMLRun: # noqa: FBT002 """ The inverse of the to_filesystem method. Instantiates an OpenMLRun object based on files stored on the file system. @@ -344,10 +344,41 @@ def from_filesystem(cls, directory: str | Path, expect_model: bool = True) -> Op return run + def publish(self) -> OpenMLRun: + """Publish the run object on the OpenML server.""" + file_elements = self._get_file_elements() + + if "description" not in file_elements: + file_elements["description"] = self._to_xml() + + result = openml._backend.run.publish(path="run", files=file_elements) + self.run_id = result + return self + + def push_tag(self, tag: str) -> None: + """Push a tag for this run on the OpenML server.""" + if self.run_id is None: + raise openml.exceptions.ObjectNotPublishedError( + "Cannot tag a run that has not been published yet." + " Please publish the run first before being able to tag it.", + ) + + openml._backend.run.tag(self.run_id, tag) + + def remove_tag(self, tag: str) -> None: + """Remove a tag for this run on the OpenML server.""" + if self.run_id is None: + raise openml.exceptions.ObjectNotPublishedError( + "Cannot untag a run that has not been published yet." + " Please publish the run first before being able to untag it.", + ) + + openml._backend.run.untag(self.run_id, tag) + def to_filesystem( self, directory: str | Path, - store_model: bool = True, # noqa: FBT001, FBT002 + store_model: bool = True, # noqa: FBT002 ) -> None: """ The inverse of the from_filesystem method. Serializes a run @@ -365,7 +396,7 @@ def to_filesystem( model. """ if self.data_content is None or self.model is None: - raise ValueError("Run should have been executed (and contain " "model / predictions)") + raise ValueError("Run should have been executed (and contain model / predictions)") directory = Path(directory) directory.mkdir(exist_ok=True, parents=True) @@ -390,6 +421,57 @@ def to_filesystem( if self.trace is not None: self.trace._to_filesystem(directory) + def _get_arff_attributes_for_task(self, task: OpenMLTask) -> list[tuple[str, Any]]: + """Get ARFF attributes based on task type. + + Parameters + ---------- + task : OpenMLTask + The task for which to generate attributes. + + Returns + ------- + list[tuple[str, Any]] + List of attribute tuples (name, type). + """ + instance_specifications = [ + ("repeat", "NUMERIC"), + ("fold", "NUMERIC"), + ] + + if isinstance(task, (OpenMLLearningCurveTask, OpenMLClassificationTask)): + instance_specifications.append(("sample", "NUMERIC")) + + instance_specifications.append(("row_id", "NUMERIC")) + + if isinstance(task, (OpenMLLearningCurveTask, OpenMLClassificationTask)): + class_labels = task.class_labels + if class_labels is None: + raise ValueError("The task has no class labels") + + prediction_confidences = [ + ("confidence." + class_labels[i], "NUMERIC") for i in range(len(class_labels)) + ] + prediction_and_true = [("prediction", class_labels), ("correct", class_labels)] + return instance_specifications + prediction_and_true + prediction_confidences + + if isinstance(task, OpenMLRegressionTask): + return [*instance_specifications, ("prediction", "NUMERIC"), ("truth", "NUMERIC")] + + if isinstance(task, OpenMLClusteringTask): + return [*instance_specifications, ("cluster", "NUMERIC")] + + supported_task_types = [ + TaskType.SUPERVISED_CLASSIFICATION, + TaskType.SUPERVISED_REGRESSION, + TaskType.CLUSTERING, + TaskType.LEARNING_CURVE, + ] + raise NotImplementedError( + f"Task type {task.task_type!s} for task_id {getattr(task, 'task_id', None)!s} " + f"is not yet supported. Supported task types are: {supported_task_types!r}" + ) + def _generate_arff_dict(self) -> OrderedDict[str, Any]: """Generates the arff dictionary for uploading predictions to the server. @@ -407,7 +489,8 @@ def _generate_arff_dict(self) -> OrderedDict[str, Any]: if self.data_content is None: raise ValueError("Run has not been executed.") if self.flow is None: - assert self.flow_id is not None, "Run has no associated flow id!" + if self.flow_id is None: + raise ValueError("Run has no associated flow id!") self.flow = get_flow(self.flow_id) if self.description_text is None: @@ -418,69 +501,7 @@ def _generate_arff_dict(self) -> OrderedDict[str, Any]: arff_dict["data"] = self.data_content arff_dict["description"] = self.description_text arff_dict["relation"] = f"openml_task_{task.task_id}_predictions" - - if isinstance(task, OpenMLLearningCurveTask): - class_labels = task.class_labels - instance_specifications = [ - ("repeat", "NUMERIC"), - ("fold", "NUMERIC"), - ("sample", "NUMERIC"), - ("row_id", "NUMERIC"), - ] - - arff_dict["attributes"] = instance_specifications - if class_labels is not None: - arff_dict["attributes"] = ( - arff_dict["attributes"] - + [("prediction", class_labels), ("correct", class_labels)] - + [ - ("confidence." + class_labels[i], "NUMERIC") - for i in range(len(class_labels)) - ] - ) - else: - raise ValueError("The task has no class labels") - - elif isinstance(task, OpenMLClassificationTask): - class_labels = task.class_labels - instance_specifications = [ - ("repeat", "NUMERIC"), - ("fold", "NUMERIC"), - ("sample", "NUMERIC"), # Legacy - ("row_id", "NUMERIC"), - ] - - arff_dict["attributes"] = instance_specifications - if class_labels is not None: - prediction_confidences = [ - ("confidence." + class_labels[i], "NUMERIC") for i in range(len(class_labels)) - ] - prediction_and_true = [("prediction", class_labels), ("correct", class_labels)] - arff_dict["attributes"] = ( - arff_dict["attributes"] + prediction_and_true + prediction_confidences - ) - else: - raise ValueError("The task has no class labels") - - elif isinstance(task, OpenMLRegressionTask): - arff_dict["attributes"] = [ - ("repeat", "NUMERIC"), - ("fold", "NUMERIC"), - ("row_id", "NUMERIC"), - ("prediction", "NUMERIC"), - ("truth", "NUMERIC"), - ] - - elif isinstance(task, OpenMLClusteringTask): - arff_dict["attributes"] = [ - ("repeat", "NUMERIC"), - ("fold", "NUMERIC"), - ("row_id", "NUMERIC"), - ("cluster", "NUMERIC"), - ] - - else: - raise NotImplementedError(f"Task type {task.task_type!s} is not yet supported.") + arff_dict["attributes"] = self._get_arff_attributes_for_task(task) return arff_dict @@ -505,19 +526,20 @@ def get_metric_fn(self, sklearn_fn: Callable, kwargs: dict | None = None) -> np. metric results """ kwargs = kwargs if kwargs else {} + predictions_arff: dict[str, Any] if self.data_content is not None and self.task_id is not None: predictions_arff = self._generate_arff_dict() elif (self.output_files is not None) and ("predictions" in self.output_files): - predictions_file_url = openml._api_calls._file_id_to_url( + predictions_file_url = openml._backend.run.file_id_to_url( self.output_files["predictions"], "predictions.arff", ) - response = openml._api_calls._download_text_file(predictions_file_url) - predictions_arff = arff.loads(response) + predictions_text = openml._backend.run.download_text_file(predictions_file_url) + predictions_arff = arff.loads(predictions_text) # TODO: make this a stream reader else: raise ValueError( - "Run should have been locally executed or " "contain outputfile reference.", + "Run should have been locally executed or contain outputfile reference.", ) # Need to know more about the task to compute scores correctly @@ -528,11 +550,11 @@ def get_metric_fn(self, sklearn_fn: Callable, kwargs: dict | None = None) -> np. task.task_type_id in [TaskType.SUPERVISED_CLASSIFICATION, TaskType.LEARNING_CURVE] and "correct" not in attribute_names ): - raise ValueError('Attribute "correct" should be set for ' "classification task runs") + raise ValueError('Attribute "correct" should be set for classification task runs') if task.task_type_id == TaskType.SUPERVISED_REGRESSION and "truth" not in attribute_names: - raise ValueError('Attribute "truth" should be set for ' "regression task runs") + raise ValueError('Attribute "truth" should be set for regression task runs') if task.task_type_id != TaskType.CLUSTERING and "prediction" not in attribute_names: - raise ValueError('Attribute "predict" should be set for ' "supervised task runs") + raise ValueError('Attribute "prediction" should be set for supervised task runs') def _attribute_list_to_dict(attribute_list): # type: ignore # convenience function: Creates a mapping to map from the name of @@ -566,7 +588,7 @@ def _attribute_list_to_dict(attribute_list): # type: ignore pred = predictions_arff["attributes"][predicted_idx][1] corr = predictions_arff["attributes"][correct_idx][1] raise ValueError( - "Predicted and Correct do not have equal values:" f" {pred!s} Vs. {corr!s}", + f"Predicted and Correct do not have equal values: {pred!s} Vs. {corr!s}", ) # TODO: these could be cached @@ -602,7 +624,7 @@ def _attribute_list_to_dict(attribute_list): # type: ignore values_correct[rep][fold][samp].append(correct) scores = [] - for rep in values_predict: + for rep in values_predict: # noqa: PLC0206, RUF100 for fold in values_predict[rep]: last_sample = len(values_predict[rep][fold]) - 1 y_pred = values_predict[rep][fold][last_sample] @@ -637,7 +659,10 @@ def _get_file_elements(self) -> dict: if self.parameter_settings is None: if self.flow is None: - assert self.flow_id is not None # for mypy + if self.flow_id is None: + raise ValueError( + "Run has no associated flow_id and cannot obtain parameter values." + ) self.flow = openml.flows.get_flow(self.flow_id) self.parameter_settings = self.flow.extension.obtain_parameter_values( self.flow, diff --git a/openml/runs/trace.py b/openml/runs/trace.py index bc9e1b5d6..d5441ae1b 100644 --- a/openml/runs/trace.py +++ b/openml/runs/trace.py @@ -5,12 +5,15 @@ from collections import OrderedDict from dataclasses import dataclass from pathlib import Path -from typing import IO, Any, Iterator +from typing import IO, TYPE_CHECKING, Any from typing_extensions import Self import arff import xmltodict +if TYPE_CHECKING: + from collections.abc import Iterator + PREFIX = "parameter_" REQUIRED_ATTRIBUTES = [ "repeat", @@ -93,7 +96,8 @@ def get_parameters(self) -> dict[str, Any]: for param, value in self.setup_string.items() } - assert self.parameters is not None + if self.parameters is None: + raise ValueError("Parameters must be set before calling get_parameters().") return {param[len(PREFIX) :]: value for param, value in self.parameters.items()} @@ -149,9 +153,7 @@ def get_selected_iteration(self, fold: int, repeat: int) -> int: for r, f, i in self.trace_iterations: if r == repeat and f == fold and self.trace_iterations[(r, f, i)].selected is True: return i - raise ValueError( - "Could not find the selected iteration for rep/fold %d/%d" % (repeat, fold), - ) + raise ValueError(f"Could not find the selected iteration for rep/fold {repeat}/{fold}") @classmethod def generate( @@ -185,8 +187,7 @@ def generate( raise ValueError("Trace content is empty.") if len(attributes) != len(content[0]): raise ValueError( - "Trace_attributes and trace_content not compatible:" - f" {attributes} vs {content[0]}", + f"Trace_attributes and trace_content not compatible: {attributes} vs {content[0]}", ) return cls._trace_from_arff_struct( @@ -492,13 +493,21 @@ def merge_traces(cls, traces: list[OpenMLRunTrace]) -> OpenMLRunTrace: for iteration in trace: key = (iteration.repeat, iteration.fold, iteration.iteration) - assert iteration.parameters is not None + if iteration.parameters is None: + raise ValueError( + f"Iteration parameters cannot be None for repeat {iteration.repeat}, " + f"fold {iteration.fold}, iteration {iteration.iteration}" + ) param_keys = iteration.parameters.keys() if previous_iteration is not None: trace_itr = merged_trace[previous_iteration] - assert trace_itr.parameters is not None + if trace_itr.parameters is None: + raise ValueError( + f"Trace iteration parameters cannot be None " + f"for iteration {previous_iteration}" + ) trace_itr_keys = trace_itr.parameters.keys() if list(param_keys) != list(trace_itr_keys): diff --git a/openml/setups/__init__.py b/openml/setups/__init__.py index dd38cb9b7..fa4072059 100644 --- a/openml/setups/__init__.py +++ b/openml/setups/__init__.py @@ -4,10 +4,10 @@ from .setup import OpenMLParameter, OpenMLSetup __all__ = [ - "OpenMLSetup", "OpenMLParameter", + "OpenMLSetup", "get_setup", + "initialize_model", "list_setups", "setup_exists", - "initialize_model", ] diff --git a/openml/setups/functions.py b/openml/setups/functions.py index 374911901..f6b118b07 100644 --- a/openml/setups/functions.py +++ b/openml/setups/functions.py @@ -1,23 +1,23 @@ # License: BSD 3-Clause from __future__ import annotations -from collections import OrderedDict +from collections.abc import Iterable from functools import partial from itertools import chain -from pathlib import Path -from typing import Any, Iterable -from typing_extensions import Literal +from typing import TYPE_CHECKING, Any, Literal import pandas as pd -import xmltodict import openml import openml.exceptions import openml.utils -from openml import config from openml.flows import OpenMLFlow, flow_exists -from .setup import OpenMLParameter, OpenMLSetup +if TYPE_CHECKING: + from .setup import OpenMLSetup + +if TYPE_CHECKING: + from collections.abc import Iterable def setup_exists(flow: OpenMLFlow) -> int: @@ -52,50 +52,8 @@ def setup_exists(flow: OpenMLFlow) -> int: ) openml_param_settings = flow.extension.obtain_parameter_values(flow) - description = xmltodict.unparse(_to_dict(flow.flow_id, openml_param_settings), pretty=True) - file_elements = { - "description": ("description.arff", description), - } # type: openml._api_calls.FILE_ELEMENTS_TYPE - result = openml._api_calls._perform_api_call( - "/setup/exists/", - "post", - file_elements=file_elements, - ) - result_dict = xmltodict.parse(result) - setup_id = int(result_dict["oml:setup_exists"]["oml:id"]) - return setup_id if setup_id > 0 else False - - -def _get_cached_setup(setup_id: int) -> OpenMLSetup: - """Load a run from the cache. - - Parameters - ---------- - setup_id : int - ID of the setup to be loaded. - Returns - ------- - OpenMLSetup - The loaded setup object. - - Raises - ------ - OpenMLCacheException - If the setup file for the given setup ID is not cached. - """ - cache_dir = Path(config.get_cache_directory()) - setup_cache_dir = cache_dir / "setups" / str(setup_id) - try: - setup_file = setup_cache_dir / "description.xml" - with setup_file.open(encoding="utf8") as fh: - setup_xml = xmltodict.parse(fh.read()) - return _create_setup_from_xml(setup_xml) - - except OSError as e: - raise openml.exceptions.OpenMLCacheException( - "Setup file for setup id %d not cached" % setup_id, - ) from e + return openml._backend.setup.exists(flow, openml_param_settings) def get_setup(setup_id: int) -> OpenMLSetup: @@ -112,24 +70,11 @@ def get_setup(setup_id: int) -> OpenMLSetup: ------- OpenMLSetup (an initialized openml setup object) """ - setup_dir = Path(config.get_cache_directory()) / "setups" / str(setup_id) - setup_dir.mkdir(exist_ok=True, parents=True) - - setup_file = setup_dir / "description.xml" - - try: - return _get_cached_setup(setup_id) - except openml.exceptions.OpenMLCacheException: - url_suffix = f"/setup/{setup_id}" - setup_xml = openml._api_calls._perform_api_call(url_suffix, "get") - with setup_file.open("w", encoding="utf8") as fh: - fh.write(setup_xml) - - result_dict = xmltodict.parse(setup_xml) - return _create_setup_from_xml(result_dict) + setup: OpenMLSetup = openml._backend.setup.get(setup_id=setup_id) + return setup -def list_setups( # noqa: PLR0913 +def list_setups( # noqa: PLR0913,PLR0917 offset: int | None = None, size: int | None = None, flow: int | None = None, @@ -161,7 +106,7 @@ def list_setups( # noqa: PLR0913 "Invalid output format selected. Only 'object', or 'dataframe' applicable.", ) - listing_call = partial(_list_setups, flow=flow, tag=tag, setup=setup) + listing_call = partial(openml._backend.setup.list, flow=flow, tag=tag, setup=setup) batches = openml.utils._list_all( listing_call, batch_size=1_000, # batch size for setups is lower @@ -176,77 +121,6 @@ def list_setups( # noqa: PLR0913 return pd.DataFrame.from_records(records, index="setup_id") -def _list_setups( - limit: int, - offset: int, - *, - setup: Iterable[int] | None = None, - flow: int | None = None, - tag: str | None = None, -) -> list[OpenMLSetup]: - """Perform API call `/setup/list/{filters}` - - Parameters - ---------- - The setup argument that is a list is separated from the single value - filters which are put into the kwargs. - - limit : int - offset : int - setup : list(int), optional - flow : int, optional - tag : str, optional - - Returns - ------- - The setups that match the filters, going from id to the OpenMLSetup object. - """ - api_call = "setup/list" - if limit is not None: - api_call += f"/limit/{limit}" - if offset is not None: - api_call += f"/offset/{offset}" - if setup is not None: - api_call += f"/setup/{','.join([str(int(i)) for i in setup])}" - if flow is not None: - api_call += f"/flow/{flow}" - if tag is not None: - api_call += f"/tag/{tag}" - - return __list_setups(api_call=api_call) - - -def __list_setups(api_call: str) -> list[OpenMLSetup]: - """Helper function to parse API calls which are lists of setups""" - xml_string = openml._api_calls._perform_api_call(api_call, "get") - setups_dict = xmltodict.parse(xml_string, force_list=("oml:setup",)) - openml_uri = "http://openml.org/openml" - # Minimalistic check if the XML is useful - if "oml:setups" not in setups_dict: - raise ValueError( - f'Error in return XML, does not contain "oml:setups": {setups_dict!s}', - ) - - if "@xmlns:oml" not in setups_dict["oml:setups"]: - raise ValueError( - f'Error in return XML, does not contain "oml:setups"/@xmlns:oml: {setups_dict!s}', - ) - - if setups_dict["oml:setups"]["@xmlns:oml"] != openml_uri: - raise ValueError( - "Error in return XML, value of " - '"oml:seyups"/@xmlns:oml is not ' - f'"{openml_uri}": {setups_dict!s}', - ) - - assert isinstance(setups_dict["oml:setups"]["oml:setup"], list), type(setups_dict["oml:setups"]) - - return [ - _create_setup_from_xml({"oml:setup_parameters": setup_}) - for setup_ in setups_dict["oml:setups"]["oml:setup"] - ] - - def initialize_model(setup_id: int, *, strict_version: bool = True) -> Any: """ Initialized a model based on a setup_id (i.e., using the exact @@ -279,69 +153,3 @@ def initialize_model(setup_id: int, *, strict_version: bool = True) -> Any: subflow.parameters[hyperparameter.parameter_name] = hyperparameter.value return flow.extension.flow_to_model(flow, strict_version=strict_version) - - -def _to_dict(flow_id: int, openml_parameter_settings: list[dict[str, Any]]) -> OrderedDict: - """Convert a flow ID and a list of OpenML parameter settings to - a dictionary representation that can be serialized to XML. - - Parameters - ---------- - flow_id : int - ID of the flow. - openml_parameter_settings : list[dict[str, Any]] - A list of OpenML parameter settings. - - Returns - ------- - OrderedDict - A dictionary representation of the flow ID and parameter settings. - """ - # for convenience, this function (ab)uses the run object. - xml: OrderedDict = OrderedDict() - xml["oml:run"] = OrderedDict() - xml["oml:run"]["@xmlns:oml"] = "http://openml.org/openml" - xml["oml:run"]["oml:flow_id"] = flow_id - xml["oml:run"]["oml:parameter_setting"] = openml_parameter_settings - - return xml - - -def _create_setup_from_xml(result_dict: dict) -> OpenMLSetup: - """Turns an API xml result into a OpenMLSetup object (or dict)""" - setup_id = int(result_dict["oml:setup_parameters"]["oml:setup_id"]) - flow_id = int(result_dict["oml:setup_parameters"]["oml:flow_id"]) - - if "oml:parameter" not in result_dict["oml:setup_parameters"]: - return OpenMLSetup(setup_id, flow_id, parameters=None) - - xml_parameters = result_dict["oml:setup_parameters"]["oml:parameter"] - if isinstance(xml_parameters, dict): - parameters = { - int(xml_parameters["oml:id"]): _create_setup_parameter_from_xml(xml_parameters), - } - elif isinstance(xml_parameters, list): - parameters = { - int(xml_parameter["oml:id"]): _create_setup_parameter_from_xml(xml_parameter) - for xml_parameter in xml_parameters - } - else: - raise ValueError( - f"Expected None, list or dict, received something else: {type(xml_parameters)!s}", - ) - - return OpenMLSetup(setup_id, flow_id, parameters) - - -def _create_setup_parameter_from_xml(result_dict: dict[str, str]) -> OpenMLParameter: - """Create an OpenMLParameter object or a dictionary from an API xml result.""" - return OpenMLParameter( - input_id=int(result_dict["oml:id"]), - flow_id=int(result_dict["oml:flow_id"]), - flow_name=result_dict["oml:flow_name"], - full_name=result_dict["oml:full_name"], - parameter_name=result_dict["oml:parameter_name"], - data_type=result_dict["oml:data_type"], - default_value=result_dict["oml:default_value"], - value=result_dict["oml:value"], - ) diff --git a/openml/setups/setup.py b/openml/setups/setup.py index 0960ad4c1..0e88d54d9 100644 --- a/openml/setups/setup.py +++ b/openml/setups/setup.py @@ -1,13 +1,18 @@ # License: BSD 3-Clause from __future__ import annotations -from typing import Any +from dataclasses import asdict, dataclass +from typing import TYPE_CHECKING, Any -import openml.config import openml.flows +from openml.utils import ReprMixin +if TYPE_CHECKING: + from collections.abc import Sequence -class OpenMLSetup: + +@dataclass(repr=False) +class OpenMLSetup(ReprMixin): """Setup object (a.k.a. Configuration). Parameters @@ -20,20 +25,20 @@ class OpenMLSetup: The setting of the parameters """ - def __init__(self, setup_id: int, flow_id: int, parameters: dict[int, Any] | None): - if not isinstance(setup_id, int): + setup_id: int + flow_id: int + parameters: dict[int, Any] | None + + def __post_init__(self) -> None: + if not isinstance(self.setup_id, int): raise ValueError("setup id should be int") - if not isinstance(flow_id, int): + if not isinstance(self.flow_id, int): raise ValueError("flow id should be int") - if parameters is not None and not isinstance(parameters, dict): + if self.parameters is not None and not isinstance(self.parameters, dict): raise ValueError("parameters should be dict") - self.setup_id = setup_id - self.flow_id = flow_id - self.parameters = parameters - def _to_dict(self) -> dict[str, Any]: return { "setup_id": self.setup_id, @@ -43,30 +48,22 @@ def _to_dict(self) -> dict[str, Any]: else None, } - def __repr__(self) -> str: - header = "OpenML Setup" - header = f"{header}\n{'=' * len(header)}\n" - - fields = { + def _get_repr_body_fields(self) -> Sequence[tuple[str, str | int | list[str] | None]]: + """Collect all information to display in the __repr__ body.""" + fields: dict[str, int | str | None] = { "Setup ID": self.setup_id, "Flow ID": self.flow_id, "Flow URL": openml.flows.OpenMLFlow.url_for_id(self.flow_id), - "# of Parameters": ( - len(self.parameters) if self.parameters is not None else float("nan") - ), + "# of Parameters": (len(self.parameters) if self.parameters is not None else "nan"), } # determines the order in which the information will be printed order = ["Setup ID", "Flow ID", "Flow URL", "# of Parameters"] - _fields = [(key, fields[key]) for key in order if key in fields] - - longest_field_name_length = max(len(name) for name, _ in _fields) - field_line_format = f"{{:.<{longest_field_name_length}}}: {{}}" - body = "\n".join(field_line_format.format(name, value) for name, value in _fields) - return header + body + return [(key, fields[key]) for key in order if key in fields] -class OpenMLParameter: +@dataclass(repr=False) +class OpenMLParameter(ReprMixin): """Parameter object (used in setup). Parameters @@ -91,43 +88,28 @@ class OpenMLParameter: If the parameter was set, the value that it was set to. """ - def __init__( # noqa: PLR0913 - self, - input_id: int, - flow_id: int, - flow_name: str, - full_name: str, - parameter_name: str, - data_type: str, - default_value: str, - value: str, - ): - self.id = input_id - self.flow_id = flow_id - self.flow_name = flow_name - self.full_name = full_name - self.parameter_name = parameter_name - self.data_type = data_type - self.default_value = default_value - self.value = value - - def _to_dict(self) -> dict[str, Any]: - return { - "id": self.id, - "flow_id": self.flow_id, - "flow_name": self.flow_name, - "full_name": self.full_name, - "parameter_name": self.parameter_name, - "data_type": self.data_type, - "default_value": self.default_value, - "value": self.value, - } + input_id: int + flow_id: int + flow_name: str + full_name: str + parameter_name: str + data_type: str + default_value: str + value: str - def __repr__(self) -> str: - header = "OpenML Parameter" - header = f"{header}\n{'=' * len(header)}\n" + def __post_init__(self) -> None: + # Map input_id to id for backward compatibility + self.id = self.input_id - fields = { + def _to_dict(self) -> dict[str, Any]: + result = asdict(self) + # Replaces input_id with id for backward compatibility + result["id"] = result.pop("input_id") + return result + + def _get_repr_body_fields(self) -> Sequence[tuple[str, str | int | list[str] | None]]: + """Collect all information to display in the __repr__ body.""" + fields: dict[str, int | str | None] = { "ID": self.id, "Flow ID": self.flow_id, # "Flow Name": self.flow_name, @@ -156,9 +138,4 @@ def __repr__(self) -> str: parameter_default, parameter_value, ] - _fields = [(key, fields[key]) for key in order if key in fields] - - longest_field_name_length = max(len(name) for name, _ in _fields) - field_line_format = f"{{:.<{longest_field_name_length}}}: {{}}" - body = "\n".join(field_line_format.format(name, value) for name, value in _fields) - return header + body + return [(key, fields[key]) for key in order if key in fields] diff --git a/openml/study/__init__.py b/openml/study/__init__.py index b7d77fec4..37a6d376a 100644 --- a/openml/study/__init__.py +++ b/openml/study/__init__.py @@ -19,8 +19,8 @@ from .study import OpenMLBenchmarkSuite, OpenMLStudy __all__ = [ - "OpenMLStudy", "OpenMLBenchmarkSuite", + "OpenMLStudy", "attach_to_study", "attach_to_suite", "create_benchmark_suite", @@ -33,6 +33,6 @@ "get_suite", "list_studies", "list_suites", - "update_suite_status", "update_study_status", + "update_suite_status", ] diff --git a/openml/study/functions.py b/openml/study/functions.py index 4e16879d7..4af25d236 100644 --- a/openml/study/functions.py +++ b/openml/study/functions.py @@ -1,16 +1,14 @@ # License: BSD 3-Clause -# ruff: noqa: PLR0913 from __future__ import annotations import warnings from functools import partial -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING import pandas as pd import xmltodict import openml._api_calls -import openml.config import openml.utils from openml.study.study import OpenMLBenchmarkSuite, OpenMLStudy @@ -31,6 +29,12 @@ def get_suite(suite_id: int | str) -> OpenMLBenchmarkSuite: ------- OpenMLSuite The OpenML suite object + + Examples + -------- + >>> import openml + >>> suite = openml.study.get_suite(99) # doctest: +SKIP + >>> suite = openml.study.get_suite("OpenML-CC18") # doctest: +SKIP """ study = _get_study(suite_id, entity_type="task") assert isinstance(study, OpenMLBenchmarkSuite) @@ -60,6 +64,11 @@ def get_study( ------- OpenMLStudy The OpenML study object + + Examples + -------- + >>> import openml + >>> study = openml.study.get_study(1) # doctest: +SKIP """ if study_id == "OpenML100": message = ( @@ -110,7 +119,10 @@ def _get_study(id_: int | str, entity_type: str) -> BaseStudy: tags = [] if "oml:tag" in result_dict: for tag in result_dict["oml:tag"]: - current_tag = {"name": tag["oml:name"], "write_access": tag["oml:write_access"]} + current_tag = { + "name": tag["oml:name"], + "write_access": tag["oml:write_access"], + } if "oml:window_start" in tag: current_tag["window_start"] = tag["oml:window_start"] tags.append(current_tag) @@ -193,8 +205,6 @@ def create_study( Parameters ---------- - benchmark_suite : int (optional) - the benchmark suite (another study) upon which this study is ran. name : str the name of the study (meta-info) description : str @@ -211,6 +221,15 @@ def create_study( ------- OpenMLStudy A local OpenML study object (call publish method to upload to server) + + Examples + -------- + >>> import openml + >>> study = openml.study.create_study( # doctest: +SKIP + ... name="My Study", + ... description="A study on decision trees", + ... run_ids=[1, 2, 3], + ... ) """ return OpenMLStudy( study_id=None, @@ -338,7 +357,8 @@ def delete_study(study_id: int) -> bool: bool True iff the deletion was successful. False otherwise """ - return openml.utils._delete_entity("study", study_id) + result: bool = openml._backend.study.delete(study_id) + return result def attach_to_suite(suite_id: int, task_ids: list[int]) -> int: @@ -422,7 +442,7 @@ def detach_from_study(study_id: int, run_ids: list[int]) -> int: new size of the study (in terms of explicitly linked entities) """ # Interestingly, there's no need to tell the server about the entity type, it knows by itself - uri = "study/%d/detach" % study_id + uri = f"study/{study_id}/detach" post_variables = {"ids": ",".join(str(x) for x in run_ids)} # type: openml._api_calls.DATA_TYPE result_xml = openml._api_calls._perform_api_call( call=uri, @@ -467,7 +487,7 @@ def list_suites( - creation_date """ listing_call = partial( - _list_studies, + openml._backend.study.list, main_entity_type="task", status=status, uploader=uploader, @@ -483,7 +503,7 @@ def list_studies( offset: int | None = None, size: int | None = None, status: str | None = None, - uploader: list[str] | None = None, + uploader: list[int] | None = None, benchmark_suite: int | None = None, ) -> pd.DataFrame: """ @@ -518,7 +538,7 @@ def list_studies( these are also returned. """ listing_call = partial( - _list_studies, + openml._backend.study.list, main_entity_type="run", status=status, uploader=uploader, @@ -529,81 +549,3 @@ def list_studies( return pd.DataFrame() return pd.concat(batches) - - -def _list_studies(limit: int, offset: int, **kwargs: Any) -> pd.DataFrame: - """Perform api call to return a list of studies. - - Parameters - ---------- - limit: int - The maximum number of studies to return. - offset: int - The number of studies to skip, starting from the first. - kwargs : dict, optional - Legal filter operators (keys in the dict): - status, main_entity_type, uploader, benchmark_suite - - Returns - ------- - studies : dataframe - """ - api_call = "study/list" - if limit is not None: - api_call += f"/limit/{limit}" - if offset is not None: - api_call += f"/offset/{offset}" - if kwargs is not None: - for operator, value in kwargs.items(): - if value is not None: - api_call += f"/{operator}/{value}" - return __list_studies(api_call=api_call) - - -def __list_studies(api_call: str) -> pd.DataFrame: - """Retrieves the list of OpenML studies and - returns it in a dictionary or a Pandas DataFrame. - - Parameters - ---------- - api_call : str - The API call for retrieving the list of OpenML studies. - - Returns - ------- - pd.DataFrame - A Pandas DataFrame of OpenML studies - """ - xml_string = openml._api_calls._perform_api_call(api_call, "get") - study_dict = xmltodict.parse(xml_string, force_list=("oml:study",)) - - # Minimalistic check if the XML is useful - assert isinstance(study_dict["oml:study_list"]["oml:study"], list), type( - study_dict["oml:study_list"], - ) - assert study_dict["oml:study_list"]["@xmlns:oml"] == "http://openml.org/openml", study_dict[ - "oml:study_list" - ]["@xmlns:oml"] - - studies = {} - for study_ in study_dict["oml:study_list"]["oml:study"]: - # maps from xml name to a tuple of (dict name, casting fn) - expected_fields = { - "oml:id": ("id", int), - "oml:alias": ("alias", str), - "oml:main_entity_type": ("main_entity_type", str), - "oml:benchmark_suite": ("benchmark_suite", int), - "oml:name": ("name", str), - "oml:status": ("status", str), - "oml:creation_date": ("creation_date", str), - "oml:creator": ("creator", int), - } - study_id = int(study_["oml:id"]) - current_study = {} - for oml_field_name, (real_field_name, cast_fn) in expected_fields.items(): - if oml_field_name in study_: - current_study[real_field_name] = cast_fn(study_[oml_field_name]) - current_study["id"] = int(current_study["id"]) - studies[study_id] = current_study - - return pd.DataFrame.from_dict(studies, orient="index") diff --git a/openml/study/study.py b/openml/study/study.py index 83bbf0497..1d0a18044 100644 --- a/openml/study/study.py +++ b/openml/study/study.py @@ -2,10 +2,13 @@ # TODO(eddiebergman): Begging for dataclassses to shorten this all from __future__ import annotations -from typing import Any, Sequence +from typing import TYPE_CHECKING, Any +import openml from openml.base import OpenMLBase -from openml.config import get_server_base_url + +if TYPE_CHECKING: + from collections.abc import Sequence class BaseStudy(OpenMLBase): @@ -56,7 +59,7 @@ class BaseStudy(OpenMLBase): a list of setup ids associated with this study """ - def __init__( # noqa: PLR0913 + def __init__( # noqa: PLR0913,PLR0917 self, study_id: int | None, alias: str | None, @@ -110,7 +113,7 @@ def _get_repr_body_fields(self) -> Sequence[tuple[str, str | int | list[str]]]: fields["ID"] = self.study_id fields["Study URL"] = self.openml_url if self.creator is not None: - fields["Creator"] = f"{get_server_base_url()}/u/{self.creator}" + fields["Creator"] = f"{openml.config.get_server_base_url()}/u/{self.creator}" if self.creation_date is not None: fields["Upload Time"] = self.creation_date.replace("T", " ") if self.data is not None: @@ -147,22 +150,18 @@ def _to_dict(self) -> dict[str, dict]: # some can not be uploaded, e.g., id, creator, creation_date simple_props = ["alias", "main_entity_type", "name", "description"] - # TODO(eddiebergman): Begging for a walrus if we can drop 3.7 simple_prop_values = {} for prop_name in simple_props: - content = getattr(self, prop_name, None) - if content is not None: + if (content := getattr(self, prop_name, None)) is not None: simple_prop_values["oml:" + prop_name] = content # maps from attribute name (which is used as outer tag name) to immer # tag name e.g., self.tasks -> 1987 complex_props = {"tasks": "task_id", "runs": "run_id"} - # TODO(eddiebergman): Begging for a walrus if we can drop 3.7 complex_prop_values = {} for prop_name, inner_name in complex_props.items(): - content = getattr(self, prop_name, None) - if content is not None: + if (content := getattr(self, prop_name, None)) is not None: complex_prop_values["oml:" + prop_name] = {"oml:" + inner_name: content} return { @@ -175,11 +174,23 @@ def _to_dict(self) -> dict[str, dict]: def push_tag(self, tag: str) -> None: """Add a tag to the study.""" - raise NotImplementedError("Tags for studies is not (yet) supported.") + raise NotImplementedError( + "Tag management for studies is not yet supported. " + "The OpenML Python SDK does not currently provide functionality" + "for adding tags to studies." + "For updates on this feature, please refer to the GitHub issues at: " + "https://github.com/openml/openml-python/issues" + ) def remove_tag(self, tag: str) -> None: """Remove a tag from the study.""" - raise NotImplementedError("Tags for studies is not (yet) supported.") + raise NotImplementedError( + "Tag management for studies is not yet supported. " + "The OpenML Python SDK does not currently provide functionality" + "for removing tags from studies. " + "For updates on this feature, please refer to the GitHub issues at: " + "https://github.com/openml/openml-python/issues" + ) class OpenMLStudy(BaseStudy): @@ -226,7 +237,7 @@ class OpenMLStudy(BaseStudy): a list of setup ids associated with this study """ - def __init__( # noqa: PLR0913 + def __init__( # noqa: PLR0913,PLR0917 self, study_id: int | None, alias: str | None, @@ -300,7 +311,7 @@ class OpenMLBenchmarkSuite(BaseStudy): a list of task ids associated with this study """ - def __init__( # noqa: PLR0913 + def __init__( # noqa: PLR0913,PLR0917 self, suite_id: int | None, alias: str | None, diff --git a/openml/tasks/__init__.py b/openml/tasks/__init__.py index f6df3a8d4..34c994e3a 100644 --- a/openml/tasks/__init__.py +++ b/openml/tasks/__init__.py @@ -19,17 +19,17 @@ ) __all__ = [ - "OpenMLTask", - "OpenMLSupervisedTask", - "OpenMLClusteringTask", - "OpenMLRegressionTask", "OpenMLClassificationTask", + "OpenMLClusteringTask", "OpenMLLearningCurveTask", + "OpenMLRegressionTask", + "OpenMLSplit", + "OpenMLSupervisedTask", + "OpenMLTask", + "TaskType", "create_task", + "delete_task", "get_task", "get_tasks", "list_tasks", - "OpenMLSplit", - "TaskType", - "delete_task", ] diff --git a/openml/tasks/functions.py b/openml/tasks/functions.py index d2bf5e946..0b60b9d43 100644 --- a/openml/tasks/functions.py +++ b/openml/tasks/functions.py @@ -38,7 +38,7 @@ def _get_cached_tasks() -> dict[int, OpenMLTask]: OpenMLTask. """ task_cache_dir = openml.utils._create_cache_directory(TASKS_CACHE_DIR_NAME) - directory_content = os.listdir(task_cache_dir) + directory_content = os.listdir(task_cache_dir) # noqa: PTH208, RUF100 directory_content.sort() # Find all dataset ids for which we have downloaded the dataset @@ -80,53 +80,11 @@ def _get_estimation_procedure_list() -> list[dict[str, Any]]: a dictionary containing the following information: id, task type id, name, type, repeats, folds, stratified. """ - url_suffix = "estimationprocedure/list" - xml_string = openml._api_calls._perform_api_call(url_suffix, "get") + result = openml._backend.estimation_procedure.list() + return [i._to_dict() for i in result] - procs_dict = xmltodict.parse(xml_string) - # Minimalistic check if the XML is useful - if "oml:estimationprocedures" not in procs_dict: - raise ValueError("Error in return XML, does not contain tag oml:estimationprocedures.") - - if "@xmlns:oml" not in procs_dict["oml:estimationprocedures"]: - raise ValueError( - "Error in return XML, does not contain tag " - "@xmlns:oml as a child of oml:estimationprocedures.", - ) - - if procs_dict["oml:estimationprocedures"]["@xmlns:oml"] != "http://openml.org/openml": - raise ValueError( - "Error in return XML, value of " - "oml:estimationprocedures/@xmlns:oml is not " - "http://openml.org/openml, but {}".format( - str(procs_dict["oml:estimationprocedures"]["@xmlns:oml"]) - ), - ) - - procs: list[dict[str, Any]] = [] - for proc_ in procs_dict["oml:estimationprocedures"]["oml:estimationprocedure"]: - task_type_int = int(proc_["oml:ttid"]) - try: - task_type_id = TaskType(task_type_int) - procs.append( - { - "id": int(proc_["oml:id"]), - "task_type_id": task_type_id, - "name": proc_["oml:name"], - "type": proc_["oml:type"], - }, - ) - except ValueError as e: - warnings.warn( - f"Could not create task type id for {task_type_int} due to error {e}", - RuntimeWarning, - stacklevel=2, - ) - return procs - - -def list_tasks( # noqa: PLR0913 +def list_tasks( # noqa: PLR0913,PLR0917 task_type: TaskType | None = None, offset: int | None = None, size: int | None = None, @@ -329,7 +287,7 @@ def __list_tasks(api_call: str) -> pd.DataFrame: # noqa: C901, PLR0912 except KeyError as e: if tid is not None: warnings.warn( - "Invalid xml for task %d: %s\nFrom %s" % (tid, e, task_), + f"Invalid xml for task {tid}: {e}\nFrom {task_}", RuntimeWarning, stacklevel=2, ) @@ -380,7 +338,11 @@ def get_tasks( tasks = [] for task_id in task_ids: tasks.append( - get_task(task_id, download_data=download_data, download_qualities=download_qualities) + get_task( + task_id, + download_data=download_data, + download_qualities=download_qualities, + ) ) return tasks @@ -388,7 +350,7 @@ def get_tasks( @openml.utils.thread_safe_if_oslo_installed def get_task( task_id: int, - download_splits: bool = False, # noqa: FBT001, FBT002 + download_splits: bool = False, # noqa: FBT002 **get_dataset_kwargs: Any, ) -> OpenMLTask: """Download OpenML task for a given task ID. @@ -411,12 +373,20 @@ def get_task( Returns ------- task: OpenMLTask + + Examples + -------- + >>> import openml + >>> task = openml.tasks.get_task(1) # doctest: +SKIP + >>> task = openml.tasks.get_task(1, download_splits=True) # doctest: +SKIP """ if not isinstance(task_id, int): raise TypeError(f"Task id should be integer, is {type(task_id)}") - tid_cache_dir = openml.utils._create_cache_directory_for_id(TASKS_CACHE_DIR_NAME, task_id) - + task_cache_directory = openml.utils._create_cache_directory_for_id( + TASKS_CACHE_DIR_NAME, task_id + ) + task_cache_directory_existed = task_cache_directory.exists() try: task = _get_task_description(task_id) dataset = get_dataset(task.dataset_id, **get_dataset_kwargs) @@ -424,13 +394,17 @@ def get_task( # Including class labels as part of task meta data handles # the case where data download was initially disabled if isinstance(task, (OpenMLClassificationTask, OpenMLLearningCurveTask)): + assert task.target_name is not None, ( + "Supervised tasks must define a target feature before retrieving class labels." + ) task.class_labels = dataset.retrieve_class_labels(task.target_name) # Clustering tasks do not have class labels # and do not offer download_split if download_splits and isinstance(task, OpenMLSupervisedTask): task.download_split() except Exception as e: - openml.utils._remove_cache_dir_for_id(TASKS_CACHE_DIR_NAME, tid_cache_dir) + if not task_cache_directory_existed: + openml.utils._remove_cache_dir_for_id(TASKS_CACHE_DIR_NAME, task_cache_directory) raise e return task @@ -442,7 +416,7 @@ def _get_task_description(task_id: int) -> OpenMLTask: except OpenMLCacheException: _cache_dir = openml.utils._create_cache_directory_for_id(TASKS_CACHE_DIR_NAME, task_id) xml_file = _cache_dir / "task.xml" - task_xml = openml._api_calls._perform_api_call("task/%d" % task_id, "get") + task_xml = openml._api_calls._perform_api_call(f"task/{task_id}", "get") with xml_file.open("w", encoding="utf8") as fh: fh.write(task_xml) @@ -526,7 +500,12 @@ def _create_task_from_xml(xml: str) -> OpenMLTask: TaskType.LEARNING_CURVE: OpenMLLearningCurveTask, }.get(task_type) if cls is None: - raise NotImplementedError(f"Task type {common_kwargs['task_type']} not supported.") + raise NotImplementedError( + f"Task type '{common_kwargs['task_type']}' is not supported. " + f"Supported task types: SUPERVISED_CLASSIFICATION," + f"SUPERVISED_REGRESSION, CLUSTERING, LEARNING_CURVE." + f"Please check the OpenML documentation for available task types." + ) return cls(**common_kwargs) # type: ignore @@ -582,9 +561,16 @@ def create_task( elif task_type == TaskType.SUPERVISED_REGRESSION: task_cls = OpenMLRegressionTask # type: ignore else: - raise NotImplementedError(f"Task type {task_type:d} not supported.") + raise NotImplementedError( + f"Task type ID {task_type:d} is not supported. " + f"Supported task type IDs: {TaskType.SUPERVISED_CLASSIFICATION.value}," + f"{TaskType.SUPERVISED_REGRESSION.value}, " + f"{TaskType.CLUSTERING.value}, {TaskType.LEARNING_CURVE.value}. " + f"Please refer to the TaskType enum for valid task type identifiers." + ) return task_cls( + task_id=None, task_type_id=task_type, task_type="None", # TODO: refactor to get task type string from ID. data_set_id=dataset_id, diff --git a/openml/tasks/split.py b/openml/tasks/split.py index 4e781df35..4a80fb47e 100644 --- a/openml/tasks/split.py +++ b/openml/tasks/split.py @@ -3,13 +3,18 @@ import pickle from collections import OrderedDict -from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from typing_extensions import NamedTuple import arff # type: ignore import numpy as np +from openml.utils import ReprMixin + +if TYPE_CHECKING: + from collections.abc import Sequence + from pathlib import Path + class Split(NamedTuple): """A single split of a dataset.""" @@ -18,7 +23,7 @@ class Split(NamedTuple): test: np.ndarray -class OpenMLSplit: +class OpenMLSplit(ReprMixin): """OpenML Split object. This class manages train-test splits for a dataset across multiple @@ -63,6 +68,22 @@ def __init__( self.folds = len(self.split[0]) self.samples = len(self.split[0][0]) + def _get_repr_body_fields(self) -> Sequence[tuple[str, str | int | list[str] | None]]: + """Collect all information to display in the __repr__ body.""" + fields: dict[str, int | str | None] = { + "Name": self.name, + "Description": ( + self.description if len(self.description) <= 80 else self.description[:77] + "..." + ), + "Repeats": self.repeats, + "Folds": self.folds, + "Samples": self.samples, + } + + order = ["Name", "Description", "Repeats", "Folds", "Samples"] + + return [(key, fields[key]) for key in order if key in fields] + def __eq__(self, other: Any) -> bool: if ( (not isinstance(self, type(other))) @@ -90,6 +111,8 @@ def __eq__(self, other: Any) -> bool: return False return True + __hash__ = None # type: ignore + @classmethod def _from_arff_file(cls, filename: Path) -> OpenMLSplit: # noqa: C901, PLR0912 repetitions = None diff --git a/openml/tasks/task.py b/openml/tasks/task.py index 395b52482..2bccd1af9 100644 --- a/openml/tasks/task.py +++ b/openml/tasks/task.py @@ -1,17 +1,16 @@ # License: BSD 3-Clause -# TODO(eddbergman): Seems like a lot of the subclasses could just get away with setting -# a `ClassVar` for whatever changes as their `__init__` defaults, less duplicated code. from __future__ import annotations +import logging import warnings from abc import ABC from enum import Enum -from pathlib import Path -from typing import TYPE_CHECKING, Any, Sequence +from typing import TYPE_CHECKING, Any, ClassVar from typing_extensions import TypedDict +import arff + import openml._api_calls -import openml.config from openml import datasets from openml.base import OpenMLBase from openml.utils import _create_cache_directory_for_id @@ -19,10 +18,15 @@ from .split import OpenMLSplit if TYPE_CHECKING: + from collections.abc import Sequence + import numpy as np import pandas as pd +logger = logging.getLogger(__name__) + + # TODO(eddiebergman): Should use `auto()` but might be too late if these numbers are used # and stored on server. class TaskType(Enum): @@ -70,31 +74,45 @@ class OpenMLTask(OpenMLBase): Refers to the URL of the data splits used for the OpenML task. """ - def __init__( # noqa: PLR0913 + DEFAULT_ESTIMATION_PROCEDURE_ID: ClassVar[int] = 1 + + def __init__( # noqa: PLR0913,PLR0917 self, task_id: int | None, task_type_id: TaskType, task_type: str, data_set_id: int, - estimation_procedure_id: int = 1, + estimation_procedure_id: int | None = None, estimation_procedure_type: str | None = None, estimation_parameters: dict[str, str] | None = None, evaluation_measure: str | None = None, data_splits_url: str | None = None, + target_name: str | None = None, ): self.task_id = int(task_id) if task_id is not None else None self.task_type_id = task_type_id self.task_type = task_type self.dataset_id = int(data_set_id) + self.target_name = target_name + resolved_estimation_procedure_id = self._resolve_estimation_procedure_id( + estimation_procedure_id, + ) self.evaluation_measure = evaluation_measure self.estimation_procedure: _EstimationProcedure = { "type": estimation_procedure_type, "parameters": estimation_parameters, "data_splits_url": data_splits_url, } - self.estimation_procedure_id = estimation_procedure_id + self.estimation_procedure_id = resolved_estimation_procedure_id self.split: OpenMLSplit | None = None + def _resolve_estimation_procedure_id(self, estimation_procedure_id: int | None) -> int: + return ( + estimation_procedure_id + if estimation_procedure_id is not None + else self.DEFAULT_ESTIMATION_PROCEDURE_ID + ) + @classmethod def _entity_letter(cls) -> str: return "t" @@ -128,7 +146,8 @@ def _get_repr_body_fields(self) -> Sequence[tuple[str, str | int | list[str]]]: if class_labels is not None: fields["# of Classes"] = len(class_labels) - if hasattr(self, "cost_matrix"): + cost_matrix = getattr(self, "cost_matrix", None) + if cost_matrix is not None: fields["Cost Matrix"] = "Available" # determines the order in which the information will be printed @@ -164,18 +183,6 @@ def get_train_test_split_indices( return self.split.get(repeat=repeat, fold=fold, sample=sample) - def _download_split(self, cache_file: Path) -> None: - # TODO(eddiebergman): Not sure about this try to read and error approach - try: - with cache_file.open(encoding="utf8"): - pass - except OSError: - split_url = self.estimation_procedure["data_splits_url"] - openml._api_calls._download_text_file( - source=str(split_url), - output_path=str(cache_file), - ) - def download_split(self) -> OpenMLSplit: """Download the OpenML split for a given task.""" # TODO(eddiebergman): Can this every be `None`? @@ -185,9 +192,23 @@ def download_split(self) -> OpenMLSplit: try: split = OpenMLSplit._from_arff_file(cached_split_file) - except OSError: + logger.debug("Loaded file from cache: %s", str(cached_split_file)) + except (OSError, arff.BadDataFormat): + logger.info("Failed to load file from cache: %s", str(cached_split_file)) + if cached_split_file.exists(): + logger.debug("Cleaning up old file") + cached_split_file.unlink() # Next, download and cache the associated split file - self._download_split(cached_split_file) + split_url = self.estimation_procedure["data_splits_url"] + openml._api_calls._download_text_file( + source=str(split_url), + output_path=str(cached_split_file), + ) + if cached_split_file.exists(): + logger.info("New file created of size %d", cached_split_file.stat().st_size) + else: + logger.info("Failed to create new file") + split = OpenMLSplit._from_arff_file(cached_split_file) return split @@ -249,13 +270,15 @@ class OpenMLSupervisedTask(OpenMLTask, ABC): Refers to the unique identifier of task. """ - def __init__( # noqa: PLR0913 + DEFAULT_ESTIMATION_PROCEDURE_ID: ClassVar[int] = 1 + + def __init__( # noqa: PLR0913,PLR0917 self, task_type_id: TaskType, task_type: str, data_set_id: int, target_name: str, - estimation_procedure_id: int = 1, + estimation_procedure_id: int | None = None, estimation_procedure_type: str | None = None, estimation_parameters: dict[str, str] | None = None, evaluation_measure: str | None = None, @@ -272,10 +295,9 @@ def __init__( # noqa: PLR0913 estimation_parameters=estimation_parameters, evaluation_measure=evaluation_measure, data_splits_url=data_splits_url, + target_name=target_name, ) - self.target_name = target_name - def get_X_and_y(self) -> tuple[pd.DataFrame, pd.Series | pd.DataFrame | None]: """Get data associated with the current task. @@ -290,7 +312,12 @@ def get_X_and_y(self) -> tuple[pd.DataFrame, pd.Series | pd.DataFrame | None]: TaskType.SUPERVISED_REGRESSION, TaskType.LEARNING_CURVE, ): - raise NotImplementedError(self.task_type) + raise NotImplementedError( + f"Task type '{self.task_type}' is not implemented for get_X_and_y(). " + f"Supported types: SUPERVISED_CLASSIFICATION, SUPERVISED_REGRESSION," + f"LEARNING_CURVE." + f"Task ID: {getattr(self, 'task_id', 'unknown')}. " + ) X, y, _, _ = dataset.get_data(target=self.target_name) return X, y @@ -325,6 +352,8 @@ class OpenMLClassificationTask(OpenMLSupervisedTask): Parameters ---------- + task_id : Union[int, None] + ID of the Classification task (if it already exists on OpenML). task_type_id : TaskType ID of the Classification task type. task_type : str @@ -333,7 +362,7 @@ class OpenMLClassificationTask(OpenMLSupervisedTask): ID of the OpenML dataset associated with the Classification task. target_name : str Name of the target variable. - estimation_procedure_id : int, default=None + estimation_procedure_id : int, default=1 ID of the estimation procedure for the Classification task. estimation_procedure_type : str, default=None Type of the estimation procedure. @@ -343,21 +372,21 @@ class OpenMLClassificationTask(OpenMLSupervisedTask): Name of the evaluation measure. data_splits_url : str, default=None URL of the data splits for the Classification task. - task_id : Union[int, None] - ID of the Classification task (if it already exists on OpenML). class_labels : List of str, default=None A list of class labels (for classification tasks). cost_matrix : array, default=None A cost matrix (for classification tasks). """ - def __init__( # noqa: PLR0913 + DEFAULT_ESTIMATION_PROCEDURE_ID: ClassVar[int] = 1 + + def __init__( # noqa: PLR0913,PLR0917 self, task_type_id: TaskType, task_type: str, data_set_id: int, target_name: str, - estimation_procedure_id: int = 1, + estimation_procedure_id: int | None = None, estimation_procedure_type: str | None = None, estimation_parameters: dict[str, str] | None = None, evaluation_measure: str | None = None, @@ -367,22 +396,21 @@ def __init__( # noqa: PLR0913 cost_matrix: np.ndarray | None = None, ): super().__init__( - task_id=task_id, task_type_id=task_type_id, task_type=task_type, data_set_id=data_set_id, + target_name=target_name, estimation_procedure_id=estimation_procedure_id, estimation_procedure_type=estimation_procedure_type, estimation_parameters=estimation_parameters, evaluation_measure=evaluation_measure, - target_name=target_name, data_splits_url=data_splits_url, + task_id=task_id, ) self.class_labels = class_labels self.cost_matrix = cost_matrix - if cost_matrix is not None: - raise NotImplementedError("Costmatrix") + raise NotImplementedError("Costmatrix functionality is not yet implemented.") class OpenMLRegressionTask(OpenMLSupervisedTask): @@ -390,6 +418,8 @@ class OpenMLRegressionTask(OpenMLSupervisedTask): Parameters ---------- + task_id : Union[int, None] + ID of the OpenML Regression task. task_type_id : TaskType Task type ID of the OpenML Regression task. task_type : str @@ -398,7 +428,7 @@ class OpenMLRegressionTask(OpenMLSupervisedTask): ID of the OpenML dataset. target_name : str Name of the target feature used in the Regression task. - estimation_procedure_id : int, default=None + estimation_procedure_id : int, default=7 ID of the OpenML estimation procedure. estimation_procedure_type : str, default=None Type of the OpenML estimation procedure. @@ -406,37 +436,11 @@ class OpenMLRegressionTask(OpenMLSupervisedTask): Parameters used by the OpenML estimation procedure. data_splits_url : str, default=None URL of the OpenML data splits for the Regression task. - task_id : Union[int, None] - ID of the OpenML Regression task. evaluation_measure : str, default=None Evaluation measure used in the Regression task. """ - def __init__( # noqa: PLR0913 - self, - task_type_id: TaskType, - task_type: str, - data_set_id: int, - target_name: str, - estimation_procedure_id: int = 7, - estimation_procedure_type: str | None = None, - estimation_parameters: dict[str, str] | None = None, - data_splits_url: str | None = None, - task_id: int | None = None, - evaluation_measure: str | None = None, - ): - super().__init__( - task_id=task_id, - task_type_id=task_type_id, - task_type=task_type, - data_set_id=data_set_id, - estimation_procedure_id=estimation_procedure_id, - estimation_procedure_type=estimation_procedure_type, - estimation_parameters=estimation_parameters, - evaluation_measure=evaluation_measure, - target_name=target_name, - data_splits_url=data_splits_url, - ) + DEFAULT_ESTIMATION_PROCEDURE_ID: ClassVar[int] = 7 class OpenMLClusteringTask(OpenMLTask): @@ -444,16 +448,16 @@ class OpenMLClusteringTask(OpenMLTask): Parameters ---------- + task_id : Union[int, None] + ID of the OpenML clustering task. task_type_id : TaskType Task type ID of the OpenML clustering task. task_type : str Task type of the OpenML clustering task. data_set_id : int ID of the OpenML dataset used in clustering the task. - estimation_procedure_id : int, default=None + estimation_procedure_id : int, default=17 ID of the OpenML estimation procedure. - task_id : Union[int, None] - ID of the OpenML clustering task. estimation_procedure_type : str, default=None Type of the OpenML estimation procedure used in the clustering task. estimation_parameters : dict, default=None @@ -467,32 +471,7 @@ class OpenMLClusteringTask(OpenMLTask): feature set for the clustering task. """ - def __init__( # noqa: PLR0913 - self, - task_type_id: TaskType, - task_type: str, - data_set_id: int, - estimation_procedure_id: int = 17, - task_id: int | None = None, - estimation_procedure_type: str | None = None, - estimation_parameters: dict[str, str] | None = None, - data_splits_url: str | None = None, - evaluation_measure: str | None = None, - target_name: str | None = None, - ): - super().__init__( - task_id=task_id, - task_type_id=task_type_id, - task_type=task_type, - data_set_id=data_set_id, - evaluation_measure=evaluation_measure, - estimation_procedure_id=estimation_procedure_id, - estimation_procedure_type=estimation_procedure_type, - estimation_parameters=estimation_parameters, - data_splits_url=data_splits_url, - ) - - self.target_name = target_name + DEFAULT_ESTIMATION_PROCEDURE_ID: ClassVar[int] = 17 def get_X(self) -> pd.DataFrame: """Get data associated with the current task. @@ -528,6 +507,8 @@ class OpenMLLearningCurveTask(OpenMLClassificationTask): Parameters ---------- + task_id : Union[int, None] + ID of the Learning Curve task. task_type_id : TaskType ID of the Learning Curve task. task_type : str @@ -536,7 +517,7 @@ class OpenMLLearningCurveTask(OpenMLClassificationTask): ID of the dataset that this task is associated with. target_name : str Name of the target feature in the dataset. - estimation_procedure_id : int, default=None + estimation_procedure_id : int, default=13 ID of the estimation procedure to use for evaluating models. estimation_procedure_type : str, default=None Type of the estimation procedure. @@ -544,8 +525,6 @@ class OpenMLLearningCurveTask(OpenMLClassificationTask): Additional parameters for the estimation procedure. data_splits_url : str, default=None URL of the file containing the data splits for Learning Curve task. - task_id : Union[int, None] - ID of the Learning Curve task. evaluation_measure : str, default=None Name of the evaluation measure to use for evaluating models. class_labels : list of str, default=None @@ -554,32 +533,4 @@ class OpenMLLearningCurveTask(OpenMLClassificationTask): Cost matrix for Learning Curve tasks. """ - def __init__( # noqa: PLR0913 - self, - task_type_id: TaskType, - task_type: str, - data_set_id: int, - target_name: str, - estimation_procedure_id: int = 13, - estimation_procedure_type: str | None = None, - estimation_parameters: dict[str, str] | None = None, - data_splits_url: str | None = None, - task_id: int | None = None, - evaluation_measure: str | None = None, - class_labels: list[str] | None = None, - cost_matrix: np.ndarray | None = None, - ): - super().__init__( - task_id=task_id, - task_type_id=task_type_id, - task_type=task_type, - data_set_id=data_set_id, - estimation_procedure_id=estimation_procedure_id, - estimation_procedure_type=estimation_procedure_type, - estimation_parameters=estimation_parameters, - evaluation_measure=evaluation_measure, - target_name=target_name, - data_splits_url=data_splits_url, - class_labels=class_labels, - cost_matrix=cost_matrix, - ) + DEFAULT_ESTIMATION_PROCEDURE_ID: ClassVar[int] = 13 diff --git a/openml/testing.py b/openml/testing.py index 2003bb1b9..7f5230efe 100644 --- a/openml/testing.py +++ b/openml/testing.py @@ -47,9 +47,7 @@ class TestBase(unittest.TestCase): "user": [], } flow_name_tracker: ClassVar[list[str]] = [] - test_server = "https://test.openml.org/api/v1/xml" - # amueller's read/write key that he will throw away later - apikey = "610344db6388d9ba34f6db45a3cf71de" + admin_key = os.environ.get(openml.config.OPENML_TEST_SERVER_ADMIN_KEY_ENV_VAR) # creating logger for tracking files uploaded to test server logger = logging.getLogger("unit_tests_published_entities") @@ -80,7 +78,7 @@ def setUp(self, n_levels: int = 1, tmpdir_suffix: str = "") -> None: for _ in range(n_levels): static_cache_dir = static_cache_dir.parent.absolute() - content = os.listdir(static_cache_dir) + content = os.listdir(static_cache_dir) # noqa: PTH208, RUF100 if "files" in content: static_cache_dir = static_cache_dir / "files" else: @@ -99,8 +97,6 @@ def setUp(self, n_levels: int = 1, tmpdir_suffix: str = "") -> None: os.chdir(self.workdir) self.cached = True - openml.config.apikey = TestBase.apikey - self.production_server = "https://www.openml.org/api/v1/xml" openml.config.set_root_cache_directory(str(self.workdir)) # Increase the number of retries to avoid spurious server failures @@ -114,8 +110,7 @@ def use_production_server(self) -> None: Please use this sparingly - it is better to use the test server. """ - openml.config.server = self.production_server - openml.config.apikey = "" + openml.config.use_production_servers() def tearDown(self) -> None: """Tear down the test""" @@ -166,7 +161,11 @@ def _delete_entity_from_tracker(cls, entity_type: str, entity: int) -> None: delete_index = next( i for i, (id_, _) in enumerate( - zip(TestBase.publish_tracker[entity_type], TestBase.flow_name_tracker), + zip( + TestBase.publish_tracker[entity_type], + TestBase.flow_name_tracker, + strict=False, + ), ) if id_ == entity ) @@ -352,9 +351,9 @@ def create_request_response( __all__ = [ - "TestBase", - "SimpleImputer", "CustomImputer", + "SimpleImputer", + "TestBase", "check_task_existence", "create_request_response", ] diff --git a/openml/utils/__init__.py b/openml/utils/__init__.py new file mode 100644 index 000000000..1e74a3684 --- /dev/null +++ b/openml/utils/__init__.py @@ -0,0 +1,39 @@ +"""Utilities module.""" + +from openml.utils._openml import ( + ProgressBar, + ReprMixin, + _create_cache_directory, + _create_cache_directory_for_id, + _create_lockfiles_dir, + _delete_entity, + _get_cache_dir_for_id, + _get_cache_dir_for_key, + _get_rest_api_type_alias, + _list_all, + _remove_cache_dir_for_id, + _tag_entity, + _tag_openml_base, + extract_xml_tags, + get_cache_size, + thread_safe_if_oslo_installed, +) + +__all__ = [ + "ProgressBar", + "ReprMixin", + "_create_cache_directory", + "_create_cache_directory_for_id", + "_create_lockfiles_dir", + "_delete_entity", + "_get_cache_dir_for_id", + "_get_cache_dir_for_key", + "_get_rest_api_type_alias", + "_list_all", + "_remove_cache_dir_for_id", + "_tag_entity", + "_tag_openml_base", + "extract_xml_tags", + "get_cache_size", + "thread_safe_if_oslo_installed", +] diff --git a/openml/utils.py b/openml/utils/_openml.py similarity index 82% rename from openml/utils.py rename to openml/utils/_openml.py index 7e72e7aee..125dcd08b 100644 --- a/openml/utils.py +++ b/openml/utils/_openml.py @@ -2,12 +2,21 @@ from __future__ import annotations import contextlib +import re import shutil import warnings +from abc import ABC, abstractmethod +from collections.abc import Callable, Mapping, Sized from functools import wraps from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Mapping, Sized, TypeVar, overload -from typing_extensions import Literal, ParamSpec +from typing import ( + TYPE_CHECKING, + Any, + Literal, + TypeVar, + overload, +) +from typing_extensions import ParamSpec import numpy as np import xmltodict @@ -18,10 +27,10 @@ import openml._api_calls import openml.exceptions -from . import config - # Avoid import cycles: https://mypy.readthedocs.io/en/latest/common_issues.html#import-cycles if TYPE_CHECKING: + from collections.abc import Callable, Iterable, Mapping, Sequence, Sized + from openml.base import OpenMLBase P = ParamSpec("P") @@ -103,7 +112,7 @@ def _get_rest_api_type_alias(oml_object: OpenMLBase) -> str: return api_type_alias -def _tag_openml_base(oml_object: OpenMLBase, tag: str, untag: bool = False) -> None: # noqa: FBT001, FBT002 +def _tag_openml_base(oml_object: OpenMLBase, tag: str, untag: bool = False) -> None: # noqa: FBT002 api_type_alias = _get_rest_api_type_alias(oml_object) if oml_object.id is None: raise openml.exceptions.ObjectNotPublishedError( @@ -198,7 +207,7 @@ def _delete_entity(entity_type: str, entity_id: int) -> bool: if entity_type not in legal_entities: raise ValueError(f"Can't delete a {entity_type}") - url_suffix = "%s/%d" % (entity_type, entity_id) + url_suffix = f"{entity_type}/{entity_id}" try: result_xml = openml._api_calls._perform_api_call(url_suffix, "delete") result = xmltodict.parse(result_xml) @@ -328,7 +337,7 @@ def _list_all( # noqa: C901 def _get_cache_dir_for_key(key: str) -> Path: - return Path(config.get_cache_directory()) / key + return Path(openml.config.get_cache_directory()) / key def _create_cache_directory(key: str) -> Path: @@ -344,7 +353,7 @@ def _create_cache_directory(key: str) -> Path: return cache_dir -def _get_cache_dir_for_id(key: str, id_: int, create: bool = False) -> Path: # noqa: FBT001, FBT002 +def _get_cache_dir_for_id(key: str, id_: int, create: bool = False) -> Path: # noqa: FBT002 cache_dir = _create_cache_directory(key) if create else _get_cache_dir_for_key(key) return Path(cache_dir) / str(id_) @@ -427,8 +436,20 @@ def safe_func(*args: P.args, **kwargs: P.kwargs) -> R: return func +def get_cache_size() -> int: + """Calculate the size of OpenML cache directory + + Returns + ------- + cache_size: int + Total size of cache in bytes + """ + path = Path(openml.config.get_cache_directory()) + return sum(f.stat().st_size for f in path.rglob("*") if f.is_file()) + + def _create_lockfiles_dir() -> Path: - path = Path(config.get_cache_directory()) / "locks" + path = Path(openml.config.get_cache_directory()) / "locks" # TODO(eddiebergman): Not sure why this is allowed to error and ignore??? with contextlib.suppress(OSError): path.mkdir(exist_ok=True, parents=True) @@ -469,3 +490,57 @@ def update(self, length: int) -> None: self._progress_bar.update(length) if self._progress_bar.total <= self._progress_bar.n: self._progress_bar.close() + + +class ReprMixin(ABC): + """A mixin class that provides a customizable string representation for OpenML objects. + + This mixin standardizes the __repr__ output format across OpenML classes. + Classes inheriting from this mixin should implement the + _get_repr_body_fields method to specify which fields to display. + """ + + def __repr__(self) -> str: + body_fields = self._get_repr_body_fields() + return self._apply_repr_template(body_fields) + + @abstractmethod + def _get_repr_body_fields(self) -> Sequence[tuple[str, str | int | list[str] | None]]: + """Collect all information to display in the __repr__ body. + + Returns + ------- + body_fields : List[Tuple[str, Union[str, int, List[str]]]] + A list of (name, value) pairs to display in the body of the __repr__. + E.g.: [('metric', 'accuracy'), ('dataset', 'iris')] + If value is a List of str, then each item of the list will appear in a separate row. + """ + # Should be implemented in the base class. + + def _apply_repr_template( + self, + body_fields: Iterable[tuple[str, str | int | list[str] | None]], + ) -> str: + """Generates the header and formats the body for string representation of the object. + + Parameters + ---------- + body_fields: List[Tuple[str, str]] + A list of (name, value) pairs to display in the body of the __repr__. + """ + # We add spaces between capitals, e.g. ClassificationTask -> Classification Task + name_with_spaces = re.sub( + r"(\w)([A-Z])", + r"\1 \2", + self.__class__.__name__[len("OpenML") :], + ) + header_text = f"OpenML {name_with_spaces}" + header = f"{header_text}\n{'=' * len(header_text)}\n" + + _body_fields: list[tuple[str, str | int | list[str]]] = [ + (k, "None" if v is None else v) for k, v in body_fields + ] + longest_field_name_length = max(len(name) for name, _ in _body_fields) + field_line_format = f"{{:.<{longest_field_name_length}}}: {{}}" + body = "\n".join(field_line_format.format(name, value) for name, value in _body_fields) + return header + body diff --git a/pyproject.toml b/pyproject.toml index 2bf762b09..c67af74e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dependencies = [ "pyarrow", "tqdm", # For MinIO download progress bars ] -requires-python = ">=3.8" +requires-python = ">=3.10,<3.15" maintainers = [ { name = "Pieter Gijsbers", email="p.gijsbers@tue.nl"}, { name = "Lennart Purucker"}, @@ -50,12 +50,11 @@ classifiers = [ "Operating System :: Unix", "Operating System :: MacOS", "Programming Language :: Python :: 3", - "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", + "Programming Language :: Python :: 3.14", ] license = { file = "LICENSE" } @@ -106,7 +105,8 @@ docs=[ "mkdocs-section-index", "mkdocs-jupyter", "mkdocs-linkcheck", - "mike" + "mike", + "pymdown-extensions<12.2", # 2026-10-10: incompatibility of 12.2 with mkdocstrings ] [project.urls] @@ -127,6 +127,7 @@ version = {attr = "openml.__version__.__version__"} # https://docs.pytest.org/en/7.2.x/reference/reference.html#ini-options-ref [tool.pytest.ini_options] +log_level="DEBUG" testpaths = ["tests"] minversion = "7.0" xfail_strict = true @@ -134,24 +135,21 @@ filterwarnings=[ "ignore:the matrix subclass:PendingDeprecationWarning" ] markers = [ - "server: anything that connects to a server", "upload: anything that uploads to a server", - "production: any interaction with the production server", + "production_server: any interaction with the production server", "cache: anything that interacts with the (test) cache", + "test_server: tests that require the OpenML test server", ] +addopts = "--ignore=tests/files" # https://github.com/charliermarsh/ruff [tool.ruff] -target-version = "py38" +target-version = "py310" line-length = 100 output-format = "grouped" -src = ["openml", "tests", "examples"] unsafe-fixes = true exclude = [ - # TODO(eddiebergman): Tests should be re-enabled after the refactor - "tests", - # ".bzr", ".direnv", ".eggs", @@ -176,7 +174,7 @@ exclude = [ # Exclude a variety of commonly ignored directories. [tool.ruff.lint.per-file-ignores] -"tests/*.py" = [ +"tests/**/*.py" = [ "D100", # Undocumented public module "D101", # Missing docstring in public class "D102", # Missing docstring in public method @@ -275,9 +273,10 @@ ignore = [ "S101", # Use of assert detected. "W292", # No newline at end of file "PLC1901", # "" can be simplified to be falsey - "TCH003", # Move stdlib import into TYPE_CHECKING "COM812", # Trailing comma missing (handled by linter, ruff recommend disabling if using formatter) "N803", # Argument should be lowercase (but we accept things like `X`) + "PLC0415", # Allow imports inside functions / non-top-level scope + "FBT001", # Allow Boolean-typed positional argument in function definition # TODO(@eddibergman): These should be enabled "D100", # Missing docstring in public module @@ -308,8 +307,9 @@ force-wrap-aliases = true convention = "numpy" [tool.mypy] -python_version = "3.8" -packages = ["openml", "tests"] +python_version = "3.10" +packages = ["openml"] +explicit_package_bases = true show_error_codes = true @@ -327,13 +327,70 @@ check_untyped_defs = true warn_return_any = true - [[tool.mypy.overrides]] -module = ["tests.*", "openml.extensions.sklearn.*"] +module = [ + # Files that are being skipped because of the mypy version upgrade from v1.13.0 to v1.19.1 + "examples._external_or_deprecated.upload_amlb_flows_and_runs", + "examples._external_or_deprecated.run_setup_tutorial", + "examples._external_or_deprecated.flows_and_runs_tutorial", + "examples._external_or_deprecated.flow_id_tutorial", + "examples._external_or_deprecated.fetch_runtimes_tutorial", + "examples._external_or_deprecated.benchmark_with_optunahub", + "examples._external_or_deprecated.2018_neurips_perrone_example", + "examples._external_or_deprecated.2018_ida_strang_example", + "examples._external_or_deprecated.2015_neurips_feurer_example", + "examples.Basics.simple_tasks_tutorial", + "examples.Basics.simple_suites_tutorial", + "examples.Basics.simple_flows_and_runs_tutorial", + "examples.Basics.simple_datasets_tutorial", + "examples.Advanced.task_manual_iteration_tutorial", + "examples.Advanced.study_tutorial", + "examples.Advanced.fetch_evaluations_tutorial", + "examples.Advanced.datasets_tutorial", + "examples.Advanced.create_upload_tutorial", + + # Files that need to be fixed after removing the entire tests directory from skipping mypy checks + "tests.test_api.test_estimation_procedure", + "tests.test_api.test_evaluation", + "tests.test_api.test_evaluation_measure", + "tests.test_api.test_flow", + "tests.test_api.test_http", + "tests.test_api.test_run", + "tests.test_api.test_setup", + "tests.test_api.test_study", + "tests.test_api.test_versions", + "tests.conftest", + "tests.test_datasets.test_dataset", + "tests.test_datasets.test_dataset_functions", + "tests.test_evaluations.test_evaluation_functions", + "tests.test_evaluations.test_evaluations_example", + "tests.test_extensions.test_functions", + "tests.test_flows.dummy_learn.dummy_forest", + "tests.test_flows.test_flow", + "tests.test_flows.test_flow_functions", + "tests.test_openml.test_api_calls", + "tests.test_openml.test_cli", + "tests.test_openml.test_config", + "tests.test_openml.test_openml", + "tests.test_runs.test_run", + "tests.test_runs.test_run_functions", + "tests.test_runs.test_trace", + "tests.test_setups.test_setup_functions", + "tests.test_study.test_study_functions", + "tests.test_tasks.test_classification_task", + "tests.test_tasks.test_clustering_task", + "tests.test_tasks.test_learning_curve_task", + "tests.test_tasks.test_regression_task", + "tests.test_tasks.test_split", + "tests.test_tasks.test_supervised_task", + "tests.test_tasks.test_task", + "tests.test_tasks.test_task_functions", + "tests.test_tasks.test_task_methods", + "tests.test_utils.test_utils", + ] -# TODO(eddiebergman): This should be re-enabled after tests get refactored ignore_errors = true -#disallow_untyped_defs = false # Sometimes we just want to ignore verbose types -#disallow_untyped_decorators = false # Test decorators are not properly typed -#disallow_incomplete_defs = false # Sometimes we just want to ignore verbose types -#disable_error_code = ["var-annotated"] +disallow_untyped_defs = false +disallow_untyped_decorators = false +disallow_incomplete_defs = false +disable_error_code = ["var-annotated"] diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 000000000..000969b80 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Package for scripts and utilities.""" diff --git a/tests/conftest.py b/tests/conftest.py index 40a801e86..0e863fba0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,7 @@ Possible Future: class TestBase from openml/testing.py can be included under this file and there would not be any requirements to import testing.py in each of the unit test modules. -""" +""" # noqa: D404 # License: BSD 3-Clause from __future__ import annotations @@ -27,18 +27,18 @@ multiprocessing.set_start_method("spawn", force=True) -from collections.abc import Iterator -import logging -import os -import shutil -from pathlib import Path -import pytest -import openml_sklearn +import logging # noqa: E402 +import os # noqa: E402 +import shutil # noqa: E402 +from collections.abc import Iterator # noqa: E402 +from pathlib import Path # noqa: E402 -import openml -from openml.testing import TestBase +import pytest # noqa: E402 -import inspect +import openml # noqa: E402 +from openml._api import HTTPClient, MinIOClient # noqa: E402 +from openml.enums import APIVersion # noqa: E402 +from openml.testing import TestBase # noqa: E402 # creating logger for unit test file deletion status logger = logging.getLogger("unit_tests") @@ -57,8 +57,7 @@ def worker_id() -> str: vars_ = list(os.environ.keys()) if "PYTEST_XDIST_WORKER" in vars_ or "PYTEST_XDIST_WORKER_COUNT" in vars_: return os.environ["PYTEST_XDIST_WORKER"] - else: - return "master" + return "master" def read_file_list() -> list[Path]: @@ -79,7 +78,7 @@ def compare_delete_files(old_list: list[Path], new_list: list[Path]) -> None: """ file_list = list(set(new_list) - set(old_list)) for file in file_list: - os.remove(file) + os.remove(file) # noqa: PTH107 logger.info(f"Deleted from local: {file}") @@ -97,13 +96,12 @@ def delete_remote_files(tracker, flow_names) -> None: :param tracker: Dict :return: None """ - openml.config.server = TestBase.test_server - openml.config.apikey = TestBase.apikey + openml.config.use_test_servers() # reordering to delete sub flows at the end of flows # sub-flows have shorter names, hence, sorting by descending order of flow name length if "flow" in tracker: - to_sort = list(zip(tracker["flow"], flow_names)) + to_sort = list(zip(tracker["flow"], flow_names, strict=False)) flow_deletion_order = [ entity_id for entity_id, _ in sorted(to_sort, key=lambda x: len(x[1]), reverse=True) ] @@ -118,7 +116,7 @@ def delete_remote_files(tracker, flow_names) -> None: try: openml.utils._delete_entity(entity_type, entity) logger.info(f"Deleted ({entity_type}, {entity})") - except Exception as e: + except Exception as e: # noqa: BLE001 logger.warning(f"Cannot delete ({entity_type},{entity}): {e}") @@ -138,7 +136,7 @@ def pytest_sessionstart() -> None: :return: None """ # file_list is global to maintain the directory snapshot during tear down - global file_list + global file_list # noqa: PLW0603 worker = worker_id() if worker == "master": file_list = read_file_list() @@ -161,7 +159,7 @@ def pytest_sessionfinish() -> None: :return: None """ # allows access to the file_list read in the set up phase - global file_list + global file_list # noqa: PLW0602 worker = worker_id() logger.info(f"Finishing worker {worker}") @@ -204,7 +202,7 @@ def _expected_static_cache_state(root_dir: Path) -> list[Path]: _c_root_dir = root_dir / "org" / "openml" / "test" res_paths = [root_dir, _c_root_dir] - for _d in ["datasets", "tasks", "runs", "setups"]: + for _d in ["datasets", "tasks"]: res_paths.append(_c_root_dir / _d) for _id in ["-1", "2"]: @@ -219,8 +217,6 @@ def _expected_static_cache_state(root_dir: Path) -> list[Path]: ) res_paths.append(_c_root_dir / "datasets" / "30" / "dataset_30.pq") - res_paths.append(_c_root_dir / "runs" / "1" / "description.xml") - res_paths.append(_c_root_dir / "setups" / "1" / "description.xml") for _id in ["1", "3", "1882"]: tmp_p = _c_root_dir / "tasks" / _id @@ -231,6 +227,17 @@ def _expected_static_cache_state(root_dir: Path) -> list[Path]: ] ) + res_paths.extend( + [ + _c_root_dir / "api" / "v1" / "xml" / "setup", + _c_root_dir / "api" / "v1" / "xml" / "setup" / "1", + _c_root_dir / "api" / "v1" / "xml" / "setup" / "1" / "body.xml", + _c_root_dir / "api" / "v1" / "xml" / "run", + _c_root_dir / "api" / "v1" / "xml" / "run" / "1", + _c_root_dir / "api" / "v1" / "xml" / "run" / "1" / "body.xml", + ] + ) + return res_paths @@ -250,11 +257,26 @@ def test_files_directory() -> Path: @pytest.fixture(scope="session") -def test_api_key() -> str: - return "c0c42819af31e706efe1f4b88c23c6c1" +def test_server_v1() -> str: + return openml.config.get_test_servers()[APIVersion.V1]["server"] + + +@pytest.fixture(scope="session") +def test_apikey_v1() -> str: + return openml.config.get_test_servers()[APIVersion.V1]["apikey"] -@pytest.fixture(autouse=True, scope="function") +@pytest.fixture(scope="session") +def test_server_v2() -> str: + return openml.config.get_test_servers()[APIVersion.V2]["server"] + + +@pytest.fixture(scope="session") +def test_apikey_v2() -> str: + return openml.config.get_test_servers()[APIVersion.V2]["apikey"] + + +@pytest.fixture(autouse=True) def verify_cache_state(test_files_directory) -> Iterator[None]: assert_static_test_cache_correct(test_files_directory) yield @@ -272,35 +294,43 @@ def as_robot() -> Iterator[None]: @pytest.fixture(autouse=True) def with_server(request): - if "production" in request.keywords: - openml.config.server = "https://www.openml.org/api/v1/xml" + openml.config.set_api_version(APIVersion.V1) + + if "production_server" in request.keywords: + openml.config.use_production_servers() yield return - openml.config.server = "https://test.openml.org/api/v1/xml" - openml.config.apikey = "c0c42819af31e706efe1f4b88c23c6c1" + + openml.config.use_test_servers() yield @pytest.fixture(autouse=True) def with_test_cache(test_files_directory, request): + # Skip this fixture for TestBase subclasses - they manage their own cache directory + # in setUp()/tearDown(). Having both mechanisms fight over the global config + # causes race conditions. + if request.instance is not None and isinstance(request.instance, TestBase): + yield + return + if not test_files_directory.exists(): raise ValueError( f"Cannot find test cache dir, expected it to be {test_files_directory!s}!", ) _root_cache_directory = openml.config._root_cache_directory - tmp_cache = test_files_directory / request.node.name + tmp_cache = test_files_directory / request.node.nodeid.replace("/", ".").replace("::", ".") openml.config.set_root_cache_directory(tmp_cache) yield openml.config.set_root_cache_directory(_root_cache_directory) if tmp_cache.exists(): shutil.rmtree(tmp_cache) - - + @pytest.fixture def static_cache_dir(): - - return Path(__file__).parent / "files" + return Path(__file__).parent / "files" + @pytest.fixture def workdir(tmp_path): @@ -308,3 +338,20 @@ def workdir(tmp_path): os.chdir(tmp_path) yield tmp_path os.chdir(original_cwd) + + +@pytest.fixture +def http_client_v1() -> HTTPClient: + return HTTPClient(api_version=APIVersion.V1) + + +@pytest.fixture +def http_client_v2() -> HTTPClient: + if openml.config.servers[APIVersion.V2]["server"] is None: + pytest.skip("V2 server is not configured") + return HTTPClient(api_version=APIVersion.V2) + + +@pytest.fixture +def minio_client() -> MinIOClient: + return MinIOClient() diff --git a/tests/files/localhost_8000 b/tests/files/localhost_8000 new file mode 120000 index 000000000..334c709ef --- /dev/null +++ b/tests/files/localhost_8000 @@ -0,0 +1 @@ +org/openml/test \ No newline at end of file diff --git a/tests/files/org/openml/test/runs/1/description.xml b/tests/files/org/openml/test/api/v1/xml/run/1/body.xml similarity index 100% rename from tests/files/org/openml/test/runs/1/description.xml rename to tests/files/org/openml/test/api/v1/xml/run/1/body.xml diff --git a/tests/files/org/openml/test/setups/1/description.xml b/tests/files/org/openml/test/api/v1/xml/setup/1/body.xml similarity index 100% rename from tests/files/org/openml/test/setups/1/description.xml rename to tests/files/org/openml/test/api/v1/xml/setup/1/body.xml diff --git a/tests/test_api/__init__.py b/tests/test_api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_api/test_estimation_procedure.py b/tests/test_api/test_estimation_procedure.py new file mode 100644 index 000000000..fd5c79248 --- /dev/null +++ b/tests/test_api/test_estimation_procedure.py @@ -0,0 +1,33 @@ +# License: BSD 3-Clause +from __future__ import annotations + +import pytest + +from openml._api import EstimationProcedureV1API, EstimationProcedureV2API +from openml.estimation_procedures import OpenMLEstimationProcedure +from openml.exceptions import OpenMLNotSupportedError + + +@pytest.fixture +def estimation_procedure_v1(http_client_v1, minio_client) -> EstimationProcedureV1API: + return EstimationProcedureV1API(http=http_client_v1, minio=minio_client) + + +@pytest.fixture +def estimation_procedure_v2(http_client_v2, minio_client) -> EstimationProcedureV2API: + return EstimationProcedureV2API(http=http_client_v2, minio=minio_client) + + +@pytest.mark.test_server +def test_v1_list(estimation_procedure_v1): + details = estimation_procedure_v1.list() + + assert isinstance(details, list) + assert len(details) > 0 + assert all(isinstance(d, OpenMLEstimationProcedure) for d in details) + + +@pytest.mark.test_server +def test_v2_list(estimation_procedure_v2): + with pytest.raises(OpenMLNotSupportedError): + estimation_procedure_v2.list() diff --git a/tests/test_api/test_evaluation.py b/tests/test_api/test_evaluation.py new file mode 100644 index 000000000..25ef94237 --- /dev/null +++ b/tests/test_api/test_evaluation.py @@ -0,0 +1,41 @@ +# License: BSD 3-Clause +from __future__ import annotations + +import pytest + +from openml._api import EvaluationV1API, EvaluationV2API +from openml.evaluations import OpenMLEvaluation +from openml.exceptions import OpenMLNotSupportedError + + +@pytest.fixture +def evaluation_v1(http_client_v1, minio_client) -> EvaluationV1API: + return EvaluationV1API(http=http_client_v1, minio=minio_client) + + +@pytest.fixture +def evaluation_v2(http_client_v2, minio_client) -> EvaluationV2API: + return EvaluationV2API(http=http_client_v2, minio=minio_client) + + +@pytest.mark.test_server +def test_v1_list(evaluation_v1): + evaluations = evaluation_v1.list( + function="predictive_accuracy", + limit=10, + offset=0, + ) + + assert isinstance(evaluations, list) + assert len(evaluations) == 10 + assert all(isinstance(e, OpenMLEvaluation) for e in evaluations) + + +@pytest.mark.test_server +def test_v2_list(evaluation_v2): + with pytest.raises(OpenMLNotSupportedError): + evaluation_v2.list( + function="predictive_accuracy", + limit=10, + offset=0, + ) diff --git a/tests/test_api/test_evaluation_measure.py b/tests/test_api/test_evaluation_measure.py new file mode 100644 index 000000000..3835c7a5f --- /dev/null +++ b/tests/test_api/test_evaluation_measure.py @@ -0,0 +1,39 @@ +# License: BSD 3-Clause +from __future__ import annotations + +import pytest + +from openml._api import EvaluationMeasureV1API, EvaluationMeasureV2API + + +@pytest.fixture +def evaluation_measure_v1(http_client_v1, minio_client) -> EvaluationMeasureV1API: + return EvaluationMeasureV1API(http=http_client_v1, minio=minio_client) + + +@pytest.fixture +def evaluation_measure_v2(http_client_v2, minio_client) -> EvaluationMeasureV2API: + return EvaluationMeasureV2API(http=http_client_v2, minio=minio_client) + + +@pytest.mark.test_server +def test_v1_list(evaluation_measure_v1): + measures = evaluation_measure_v1.list() + assert isinstance(measures, list) + assert all(isinstance(s, str) for s in measures) + + +@pytest.mark.test_server +def test_v2_list(evaluation_measure_v2): + measures = evaluation_measure_v2.list() + assert isinstance(measures, list) + assert all(isinstance(s, str) for s in measures) + + +@pytest.mark.test_server +def test_list_matches(evaluation_measure_v1, evaluation_measure_v2): + output_v1 = evaluation_measure_v1.list() + output_v2 = evaluation_measure_v2.list() + + assert type(output_v1) is type(output_v2) + assert output_v1 == output_v2 diff --git a/tests/test_api/test_flow.py b/tests/test_api/test_flow.py new file mode 100644 index 000000000..6c9d440aa --- /dev/null +++ b/tests/test_api/test_flow.py @@ -0,0 +1,282 @@ +# License: BSD 3-Clause +from __future__ import annotations + +from typing import Any +from unittest.mock import patch + +import pandas as pd +import pytest +from requests import Response, Session + +import openml +from openml._api import FlowV1API, FlowV2API +from openml.exceptions import OpenMLNotSupportedError, OpenMLServerException +from openml.flows.flow import OpenMLFlow + + +@pytest.fixture +def flow_v1(http_client_v1, minio_client) -> FlowV1API: + return FlowV1API(http=http_client_v1, minio=minio_client) + + +@pytest.fixture +def flow_v2(http_client_v2, minio_client) -> FlowV2API: + return FlowV2API(http=http_client_v2, minio=minio_client) + + +def _validate_flow(flow: OpenMLFlow) -> None: + assert isinstance(flow, OpenMLFlow) + assert isinstance(flow.flow_id, int) + assert isinstance(flow.name, str) + assert isinstance(flow.version, str) + # There are some runs on openml.org that can have an empty external version + ext_version = flow.external_version + ext_version_str_or_none = ( + isinstance(ext_version, str) or ext_version is None or pd.isna(ext_version) + ) + assert ext_version_str_or_none + + +def _validate_flow_dict(flow: dict[str, Any]) -> None: + assert type(flow) is dict + assert len(flow) == 6 + assert isinstance(flow["id"], int) + assert isinstance(flow["name"], str) + assert isinstance(flow["full_name"], str) + assert isinstance(flow["version"], str) + # There are some runs on openml.org that can have an empty external version + ext_version = flow["external_version"] + ext_version_str_or_none = ( + isinstance(ext_version, str) or ext_version is None or pd.isna(ext_version) + ) + assert ext_version_str_or_none + + +@pytest.mark.test_server +def test_flow_v1_get(flow_v1): + flow = flow_v1.get(flow_id=1) + _validate_flow(flow) + + +@pytest.mark.test_server +def test_flow_v1_list(flow_v1): + limit = 5 + flows_df = flow_v1.list(limit=limit) + + assert isinstance(flows_df, pd.DataFrame) + assert len(flows_df) <= limit + + for flow in flows_df.to_dict(orient="index").values(): + _validate_flow_dict(flow) + + +def test_flow_v1_exists_input_validation(flow_v1): + with pytest.raises(ValueError, match="Argument 'name' should be a non-empty string"): + flow_v1.exists(name="", external_version="1") + + with pytest.raises(ValueError, match="Argument 'version' should be a non-empty string"): + flow_v1.exists(name="sklearn.tree.DecisionTreeClassifier", external_version="") + + +def test_flow_v1_exists_mocked_success(flow_v1): + flow_name = "sklearn.tree.DecisionTreeClassifier" + external_version = "1" + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + b'\n' + b" 123\n" + b"\n" + ) + + result = flow_v1.exists(name=flow_name, external_version=external_version) + + assert result == 123 + mock_request.assert_called_once() + _, kwargs = mock_request.call_args + assert kwargs["method"] == "POST" + assert kwargs["url"] == openml.config.server + "flow/exists" + assert kwargs["params"] == {} + assert kwargs["headers"] == openml.config._HEADERS + assert kwargs["files"] is None + assert kwargs["data"]["name"] == flow_name + assert kwargs["data"]["external_version"] == external_version + + +def test_flow_v1_exists_mocked_server_error(flow_v1): + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + b'\n' + b" 104\n" + b" Server error\n" + b"\n" + ) + + with pytest.raises(OpenMLServerException, match="Server error"): + flow_v1.exists(name="foo", external_version="1") + + +def test_flow_v1_publish_mocked(flow_v1, test_apikey_v1): + files = {"description": ""} + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + b'\n' + b" 321\n" + b"\n" + ) + + result = flow_v1.publish(path="flow", files=files) + + assert result == 321 + mock_request.assert_called_once_with( + method="POST", + url=openml.config.server + "flow", + params={}, + data={"api_key": test_apikey_v1}, + headers=openml.config._HEADERS, + files=files, + ) + + +def test_flow_v1_delete_mocked(flow_v1, test_apikey_v1): + flow_id = 123 + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + b'\n' + b" 123\n" + b"\n" + ) + + result = flow_v1.delete(flow_id) + + assert result is True + mock_request.assert_called_once_with( + method="DELETE", + url=openml.config.server + f"flow/{flow_id}", + params={"api_key": test_apikey_v1}, + data={}, + headers=openml.config._HEADERS, + files=None, + ) + + +def test_flow_v1_tag_mocked(flow_v1, test_apikey_v1): + """Test V1 tagging a flow.""" + flow_id = 42 + tag_name = "my-test-tag" + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + '' + f"{flow_id}" + f"{tag_name}" + "" + ).encode() + + tags = flow_v1.tag(flow_id, tag_name) + + assert tag_name in tags + mock_request.assert_called_once_with( + method="POST", + url=openml.config.server + "flow/tag", + params={}, + data={ + "api_key": test_apikey_v1, + "flow_id": flow_id, + "tag": tag_name, + }, + headers=openml.config._HEADERS, + files=None, + ) + + +def test_flow_v1_untag_mocked(flow_v1, test_apikey_v1): + """Test V1 untagging a flow.""" + flow_id = 42 + tag_name = "my-test-tag" + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + '' + f"{flow_id}" + "" + ).encode() + + tags = flow_v1.untag(flow_id, tag_name) + + assert tag_name not in tags + mock_request.assert_called_once_with( + method="POST", + url=openml.config.server + "flow/untag", + params={}, + data={ + "api_key": test_apikey_v1, + "flow_id": flow_id, + "tag": tag_name, + }, + headers=openml.config._HEADERS, + files=None, + ) + + +@pytest.mark.test_server +def test_flow_v2_get(flow_v2): + flow = flow_v2.get(flow_id=1) + _validate_flow(flow) + + +def test_flow_v2_exists_nonexistent(flow_v2): + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = b"{}" + mock_request.return_value.json = lambda: {"flow_id": False} + + result = flow_v2.exists( + name="NonExistentFlowName123456789", + external_version="0.0.0.nonexistent", + ) + + assert result is False + + +def test_flow_v2_list_not_supported(flow_v2): + with pytest.raises( + OpenMLNotSupportedError, + match="FlowV2API: v2 API does not support `list` for resource `flow`", + ): + flow_v2.list(limit=10) + + +def test_flow_v2_publish_not_supported(flow_v2): + with pytest.raises( + OpenMLNotSupportedError, + match="FlowV2API: v2 API does not support `publish` for resource `flow`", + ): + flow_v2.publish(path="flow", files={"description": ""}) + + +@pytest.mark.test_server +def test_flow_v1_v2_get_output_match(flow_v1, flow_v2): + flow_from_v1 = flow_v1.get(flow_id=1) + flow_from_v2 = flow_v2.get(flow_id=1) + + assert flow_from_v1.flow_id == flow_from_v2.flow_id + assert flow_from_v1.name == flow_from_v2.name + assert flow_from_v1.version == flow_from_v2.version + assert flow_from_v1.external_version == flow_from_v2.external_version + assert flow_from_v1.description == flow_from_v2.description diff --git a/tests/test_api/test_http.py b/tests/test_api/test_http.py new file mode 100644 index 000000000..a27db2898 --- /dev/null +++ b/tests/test_api/test_http.py @@ -0,0 +1,287 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +from unittest.mock import patch +from urllib.parse import urljoin, urlparse + +import pytest +from requests import Request, Response, Session + +import openml +from openml._api import HTTPCache, HTTPClient +from openml.exceptions import OpenMLAuthenticationError, OpenMLServerError + + +@pytest.fixture +def cache(http_client_v1) -> HTTPCache: + return http_client_v1.cache + + +@pytest.fixture +def http_client(http_client_v1) -> HTTPClient: + return http_client_v1 + + +@pytest.fixture +def sample_path() -> str: + return "task/1" + + +@pytest.fixture +def sample_url_v1(sample_path, test_server_v1) -> str: + return urljoin(test_server_v1, sample_path) + + +@pytest.fixture +def sample_download_url_v1(test_server_v1) -> str: + server = test_server_v1.split("api/")[0] + endpoint = "data/v1/download/1/anneal.arff" + return server + endpoint + + +def test_cache(cache, sample_url_v1): + params = {"param1": "value1", "param2": "value2"} + + # validate key + + parsed_url = urlparse(sample_url_v1) + path_parts = parsed_url.path.strip("/").split("/") + params_key = "&".join([f"{k}={v}" for k, v in params.items()]) + + key = cache.get_key(sample_url_v1, params) + + expected_key = str(Path(*path_parts) / params_key) + + assert key == expected_key + + # validate path + + path = cache._key_to_path(key) + expected_path = Path(openml.config.get_cache_directory()).joinpath(key) + + assert path == expected_path + + # validate save/load + + # mock response + req = Request("GET", sample_url_v1).prepare() + response = Response() + response.status_code = 200 + response.url = sample_url_v1 + response.reason = "OK" + response._content = b"test" + response.headers = {"Content-Type": "text/xml"} + response.encoding = "utf-8" + response.request = req + response.elapsed = type("Elapsed", (), {"total_seconds": lambda _x: 0.1})() + + cache.save(key, response) + cached = cache.load(key) + + assert cached.status_code == 200 + assert cached.url == sample_url_v1 + assert cached.content == b"test" + assert cached.headers["Content-Type"] == "text/xml" + + +@pytest.mark.test_server +def test_get(http_client): + response = http_client.get("task/1") + + assert response.status_code == 200 + assert b" RunV1API: + return RunV1API(http=http_client_v1, minio=minio_client) + + +@pytest.fixture +def run_v2(http_client_v2, minio_client) -> RunV2API: + return RunV2API(http=http_client_v2, minio=minio_client) + + +def _assert_run_shape(run: OpenMLRun) -> None: + assert isinstance(run, OpenMLRun) + assert isinstance(run.run_id, int) + assert run.run_id > 0 + assert isinstance(run.task_id, int) + + +def test_run_v1_get(run_v1, test_files_directory): + openml.config.set_root_cache_directory(test_files_directory) + run = run_v1.get(run_id=1) + _assert_run_shape(run) + + +@pytest.mark.test_server +def test_run_v1_list(run_v1): + limit = 5 + runs_df = run_v1.list(limit=limit, offset=0) + + assert len(runs_df) == limit + assert "run_id" in runs_df.columns + assert "task_id" in runs_df.columns + assert "setup_id" in runs_df.columns + assert "flow_id" in runs_df.columns + + +def test_run_v1_publish_mocked(run_v1, test_apikey_v1): + """Test publish with oml:run_id (actual server response format).""" + files = {"description": ""} + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + b'\n' + b" 456\n" + b"\n" + ) + + result = run_v1.publish(path="run", files=files) + + assert result == 456 + mock_request.assert_called_once_with( + method="POST", + url=openml.config.server + "run", + params={}, + data={"api_key": test_apikey_v1}, + headers=openml.config._HEADERS, + files=files, + ) + + +def test_run_v1_delete_mocked(run_v1, test_apikey_v1): + run_id = 456 + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + b'\n' + b" 456\n" + b"\n" + ) + + result = run_v1.delete(run_id) + + assert result is True + mock_request.assert_called_once_with( + method="DELETE", + url=openml.config.server + f"run/{run_id}", + params={"api_key": test_apikey_v1}, + data={}, + headers=openml.config._HEADERS, + files=None, + ) + + +def test_run_v2_get_not_supported(run_v2): + with pytest.raises( + OpenMLNotSupportedError, + match="RunV2API: v2 API does not support `get` for resource `run`", + ): + run_v2.get(run_id=1) + + +def test_run_v2_list_not_supported(run_v2): + with pytest.raises( + OpenMLNotSupportedError, + match="RunV2API: v2 API does not support `list` for resource `run`", + ): + run_v2.list(limit=5, offset=0) + + +def test_run_v2_publish_not_supported(run_v2): + with pytest.raises( + OpenMLNotSupportedError, + match="RunV2API: v2 API does not support `publish` for resource `run`", + ): + run_v2.publish(path="run", files={"description": ""}) diff --git a/tests/test_api/test_setup.py b/tests/test_api/test_setup.py new file mode 100644 index 000000000..1f3b03ac6 --- /dev/null +++ b/tests/test_api/test_setup.py @@ -0,0 +1,112 @@ +# License: BSD 3-Clause +from __future__ import annotations + +import uuid + +import pytest +import sklearn.naive_bayes +import sklearn.tree +from openml_sklearn import SklearnExtension + +import openml +from openml._api import SetupV1API, SetupV2API +from openml.exceptions import OpenMLNotSupportedError +from openml.setups.setup import OpenMLSetup +from openml.testing import TestBase + + +def get_sentinel(): + # Create a unique prefix for the flow. Necessary because the flow is + # identified by its name and external version online. Having a unique + # name allows us to publish the same flow in each test run + sentinel = uuid.uuid4().hex[:10] + return f"TEST{sentinel}" + + +@pytest.fixture +def setup_v1(http_client_v1, minio_client) -> SetupV1API: + return SetupV1API(http=http_client_v1, minio=minio_client) + + +@pytest.fixture +def setup_v2(http_client_v2, minio_client) -> SetupV2API: + return SetupV2API(http=http_client_v2, minio=minio_client) + + +@pytest.mark.test_server +def test_v1_list(setup_v1): + setups = setup_v1.list(limit=10, offset=0) + + assert isinstance(setups, list) + assert len(setups) > 0 + assert all(isinstance(s, OpenMLSetup) for s in setups) + + +@pytest.mark.test_server +def test_v1_get(setup_v1): + setup_id = 1 + setup = setup_v1.get(setup_id) + + assert isinstance(setup, OpenMLSetup) + assert setup.setup_id == setup_id + + +@pytest.mark.sklearn +@pytest.mark.test_server +def test_v1_exists_nonexisting_setup(setup_v1): + """Test exists() returns False when setup doesn't exist""" + # first publish a non-existing flow + sentinel = get_sentinel() + # because of the sentinel, we can not use flows that contain subflows + dectree = sklearn.tree.DecisionTreeClassifier() + flow = SklearnExtension().model_to_flow(dectree) + flow.name = f"{sentinel}{flow.name}" + flow.publish() + TestBase._mark_entity_for_removal("flow", flow.flow_id, flow.name) + TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {flow.flow_id}") + openml_param_settings = flow.extension.obtain_parameter_values(flow) + # although the flow exists (created as of previous statement), + # we can be sure there are no setups (yet) as it was just created + # and hasn't been ran + setup_id = setup_v1.exists(flow, openml_param_settings) + assert not setup_id + + +@pytest.mark.sklearn +@pytest.mark.test_server +def test_v1_exists_existing_setup(setup_v1): + """Test exists() returns setup_id when setup exists""" + flow = SklearnExtension().model_to_flow(sklearn.naive_bayes.GaussianNB()) + flow.name = f"{get_sentinel()}{flow.name}" + flow.publish() + TestBase._mark_entity_for_removal("flow", flow.flow_id, flow.name) + TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {flow.flow_id}") + openml_param_settings = flow.extension.obtain_parameter_values(flow) + # now run the flow on an easy task: + task = openml.tasks.get_task(115) + run = openml.runs.run_flow_on_task(flow, task) + # spoof flow id, otherwise the sentinel is ignored + run.flow_id = flow.flow_id + run.publish() + TestBase._mark_entity_for_removal("run", run.run_id) + TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {run.run_id}") + # download the run, as it contains the right setup id + run = openml.runs.get_run(run.run_id) + # execute the function we are interested in + setup_id = setup_v1.exists(flow, openml_param_settings) + assert setup_id == run.setup_id + + +def test_v2_list(setup_v2): + with pytest.raises(OpenMLNotSupportedError): + setup_v2.list(limit=10, offset=0) + + +def test_v2_get(setup_v2): + with pytest.raises(OpenMLNotSupportedError): + setup_v2.get(1) + + +def test_v2_exists(setup_v2): + with pytest.raises(OpenMLNotSupportedError): + setup_v2.exists(flow=None, param_settings=None) diff --git a/tests/test_api/test_study.py b/tests/test_api/test_study.py new file mode 100644 index 000000000..7aa34661c --- /dev/null +++ b/tests/test_api/test_study.py @@ -0,0 +1,222 @@ +# License: BSD 3-Clause +from __future__ import annotations + +from unittest.mock import patch + +import pandas as pd +import pytest +from requests import Response, Session + +import openml +from openml._api.resources import StudyV1API, StudyV2API +from openml.exceptions import OpenMLNotSupportedError + + +@pytest.fixture +def study_v1(http_client_v1, minio_client) -> StudyV1API: + """Fixture for V1 Study API instance.""" + return StudyV1API(http=http_client_v1, minio=minio_client) + + +@pytest.fixture +def study_v2(http_client_v2, minio_client) -> StudyV2API: + """Fixture for V2 Study API instance.""" + return StudyV2API(http=http_client_v2, minio=minio_client) + + +def test_v1_list(study_v1, test_server_v1, test_apikey_v1): # noqa: ARG001 + """Test V1 list basic functionality with limit and offset.""" + # Mock response with study list + mock_response = """ + + + 1 + test-study-1 + task + Test Study 1 + active + + + 2 + test-study-2 + run + Test Study 2 + active + + + """ + limit = 5 + offset = 0 + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = mock_response.encode("utf-8") + + studies_df = study_v1.list(limit=limit, offset=offset) + + assert studies_df is not None + assert isinstance(studies_df, pd.DataFrame) + assert len(studies_df) == 2 + + expected_columns = {"id", "alias", "main_entity_type", "name", "status"} + assert expected_columns.issubset(set(studies_df.columns)) + + mock_request.assert_called_once_with( + method="GET", + url=test_server_v1 + f"study/list/limit/{limit}/offset/{offset}", + params={}, + data={}, + headers=openml.config._HEADERS, + files=None, + ) + + +def test_v1_publish(study_v1, test_server_v1, test_apikey_v1): + """Test V1 publish a new study.""" + study_id = 999 + study_files = {"description": "Test Study Description"} + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + f'\n' + f"\t{study_id}\n" + f"\n" + ).encode() + + published_id = study_v1.publish("study", files=study_files) + + assert published_id == study_id + + mock_request.assert_called_once_with( + method="POST", + url=test_server_v1 + "study", + params={}, + data={"api_key": test_apikey_v1}, + headers=openml.config._HEADERS, + files=study_files, + ) + + +def test_v1_delete(study_v1, test_server_v1, test_apikey_v1): + """Test V1 delete a study.""" + study_id = 100 + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + f'\n' + f" {study_id}\n" + f"\n" + ).encode() + + result = study_v1.delete(study_id) + + assert result + + mock_request.assert_called_once_with( + method="DELETE", + url=test_server_v1 + "study/" + str(study_id), + params={"api_key": test_apikey_v1}, + data={}, + headers=openml.config._HEADERS, + files=None, + ) + + +def test_v1_tag(study_v1, test_server_v1, test_apikey_v1): + """Test V1 tag a study.""" + study_id = 100 + tag_name = "important-tag" + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + f'' + f"{study_id}" + f"{tag_name}" + f"" + ).encode() + + tags = study_v1.tag(study_id, tag_name) + + assert tag_name in tags + + mock_request.assert_called_once_with( + method="POST", + url=test_server_v1 + "study/tag", + params={}, + data={ + "api_key": test_apikey_v1, + "study_id": study_id, + "tag": tag_name, + }, + headers=openml.config._HEADERS, + files=None, + ) + + +def test_v1_untag(study_v1, test_server_v1, test_apikey_v1): + """Test V1 untag a study.""" + study_id = 100 + tag_name = "important-tag" + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + f'' + f"{study_id}" + f"" + ).encode() + + tags = study_v1.untag(study_id, tag_name) + + assert tag_name not in tags + + mock_request.assert_called_once_with( + method="POST", + url=test_server_v1 + "study/untag", + params={}, + data={ + "api_key": test_apikey_v1, + "study_id": study_id, + "tag": tag_name, + }, + headers=openml.config._HEADERS, + files=None, + ) + + +def test_v2_list(study_v2): + """Test that V2 list raises OpenMLNotSupportedError.""" + with pytest.raises(OpenMLNotSupportedError): + study_v2.list(limit=5, offset=0) + + +def test_v2_publish(study_v2): + """Test that V2 publish raises OpenMLNotSupportedError.""" + with pytest.raises(OpenMLNotSupportedError): + study_v2.publish(path="study", files=None) + + +def test_v2_delete(study_v2): + """Test that V2 delete raises OpenMLNotSupportedError.""" + with pytest.raises(OpenMLNotSupportedError): + study_v2.delete(resource_id=100) + + +def test_v2_tag(study_v2): + """Test that V2 tag raises OpenMLNotSupportedError.""" + with pytest.raises(OpenMLNotSupportedError): + study_v2.tag(resource_id=100, tag="test-tag") + + +def test_v2_untag(study_v2): + """Test that V2 untag raises OpenMLNotSupportedError.""" + with pytest.raises(OpenMLNotSupportedError): + study_v2.untag(resource_id=100, tag="test-tag") diff --git a/tests/test_api/test_versions.py b/tests/test_api/test_versions.py new file mode 100644 index 000000000..fc655b6e9 --- /dev/null +++ b/tests/test_api/test_versions.py @@ -0,0 +1,209 @@ +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from requests import Response, Session + +import openml +from openml._api import FallbackProxy, ResourceAPI, ResourceV1API, ResourceV2API +from openml.enums import ResourceType +from openml.exceptions import OpenMLNotSupportedError + + +class DummyTaskAPI(ResourceAPI): + resource_type: ResourceType = ResourceType.TASK + + +class DummyTaskV1API(ResourceV1API, DummyTaskAPI): + pass + + +class DummyTaskV2API(ResourceV2API, DummyTaskAPI): + pass + + +@pytest.fixture +def dummy_task_v1(http_client_v1, minio_client) -> DummyTaskV1API: + return DummyTaskV1API(http=http_client_v1, minio=minio_client) + + +@pytest.fixture +def dummy_task_v2(http_client_v2, minio_client) -> DummyTaskV1API: + return DummyTaskV2API(http=http_client_v2, minio=minio_client) + + +@pytest.fixture +def dummy_task_fallback(dummy_task_v1, dummy_task_v2) -> DummyTaskV1API: + return FallbackProxy(dummy_task_v2, dummy_task_v1) + + +def test_v1_publish(dummy_task_v1, test_server_v1, test_apikey_v1): + resource = dummy_task_v1 + resource_name = resource.resource_type.value + resource_files = {"description": "Resource Description File"} + resource_id = 123 + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + f'\n' + f"\t{resource_id}\n" + f"\n" + ).encode() + + published_resource_id = resource.publish( + resource_name, + files=resource_files, + ) + + assert resource_id == published_resource_id + + mock_request.assert_called_once_with( + method="POST", + url=test_server_v1 + resource_name, + params={}, + data={"api_key": test_apikey_v1}, + headers=openml.config._HEADERS, + files=resource_files, + ) + + +def test_v1_delete(dummy_task_v1, test_server_v1, test_apikey_v1): + resource = dummy_task_v1 + resource_name = resource.resource_type.value + resource_id = 123 + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + f'\n' + f" {resource_id}\n" + f"\n" + ).encode() + + resource.delete(resource_id) + + mock_request.assert_called_once_with( + method="DELETE", + url=(test_server_v1 + resource_name + "/" + str(resource_id)), + params={"api_key": test_apikey_v1}, + data={}, + headers=openml.config._HEADERS, + files=None, + ) + + +def test_v1_tag(dummy_task_v1, test_server_v1, test_apikey_v1): + resource = dummy_task_v1 + resource_id = 123 + resource_tag = "TAG" + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + f'' + f"{resource_id}" + f"{resource_tag}" + f"" + ).encode() + + tags = resource.tag(resource_id, resource_tag) + + assert resource_tag in tags + + mock_request.assert_called_once_with( + method="POST", + url=(test_server_v1 + resource.resource_type + "/tag"), + params={}, + data={ + "api_key": test_apikey_v1, + "task_id": resource_id, + "tag": resource_tag, + }, + headers=openml.config._HEADERS, + files=None, + ) + + +def test_v1_untag(dummy_task_v1, test_server_v1, test_apikey_v1): + resource = dummy_task_v1 + resource_id = 123 + resource_tag = "TAG" + + with patch.object(Session, "request") as mock_request: + mock_request.return_value = Response() + mock_request.return_value.status_code = 200 + mock_request.return_value._content = ( + f'' + f"{resource_id}" + f"" + ).encode() + + tags = resource.untag(resource_id, resource_tag) + + assert resource_tag not in tags + + mock_request.assert_called_once_with( + method="POST", + url=(test_server_v1 + resource.resource_type + "/untag"), + params={}, + data={ + "api_key": test_apikey_v1, + "task_id": resource_id, + "tag": resource_tag, + }, + headers=openml.config._HEADERS, + files=None, + ) + + +def test_v2_publish(dummy_task_v2): + with pytest.raises(OpenMLNotSupportedError): + dummy_task_v2.publish(path=None, files=None) + + +def test_v2_delete(dummy_task_v2): + with pytest.raises(OpenMLNotSupportedError): + dummy_task_v2.delete(resource_id=None) + + +def test_v2_tag(dummy_task_v2): + with pytest.raises(OpenMLNotSupportedError): + dummy_task_v2.tag(resource_id=None, tag=None) + + +def test_v2_untag(dummy_task_v2): + with pytest.raises(OpenMLNotSupportedError): + dummy_task_v2.untag(resource_id=None, tag=None) + + +def test_fallback_publish(dummy_task_fallback): + with patch.object(ResourceV1API, "publish") as mock_publish: + mock_publish.return_value = None + dummy_task_fallback.publish(path=None, files=None) + mock_publish.assert_called_once_with(path=None, files=None) + + +def test_fallback_delete(dummy_task_fallback): + with patch.object(ResourceV1API, "delete") as mock_delete: + mock_delete.return_value = None + dummy_task_fallback.delete(resource_id=None) + mock_delete.assert_called_once_with(resource_id=None) + + +def test_fallback_tag(dummy_task_fallback): + with patch.object(ResourceV1API, "tag") as mock_tag: + mock_tag.return_value = None + dummy_task_fallback.tag(resource_id=None, tag=None) + mock_tag.assert_called_once_with(resource_id=None, tag=None) + + +def test_fallback_untag(dummy_task_fallback): + with patch.object(ResourceV1API, "untag") as mock_untag: + mock_untag.return_value = None + dummy_task_fallback.untag(resource_id=None, tag=None) + mock_untag.assert_called_once_with(resource_id=None, tag=None) diff --git a/tests/test_datasets/test_dataset.py b/tests/test_datasets/test_dataset.py index 86a4d3f57..facc111d3 100644 --- a/tests/test_datasets/test_dataset.py +++ b/tests/test_datasets/test_dataset.py @@ -2,23 +2,19 @@ from __future__ import annotations import os -import unittest.mock +from pathlib import Path from time import time import numpy as np import pandas as pd import pytest -from scipy import sparse import openml from openml.datasets import OpenMLDataFeature, OpenMLDataset -from openml.exceptions import PyOpenMLError from openml.testing import TestBase -import pytest - -@pytest.mark.production() +@pytest.mark.production_server class OpenMLDatasetTest(TestBase): _multiprocess_can_split_ = True @@ -94,29 +90,32 @@ def test__unpack_categories_with_nan_likes(self): clean_series = OpenMLDataset._unpack_categories(series, categories) expected_values = ["a", "b", np.nan, np.nan, np.nan, "b", "a"] - self.assertListEqual(list(clean_series.values), expected_values) - self.assertListEqual(list(clean_series.cat.categories.values), list("ab")) + self.assertListEqual(list(clean_series.values), expected_values) # noqa: PT009 + self.assertListEqual(list(clean_series.cat.categories.values), list("ab")) # noqa: PT009 def test_get_data_pandas(self): data, _, _, _ = self.titanic.get_data() assert isinstance(data, pd.DataFrame) assert data.shape[1] == len(self.titanic.features) assert data.shape[0] == 1309 + # Dynamically detect what this version of Pandas calls string columns. + str_dtype = data["name"].dtype.name + col_dtype = { "pclass": "uint8", "survived": "category", - "name": "object", + "name": str_dtype, "sex": "category", "age": "float64", "sibsp": "uint8", "parch": "uint8", - "ticket": "object", + "ticket": str_dtype, "fare": "float64", - "cabin": "object", + "cabin": str_dtype, "embarked": "category", - "boat": "object", + "boat": str_dtype, "body": "float64", - "home.dest": "object", + "home.dest": str_dtype, } for col_name in data.columns: assert data[col_name].dtype.name == col_dtype[col_name] @@ -159,14 +158,14 @@ def test_get_data_with_rowid(self): self.dataset.row_id_attribute = "condition" rval, _, categorical, _ = self.dataset.get_data(include_row_id=True) assert isinstance(rval, pd.DataFrame) - for dtype, is_cat, col in zip(rval.dtypes, categorical, rval): + for dtype, is_cat, col in zip(rval.dtypes, categorical, rval, strict=False): self._check_expected_type(dtype, is_cat, rval[col]) assert rval.shape == (898, 39) assert len(categorical) == 39 rval, _, categorical, _ = self.dataset.get_data() assert isinstance(rval, pd.DataFrame) - for dtype, is_cat, col in zip(rval.dtypes, categorical, rval): + for dtype, is_cat, col in zip(rval.dtypes, categorical, rval, strict=False): self._check_expected_type(dtype, is_cat, rval[col]) assert rval.shape == (898, 38) assert len(categorical) == 38 @@ -175,7 +174,7 @@ def test_get_data_with_rowid(self): def test_get_data_with_target_pandas(self): X, y, categorical, attribute_names = self.dataset.get_data(target="class") assert isinstance(X, pd.DataFrame) - for dtype, is_cat, col in zip(X.dtypes, categorical, X): + for dtype, is_cat, col in zip(X.dtypes, categorical, X, strict=False): self._check_expected_type(dtype, is_cat, X[col]) assert isinstance(y, pd.Series) assert y.dtype.name == "category" @@ -189,24 +188,24 @@ def test_get_data_with_target_pandas(self): def test_get_data_rowid_and_ignore_and_target(self): self.dataset.ignore_attribute = ["condition"] self.dataset.row_id_attribute = ["hardness"] - X, y, categorical, names = self.dataset.get_data(target="class") + X, y, categorical, _names = self.dataset.get_data(target="class") assert X.shape == (898, 36) assert len(categorical) == 36 cats = [True] * 3 + [False, True, True, False] + [True] * 23 + [False] * 3 + [True] * 3 - self.assertListEqual(categorical, cats) + self.assertListEqual(categorical, cats) # noqa: PT009 assert y.shape == (898,) @pytest.mark.skip("https://github.com/openml/openml-python/issues/1157") def test_get_data_with_ignore_attributes(self): self.dataset.ignore_attribute = ["condition"] rval, _, categorical, _ = self.dataset.get_data(include_ignore_attribute=True) - for dtype, is_cat, col in zip(rval.dtypes, categorical, rval): + for dtype, is_cat, col in zip(rval.dtypes, categorical, rval, strict=False): self._check_expected_type(dtype, is_cat, rval[col]) assert rval.shape == (898, 39) assert len(categorical) == 39 rval, _, categorical, _ = self.dataset.get_data(include_ignore_attribute=False) - for dtype, is_cat, col in zip(rval.dtypes, categorical, rval): + for dtype, is_cat, col in zip(rval.dtypes, categorical, rval, strict=False): self._check_expected_type(dtype, is_cat, rval[col]) assert rval.shape == (898, 38) assert len(categorical) == 38 @@ -222,7 +221,7 @@ def test_get_data_corrupt_pickle(self): # Lazy loaded dataset, populate cache. self.iris.get_data() # Corrupt pickle file, overwrite as empty. - with open(self.iris.data_pickle_file, "w") as fh: + with open(self.iris.data_pickle_file, "w") as fh: # noqa: PTH123 fh.write("") # Despite the corrupt file, the data should be loaded from the ARFF file. # A warning message is written to the python logger. @@ -242,7 +241,7 @@ def test_lazy_loading_metadata(self): download_features_meta_data=True, download_qualities=True, ) - change_time = os.stat(did_cache_dir).st_mtime + change_time = os.stat(did_cache_dir).st_mtime # noqa: PTH116 # Test with cache _dataset = openml.datasets.get_dataset( @@ -251,7 +250,7 @@ def test_lazy_loading_metadata(self): download_features_meta_data=False, download_qualities=False, ) - assert change_time == os.stat(did_cache_dir).st_mtime + assert change_time == os.stat(did_cache_dir).st_mtime # noqa: PTH116 assert _dataset.features == _compare_dataset.features assert _dataset.qualities == _compare_dataset.qualities @@ -267,17 +266,18 @@ def test_lazy_loading_metadata(self): download_features_meta_data=False, download_qualities=False, ) - assert ["description.xml"] == os.listdir(did_cache_dir) - assert change_time != os.stat(did_cache_dir).st_mtime + assert [f.name for f in Path(did_cache_dir).iterdir()] == ["description.xml"] + assert change_time != os.stat(did_cache_dir).st_mtime # noqa: PTH116 assert _dataset.features == _compare_dataset.features assert _dataset.qualities == _compare_dataset.qualities def test_equality_comparison(self): - self.assertEqual(self.iris, self.iris) - self.assertNotEqual(self.iris, self.titanic) - self.assertNotEqual(self.titanic, "Wrong_object") + assert self.iris == self.iris + assert self.iris != self.titanic + assert self.titanic != "Wrong_object" +@pytest.mark.test_server def test_tagging(): dataset = openml.datasets.get_dataset(125, download_data=False) @@ -294,51 +294,58 @@ def test_tagging(): datasets = openml.datasets.list_datasets(tag=tag) assert datasets.empty + +@pytest.mark.test_server def test_get_feature_with_ontology_data_id_11(): # test on car dataset, which has built-in ontology references dataset = openml.datasets.get_dataset(11) assert len(dataset.features) == 7 assert len(dataset.features[1].ontologies) >= 2 assert len(dataset.features[2].ontologies) >= 1 - assert len(dataset.features[3].ontologies) >= 1 + assert len(dataset.features[3].ontologies) >= 1 + +@pytest.mark.test_server def test_add_remove_ontology_to_dataset(): did = 1 feature_index = 1 ontology = "https://www.openml.org/unittest/" + str(time()) openml.datasets.functions.data_feature_add_ontology(did, feature_index, ontology) - openml.datasets.functions.data_feature_remove_ontology(did, feature_index, ontology) + openml.datasets.functions.data_feature_remove_ontology(did, feature_index, ontology) + +@pytest.mark.test_server def test_add_same_ontology_multiple_features(): did = 1 ontology = "https://www.openml.org/unittest/" + str(time()) for i in range(3): - openml.datasets.functions.data_feature_add_ontology(did, i, ontology) + openml.datasets.functions.data_feature_add_ontology(did, i, ontology) +@pytest.mark.test_server def test_add_illegal_long_ontology(): did = 1 ontology = "http://www.google.com/" + ("a" * 257) try: openml.datasets.functions.data_feature_add_ontology(did, 1, ontology) - assert False + raise AssertionError() except openml.exceptions.OpenMLServerException as e: - assert e.code == 1105 - + assert e.code == 1105 # noqa: PT017 +@pytest.mark.test_server def test_add_illegal_url_ontology(): did = 1 ontology = "not_a_url" + str(time()) try: openml.datasets.functions.data_feature_add_ontology(did, 1, ontology) - assert False + raise AssertionError() except openml.exceptions.OpenMLServerException as e: - assert e.code == 1106 + assert e.code == 1106 # noqa: PT017 -@pytest.mark.production() +@pytest.mark.production_server class OpenMLDatasetTestSparse(TestBase): _multiprocess_can_split_ = True @@ -351,7 +358,7 @@ def setUp(self): def test_get_sparse_dataset_dataframe_with_target(self): X, y, _, attribute_names = self.sparse_dataset.get_data(target="class") assert isinstance(X, pd.DataFrame) - assert isinstance(X.dtypes[0], pd.SparseDtype) + assert isinstance(X.dtypes.iloc[0], pd.SparseDtype) assert X.shape == (600, 20000) assert isinstance(y, pd.Series) @@ -385,7 +392,7 @@ def test_get_sparse_dataset_rowid_and_ignore_and_target(self): assert X.shape == (600, 19998) assert len(categorical) == 19998 - self.assertListEqual(categorical, [False] * 19998) + self.assertListEqual(categorical, [False] * 19998) # noqa: PT009 assert y.shape == (600,) def test_get_sparse_categorical_data_id_395(self): @@ -399,6 +406,7 @@ def test_get_sparse_categorical_data_id_395(self): assert len(feature.nominal_values) == 25 +@pytest.mark.test_server def test__read_features(mocker, workdir, static_cache_dir): """Test we read the features from the xml if no cache pickle is available. This test also does some simple checks to verify that the features are read correctly @@ -406,11 +414,11 @@ def test__read_features(mocker, workdir, static_cache_dir): filename_mock = mocker.patch("openml.datasets.dataset._get_features_pickle_file") pickle_mock = mocker.patch("openml.datasets.dataset.pickle") - filename_mock.return_value = os.path.join(workdir, "features.xml.pkl") + filename_mock.return_value = os.path.join(workdir, "features.xml.pkl") # noqa: PTH118 pickle_mock.load.side_effect = FileNotFoundError features = openml.datasets.dataset._read_features( - os.path.join( + os.path.join( # noqa: PTH118 static_cache_dir, "org", "openml", @@ -430,19 +438,19 @@ def test__read_features(mocker, workdir, static_cache_dir): assert pickle_mock.dump.call_count == 1 +@pytest.mark.test_server def test__read_qualities(static_cache_dir, workdir, mocker): """Test we read the qualities from the xml if no cache pickle is available. This test also does some minor checks to ensure that the qualities are read correctly. """ - filename_mock = mocker.patch("openml.datasets.dataset._get_qualities_pickle_file") pickle_mock = mocker.patch("openml.datasets.dataset.pickle") - filename_mock.return_value=os.path.join(workdir, "qualities.xml.pkl") + filename_mock.return_value = os.path.join(workdir, "qualities.xml.pkl") # noqa: PTH118 pickle_mock.load.side_effect = FileNotFoundError qualities = openml.datasets.dataset._read_qualities( - os.path.join( + os.path.join( # noqa: PTH118 static_cache_dir, "org", "openml", @@ -458,7 +466,6 @@ def test__read_qualities(static_cache_dir, workdir, mocker): assert pickle_mock.dump.call_count == 1 - def test__check_qualities(): qualities = [{"oml:name": "a", "oml:value": "0.5"}] qualities = openml.datasets.dataset._check_qualities(qualities) @@ -470,4 +477,4 @@ def test__check_qualities(): qualities = [{"oml:name": "a", "oml:value": None}] qualities = openml.datasets.dataset._check_qualities(qualities) - assert qualities["a"] != qualities["a"] \ No newline at end of file + assert qualities["a"] != qualities["a"] diff --git a/tests/test_datasets/test_dataset_functions.py b/tests/test_datasets/test_dataset_functions.py index 4145b86ad..fae4a1c4e 100644 --- a/tests/test_datasets/test_dataset_functions.py +++ b/tests/test_datasets/test_dataset_functions.py @@ -7,9 +7,9 @@ import shutil import time import uuid +from collections.abc import Iterable from itertools import product from pathlib import Path -from typing import Iterable from unittest import mock import arff @@ -17,7 +17,6 @@ import pandas as pd import pytest import requests -import requests_mock import scipy.sparse from oslo_concurrency import lockutils @@ -59,20 +58,20 @@ def tearDown(self): super().tearDown() def _remove_pickle_files(self): - self.lock_path = os.path.join(openml.config.get_cache_directory(), "locks") + self.lock_path = os.path.join(openml.config.get_cache_directory(), "locks") # noqa: PTH118 for did in ["-1", "2"]: with lockutils.external_lock( name=f"datasets.functions.get_dataset:{did}", lock_path=self.lock_path, ): - pickle_path = os.path.join( + pickle_path = os.path.join( # noqa: PTH118 openml.config.get_cache_directory(), "datasets", did, "dataset.pkl.py3", ) - try: - os.remove(pickle_path) + try: # noqa: SIM105 + os.remove(pickle_path) # noqa: PTH107 except (OSError, FileNotFoundError): # Replaced a bare except. Not sure why either of these would be acceptable. pass @@ -95,7 +94,7 @@ def _get_empty_param_for_dataset(self): } def _check_dataset(self, dataset): - assert type(dataset) == dict + assert type(dataset) == dict # noqa: E721 assert len(dataset) >= 2 assert "did" in dataset assert isinstance(dataset["did"], int) @@ -107,20 +106,23 @@ def _check_datasets(self, datasets): for did in datasets: self._check_dataset(datasets[did]) + @pytest.mark.test_server def test_tag_untag_dataset(self): - tag = "test_tag_%d" % random.randint(1, 1000000) + tag = f"test_tag_{random.randint(1, 1000000)}" # noqa: S311 all_tags = _tag_entity("data", 1, tag) assert tag in all_tags all_tags = _tag_entity("data", 1, tag, untag=True) assert tag not in all_tags + @pytest.mark.test_server def test_list_datasets_length(self): datasets = openml.datasets.list_datasets() assert len(datasets) >= 100 + @pytest.mark.test_server def test_list_datasets_paginate(self): size = 10 - max = 100 + max = 100 # noqa: A001 for i in range(0, max, size): datasets = openml.datasets.list_datasets(offset=i, size=size) assert len(datasets) == size @@ -132,11 +134,12 @@ def test_list_datasets_paginate(self): categories=["in_preparation", "active", "deactivated"], ) + @pytest.mark.test_server def test_list_datasets_empty(self): datasets = openml.datasets.list_datasets(tag="NoOneWouldUseThisTagAnyway") assert datasets.empty - @pytest.mark.production() + @pytest.mark.production_server def test_check_datasets_active(self): # Have to test on live because there is no deactivated dataset on the test server. self.use_production_server() @@ -147,14 +150,14 @@ def test_check_datasets_active(self): assert active[2] assert not active[17] assert active.get(79) is None - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 ValueError, r"Could not find dataset\(s\) 79 in OpenML dataset list.", openml.datasets.check_datasets_active, [79], ) - openml.config.server = self.test_server + @pytest.mark.test_server def test_illegal_character_tag(self): dataset = openml.datasets.get_dataset(1) tag = "illegal_tag&" @@ -162,8 +165,9 @@ def test_illegal_character_tag(self): dataset.push_tag(tag) raise AssertionError() except openml.exceptions.OpenMLServerException as e: - assert e.code == 477 + assert e.code == 477 # noqa: PT017 + @pytest.mark.test_server def test_illegal_length_tag(self): dataset = openml.datasets.get_dataset(1) tag = "a" * 65 @@ -171,33 +175,32 @@ def test_illegal_length_tag(self): dataset.push_tag(tag) raise AssertionError() except openml.exceptions.OpenMLServerException as e: - assert e.code == 477 + assert e.code == 477 # noqa: PT017 - @pytest.mark.production() + @pytest.mark.production_server def test__name_to_id_with_deactivated(self): """Check that an activated dataset is returned if an earlier deactivated one exists.""" self.use_production_server() # /d/1 was deactivated assert openml.datasets.functions._name_to_id("anneal") == 2 - openml.config.server = self.test_server - @pytest.mark.production() + @pytest.mark.production_server def test__name_to_id_with_multiple_active(self): """With multiple active datasets, retrieve the least recent active.""" self.use_production_server() assert openml.datasets.functions._name_to_id("iris") == 61 - @pytest.mark.production() + @pytest.mark.production_server def test__name_to_id_with_version(self): """With multiple active datasets, retrieve the least recent active.""" self.use_production_server() assert openml.datasets.functions._name_to_id("iris", version=3) == 969 - @pytest.mark.production() + @pytest.mark.production_server def test__name_to_id_with_multiple_active_error(self): """With multiple active datasets, retrieve the least recent active.""" self.use_production_server() - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 ValueError, "Multiple active datasets exist with name 'iris'.", openml.datasets.functions._name_to_id, @@ -205,18 +208,20 @@ def test__name_to_id_with_multiple_active_error(self): error_if_multiple=True, ) + @pytest.mark.test_server def test__name_to_id_name_does_not_exist(self): """With multiple active datasets, retrieve the least recent active.""" - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 RuntimeError, "No active datasets exist with name 'does_not_exist'.", openml.datasets.functions._name_to_id, dataset_name="does_not_exist", ) + @pytest.mark.test_server def test__name_to_id_version_does_not_exist(self): """With multiple active datasets, retrieve the least recent active.""" - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 RuntimeError, "No active datasets exist with name 'iris' and version '100000'.", openml.datasets.functions._name_to_id, @@ -224,6 +229,7 @@ def test__name_to_id_version_does_not_exist(self): version=100000, ) + @pytest.mark.test_server def test_get_datasets_by_name(self): # did 1 and 2 on the test server: dids = ["anneal", "kr-vs-kp"] @@ -231,6 +237,7 @@ def test_get_datasets_by_name(self): assert len(datasets) == 2 _assert_datasets_retrieved_successfully([1, 2]) + @pytest.mark.test_server def test_get_datasets_by_mixed(self): # did 1 and 2 on the test server: dids = ["anneal", 2] @@ -238,15 +245,17 @@ def test_get_datasets_by_mixed(self): assert len(datasets) == 2 _assert_datasets_retrieved_successfully([1, 2]) + @pytest.mark.test_server def test_get_datasets(self): dids = [1, 2] datasets = openml.datasets.get_datasets(dids) assert len(datasets) == 2 _assert_datasets_retrieved_successfully([1, 2]) + @pytest.mark.test_server def test_get_dataset_by_name(self): dataset = openml.datasets.get_dataset("anneal") - assert type(dataset) == OpenMLDataset + assert type(dataset) == OpenMLDataset # noqa: E721 assert dataset.dataset_id == 1 _assert_datasets_retrieved_successfully([1]) @@ -255,42 +264,44 @@ def test_get_dataset_by_name(self): @pytest.mark.skip("Feature is experimental, can not test against stable server.") def test_get_dataset_download_all_files(self): - # openml.datasets.get_dataset(id, download_all_files=True) + # openml.datasets.get_dataset(id, download_all_files=True) # noqa: ERA001 # check for expected files # checking that no additional files are downloaded if # the default (false) is used, seems covered by # test_get_dataset_lazy raise NotImplementedError + @pytest.mark.test_server def test_get_dataset_uint8_dtype(self): dataset = openml.datasets.get_dataset(1) - assert type(dataset) == OpenMLDataset + assert type(dataset) == OpenMLDataset # noqa: E721 assert dataset.name == "anneal" df, _, _, _ = dataset.get_data() assert df["carbon"].dtype == "uint8" - @pytest.mark.production() + @pytest.mark.production_server def test_get_dataset_cannot_access_private_data(self): # Issue324 Properly handle private datasets when trying to access them self.use_production_server() - self.assertRaises(OpenMLPrivateDatasetError, openml.datasets.get_dataset, 45) + self.assertRaises(OpenMLPrivateDatasetError, openml.datasets.get_dataset, 45) # noqa: PT027 @pytest.mark.skip("Need to find dataset name of private dataset") def test_dataset_by_name_cannot_access_private_data(self): self.use_production_server() - self.assertRaises(OpenMLPrivateDatasetError, openml.datasets.get_dataset, "NAME_GOES_HERE") + self.assertRaises(OpenMLPrivateDatasetError, openml.datasets.get_dataset, "NAME_GOES_HERE") # noqa: PT027 + @pytest.mark.test_server def test_get_dataset_lazy_all_functions(self): """Test that all expected functionality is available without downloading the dataset.""" dataset = openml.datasets.get_dataset(1) # We only tests functions as general integrity is tested by test_get_dataset_lazy def ensure_absence_of_real_data(): - assert not os.path.exists( - os.path.join(openml.config.get_cache_directory(), "datasets", "1", "dataset.arff") + assert not os.path.exists( # noqa: PTH110 + os.path.join(openml.config.get_cache_directory(), "datasets", "1", "dataset.arff") # noqa: PTH118 ) - tag = "test_lazy_tag_%d" % random.randint(1, 1000000) + tag = f"test_lazy_tag_{random.randint(1, 1000000)}" # noqa: S311 dataset.push_tag(tag) ensure_absence_of_real_data() @@ -309,24 +320,28 @@ def ensure_absence_of_real_data(): assert classes == ["1", "2", "3", "4", "5", "U"] ensure_absence_of_real_data() + @pytest.mark.test_server def test_get_dataset_sparse(self): dataset = openml.datasets.get_dataset(102) X, *_ = dataset.get_data() assert isinstance(X, pd.DataFrame) assert all(isinstance(col, pd.SparseDtype) for col in X.dtypes) + @pytest.mark.test_server def test_download_rowid(self): # Smoke test which checks that the dataset has the row-id set correctly did = 44 dataset = openml.datasets.get_dataset(did) assert dataset.row_id_attribute == "Counter" + @pytest.mark.test_server def test__get_dataset_description(self): description = _get_dataset_description(self.workdir, 2) assert isinstance(description, dict) - description_xml_path = os.path.join(self.workdir, "description.xml") - assert os.path.exists(description_xml_path) + description_xml_path = os.path.join(self.workdir, "description.xml") # noqa: PTH118 + assert os.path.exists(description_xml_path) # noqa: PTH110 + @pytest.mark.test_server def test__getarff_path_dataset_arff(self): openml.config.set_root_cache_directory(self.static_cache_dir) description = _get_dataset_description(self.workdir, 2) @@ -335,7 +350,7 @@ def test__getarff_path_dataset_arff(self): assert arff_path.exists() def test__download_minio_file_object_does_not_exist(self): - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 FileNotFoundError, r"Object at .* does not exist", _download_minio_file, @@ -350,26 +365,26 @@ def test__download_minio_file_to_directory(self): destination=self.workdir, exists_ok=True, ) - assert os.path.isfile( - os.path.join(self.workdir, "dataset_20.pq") - ), "_download_minio_file can save to a folder by copying the object name" + assert os.path.isfile(os.path.join(self.workdir, "dataset_20.pq")), ( # noqa: PTH113, PTH118 + "_download_minio_file can save to a folder by copying the object name" + ) def test__download_minio_file_to_path(self): - file_destination = os.path.join(self.workdir, "custom.pq") + file_destination = os.path.join(self.workdir, "custom.pq") # noqa: PTH118 _download_minio_file( source="http://data.openml.org/dataset20/dataset_20.pq", destination=file_destination, exists_ok=True, ) - assert os.path.isfile( - file_destination - ), "_download_minio_file can save to a folder by copying the object name" + assert os.path.isfile(file_destination), ( # noqa: PTH113 + "_download_minio_file can save to a folder by copying the object name" + ) def test__download_minio_file_raises_FileExists_if_destination_in_use(self): file_destination = Path(self.workdir, "custom.pq") file_destination.touch() - self.assertRaises( + self.assertRaises( # noqa: PT027 FileExistsError, _download_minio_file, source="http://data.openml.org/dataset20/dataset_20.pq", @@ -384,12 +399,12 @@ def test__download_minio_file_works_with_bucket_subdirectory(self): destination=file_destination, exists_ok=True, ) - assert os.path.isfile( - file_destination - ), "_download_minio_file can download from subdirectories" - + assert os.path.isfile(file_destination), ( # noqa: PTH113 + "_download_minio_file can download from subdirectories" + ) @mock.patch("openml._api_calls._download_minio_file") + @pytest.mark.test_server def test__get_dataset_parquet_is_cached(self, patch): openml.config.set_root_cache_directory(self.static_cache_dir) patch.side_effect = RuntimeError( @@ -420,7 +435,7 @@ def test__getarff_md5_issue(self): n = openml.config.connection_n_retries openml.config.connection_n_retries = 1 - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 OpenMLHashException, "Checksum of downloaded file is unequal to the expected checksum abc when downloading " "https://www.openml.org/data/download/61. Raised when downloading dataset 5.", @@ -430,33 +445,36 @@ def test__getarff_md5_issue(self): openml.config.connection_n_retries = n + @pytest.mark.test_server def test__get_dataset_features(self): features_file = _get_dataset_features_file(self.workdir, 2) assert isinstance(features_file, Path) features_xml_path = self.workdir / "features.xml" assert features_xml_path.exists() + @pytest.mark.test_server def test__get_dataset_qualities(self): qualities = _get_dataset_qualities_file(self.workdir, 2) assert isinstance(qualities, Path) qualities_xml_path = self.workdir / "qualities.xml" assert qualities_xml_path.exists() + @pytest.mark.test_server def test_get_dataset_force_refresh_cache(self): did_cache_dir = _create_cache_directory_for_id( DATASETS_CACHE_DIR_NAME, 2, ) openml.datasets.get_dataset(2) - change_time = os.stat(did_cache_dir).st_mtime + change_time = os.stat(did_cache_dir).st_mtime # noqa: PTH116 # Test default openml.datasets.get_dataset(2) - assert change_time == os.stat(did_cache_dir).st_mtime + assert change_time == os.stat(did_cache_dir).st_mtime # noqa: PTH116 # Test refresh openml.datasets.get_dataset(2, force_refresh_cache=True) - assert change_time != os.stat(did_cache_dir).st_mtime + assert change_time != os.stat(did_cache_dir).st_mtime # noqa: PTH116 # Final clean up openml.utils._remove_cache_dir_for_id( @@ -464,6 +482,7 @@ def test_get_dataset_force_refresh_cache(self): did_cache_dir, ) + @pytest.mark.test_server def test_get_dataset_force_refresh_cache_clean_start(self): did_cache_dir = _create_cache_directory_for_id( DATASETS_CACHE_DIR_NAME, @@ -477,7 +496,7 @@ def test_get_dataset_force_refresh_cache_clean_start(self): # Test clean start openml.datasets.get_dataset(2, force_refresh_cache=True) - assert os.path.exists(did_cache_dir) + assert os.path.exists(did_cache_dir) # noqa: PTH110 # Final clean up openml.utils._remove_cache_dir_for_id( @@ -491,29 +510,26 @@ def test_deletion_of_cache_dir(self): DATASETS_CACHE_DIR_NAME, 1, ) - assert os.path.exists(did_cache_dir) + assert os.path.exists(did_cache_dir) # noqa: PTH110 openml.utils._remove_cache_dir_for_id( DATASETS_CACHE_DIR_NAME, did_cache_dir, ) - assert not os.path.exists(did_cache_dir) + assert not os.path.exists(did_cache_dir) # noqa: PTH110 # get_dataset_description is the only data guaranteed to be downloaded @mock.patch("openml.datasets.functions._get_dataset_description") + @pytest.mark.test_server def test_deletion_of_cache_dir_faulty_download(self, patch): patch.side_effect = Exception("Boom!") - self.assertRaisesRegex(Exception, "Boom!", openml.datasets.get_dataset, dataset_id=1) - datasets_cache_dir = os.path.join(self.workdir, "org", "openml", "test", "datasets") - assert len(os.listdir(datasets_cache_dir)) == 0 + self.assertRaisesRegex(Exception, "Boom!", openml.datasets.get_dataset, dataset_id=1) # noqa: PT027 + datasets_cache_dir = os.path.join(openml.config.get_cache_directory(), "datasets") # noqa: PTH118 + assert len(list(Path(datasets_cache_dir).iterdir())) == 0 + @pytest.mark.test_server def test_publish_dataset(self): - # lazy loading not possible as we need the arff-file. - openml.datasets.get_dataset(3, download_data=True) - file_path = os.path.join( - openml.config.get_cache_directory(), - "datasets", - "3", - "dataset.arff", + arff_file_path = ( + self.static_cache_dir / "org" / "openml" / "test" / "datasets" / "2" / "dataset.arff" ) dataset = OpenMLDataset( "anneal", @@ -522,7 +538,7 @@ def test_publish_dataset(self): version=1, licence="public", default_target_attribute="class", - data_file=file_path, + data_file=arff_file_path, ) dataset.publish() TestBase._mark_entity_for_removal("data", dataset.dataset_id) @@ -531,6 +547,7 @@ def test_publish_dataset(self): ) assert isinstance(dataset.dataset_id, int) + @pytest.mark.test_server def test__retrieve_class_labels(self): openml.config.set_root_cache_directory(self.static_cache_dir) labels = openml.datasets.get_dataset(2).retrieve_class_labels() @@ -547,6 +564,7 @@ def test__retrieve_class_labels(self): labels = custom_ds.retrieve_class_labels(target_name=custom_ds.features[31].name) assert labels == ["COIL", "SHEET"] + @pytest.mark.test_server def test_upload_dataset_with_url(self): dataset = OpenMLDataset( f"{self._get_sentinel()}-UploadTestWithURL", @@ -572,7 +590,12 @@ def _assert_status_of_dataset(self, *, did: int, status: str): assert len(result) == 1 assert result[did]["status"] == status - @pytest.mark.flaky() + @pytest.mark.skipif( + not os.environ.get(openml.config.OPENML_TEST_SERVER_ADMIN_KEY_ENV_VAR), + reason="Test requires admin key. Set OPENML_TEST_SERVER_ADMIN_KEY environment variable.", + ) + @pytest.mark.flaky + @pytest.mark.test_server def test_data_status(self): dataset = OpenMLDataset( f"{self._get_sentinel()}-UploadTestWithURL", @@ -586,9 +609,9 @@ def test_data_status(self): TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {dataset.id}") did = dataset.id - # admin key for test server (only adminds can activate datasets. + # admin key for test server (only admins can activate datasets. # all users can deactivate their own datasets) - openml.config.apikey = "d488d8afd93b32331cf6ea9d7003d4c3" + openml.config.apikey = TestBase.admin_key openml.datasets.status_update(did, "active") self._assert_status_of_dataset(did=did, status="active") @@ -599,7 +622,7 @@ def test_data_status(self): openml.datasets.status_update(did, "active") self._assert_status_of_dataset(did=did, status="active") - with pytest.raises(ValueError): + with pytest.raises(ValueError): # noqa: PT011 openml.datasets.status_update(did, "in_preparation") self._assert_status_of_dataset(did=did, status="active") @@ -656,14 +679,13 @@ def test_attributes_arff_from_df_unknown_dtype(self): [pd.Timestamp("2012-05-01"), pd.Timestamp("2012-05-02")], ] dtype = ["mixed-integer", "datetime64"] - for arr, dt in zip(data, dtype): + for arr, dt in zip(data, dtype, strict=False): df = pd.DataFrame(arr) - err_msg = ( - f"The dtype '{dt}' of the column '0' is not currently " "supported by liac-arff" - ) + err_msg = f"The dtype '{dt}' of the column '0' is not currently supported by liac-arff" with pytest.raises(ValueError, match=err_msg): attributes_arff_from_df(df) + @pytest.mark.test_server def test_create_dataset_numpy(self): data = np.array([[1, 2, 3], [1.2, 2.5, 3.8], [2, 5, 8], [0, 1, 0]]).T @@ -692,11 +714,12 @@ def test_create_dataset_numpy(self): TestBase._mark_entity_for_removal("data", dataset.id) TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {dataset.id}") - assert ( - _get_online_dataset_arff(dataset.id) == dataset._dataset - ), "Uploaded arff does not match original one" + assert _get_online_dataset_arff(dataset.id) == dataset._dataset, ( + "Uploaded arff does not match original one" + ) assert _get_online_dataset_format(dataset.id) == "arff", "Wrong format for dataset" + @pytest.mark.test_server def test_create_dataset_list(self): data = [ ["a", "sunny", 85.0, 85.0, "FALSE", "no"], @@ -746,11 +769,12 @@ def test_create_dataset_list(self): dataset.publish() TestBase._mark_entity_for_removal("data", dataset.id) TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {dataset.id}") - assert ( - _get_online_dataset_arff(dataset.id) == dataset._dataset - ), "Uploaded ARFF does not match original one" + assert _get_online_dataset_arff(dataset.id) == dataset._dataset, ( + "Uploaded ARFF does not match original one" + ) assert _get_online_dataset_format(dataset.id) == "arff", "Wrong format for dataset" + @pytest.mark.test_server def test_create_dataset_sparse(self): # test the scipy.sparse.coo_matrix sparse_data = scipy.sparse.coo_matrix( @@ -788,12 +812,12 @@ def test_create_dataset_sparse(self): TestBase.logger.info( f"collected from {__file__.split('/')[-1]}: {xor_dataset.id}", ) - assert ( - _get_online_dataset_arff(xor_dataset.id) == xor_dataset._dataset - ), "Uploaded ARFF does not match original one" - assert ( - _get_online_dataset_format(xor_dataset.id) == "sparse_arff" - ), "Wrong format for dataset" + assert _get_online_dataset_arff(xor_dataset.id) == xor_dataset._dataset, ( + "Uploaded ARFF does not match original one" + ) + assert _get_online_dataset_format(xor_dataset.id) == "sparse_arff", ( + "Wrong format for dataset" + ) # test the list of dicts sparse representation sparse_data = [{0: 0.0}, {1: 1.0, 2: 1.0}, {0: 1.0, 2: 1.0}, {0: 1.0, 1: 1.0}] @@ -820,12 +844,12 @@ def test_create_dataset_sparse(self): TestBase.logger.info( f"collected from {__file__.split('/')[-1]}: {xor_dataset.id}", ) - assert ( - _get_online_dataset_arff(xor_dataset.id) == xor_dataset._dataset - ), "Uploaded ARFF does not match original one" - assert ( - _get_online_dataset_format(xor_dataset.id) == "sparse_arff" - ), "Wrong format for dataset" + assert _get_online_dataset_arff(xor_dataset.id) == xor_dataset._dataset, ( + "Uploaded ARFF does not match original one" + ) + assert _get_online_dataset_format(xor_dataset.id) == "sparse_arff", ( + "Wrong format for dataset" + ) def test_create_invalid_dataset(self): data = [ @@ -848,13 +872,14 @@ def test_create_invalid_dataset(self): param = self._get_empty_param_for_dataset() param["data"] = data - self.assertRaises(ValueError, create_dataset, **param) + self.assertRaises(ValueError, create_dataset, **param) # noqa: PT027 param["data"] = data[0] - self.assertRaises(ValueError, create_dataset, **param) + self.assertRaises(ValueError, create_dataset, **param) # noqa: PT027 + @pytest.mark.test_server def test_get_online_dataset_arff(self): - dataset_id = 100 # Australian + dataset_id = 128 # iris -- one of the few datasets without parquet file # lazy loading not used as arff file is checked. dataset = openml.datasets.get_dataset(dataset_id, download_data=True) decoder = arff.ArffDecoder() @@ -868,9 +893,10 @@ def test_get_online_dataset_arff(self): return_type=arff.DENSE if d_format == "arff" else arff.COO, ), "ARFF files are not equal" + @pytest.mark.test_server def test_topic_api_error(self): # Check server exception when non-admin accessses apis - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 OpenMLServerException, "Topic can only be added/removed by admin.", _topic_add_dataset, @@ -878,7 +904,7 @@ def test_topic_api_error(self): topic="business", ) # Check server exception when non-admin accessses apis - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 OpenMLServerException, "Topic can only be added/removed by admin.", _topic_delete_dataset, @@ -886,15 +912,17 @@ def test_topic_api_error(self): topic="business", ) + @pytest.mark.test_server def test_get_online_dataset_format(self): # Phoneme dataset dataset_id = 77 dataset = openml.datasets.get_dataset(dataset_id) - assert dataset.format.lower() == _get_online_dataset_format( - dataset_id - ), "The format of the ARFF files is different" + assert dataset.format.lower() == _get_online_dataset_format(dataset_id), ( + "The format of the ARFF files is different" + ) + @pytest.mark.test_server def test_create_dataset_pandas(self): data = [ ["a", "sunny", 85.0, 85.0, "FALSE", "no"], @@ -947,9 +975,9 @@ def test_create_dataset_pandas(self): dataset.publish() TestBase._mark_entity_for_removal("data", dataset.id) TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {dataset.id}") - assert ( - _get_online_dataset_arff(dataset.id) == dataset._dataset - ), "Uploaded ARFF does not match original one" + assert _get_online_dataset_arff(dataset.id) == dataset._dataset, ( + "Uploaded ARFF does not match original one" + ) # Check that DataFrame with Sparse columns are supported properly sparse_data = scipy.sparse.coo_matrix( @@ -983,9 +1011,9 @@ def test_create_dataset_pandas(self): dataset.publish() TestBase._mark_entity_for_removal("data", dataset.id) TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {dataset.id}") - assert ( - _get_online_dataset_arff(dataset.id) == dataset._dataset - ), "Uploaded ARFF does not match original one" + assert _get_online_dataset_arff(dataset.id) == dataset._dataset, ( + "Uploaded ARFF does not match original one" + ) assert _get_online_dataset_format(dataset.id) == "sparse_arff", "Wrong format for dataset" # Check that we can overwrite the attributes @@ -1119,6 +1147,7 @@ def test_ignore_attributes_dataset(self): paper_url=paper_url, ) + @pytest.mark.test_server def test_publish_fetch_ignore_attribute(self): """Test to upload and retrieve dataset and check ignore_attributes""" data = [ @@ -1237,6 +1266,7 @@ def test_create_dataset_row_id_attribute_error(self): paper_url=paper_url, ) + @pytest.mark.test_server def test_create_dataset_row_id_attribute_inference(self): # meta-information name = f"{self._get_sentinel()}-pandas_testing_dataset" @@ -1259,6 +1289,7 @@ def test_create_dataset_row_id_attribute_inference(self): for output_row_id, (row_id, index_name) in zip( expected_row_id, product(row_id_attr, df_index_name), + strict=False, ): df.index.name = index_name dataset = openml.datasets.functions.create_dataset( @@ -1327,26 +1358,29 @@ def test_create_dataset_attributes_auto_without_df(self): paper_url=paper_url, ) + @pytest.mark.test_server def test_list_qualities(self): qualities = openml.datasets.list_qualities() assert isinstance(qualities, list) is True assert all(isinstance(q, str) for q in qualities) is True + @pytest.mark.test_server def test_get_dataset_cache_format_pickle(self): dataset = openml.datasets.get_dataset(1) dataset.get_data() - assert type(dataset) == OpenMLDataset + assert type(dataset) == OpenMLDataset # noqa: E721 assert dataset.name == "anneal" assert len(dataset.features) > 1 assert len(dataset.qualities) > 4 - X, y, categorical, attribute_names = dataset.get_data() + X, _y, categorical, attribute_names = dataset.get_data() assert isinstance(X, pd.DataFrame) assert X.shape == (898, 39) assert len(categorical) == X.shape[1] assert len(attribute_names) == X.shape[1] + @pytest.mark.test_server def test_get_dataset_cache_format_feather(self): # This test crashed due to using the parquet file by default, which is downloaded # from minio. However, there is a mismatch between OpenML test server and minio IDs. @@ -1359,50 +1393,29 @@ def test_get_dataset_cache_format_feather(self): # Check if dataset is written to cache directory using feather cache_dir = openml.config.get_cache_directory() - cache_dir_for_id = os.path.join(cache_dir, "datasets", "128") - feather_file = os.path.join(cache_dir_for_id, "dataset.feather") - pickle_file = os.path.join(cache_dir_for_id, "dataset.feather.attributes.pkl.py3") + cache_dir_for_id = os.path.join(cache_dir, "datasets", "128") # noqa: PTH118 + feather_file = os.path.join(cache_dir_for_id, "dataset.feather") # noqa: PTH118 + pickle_file = os.path.join(cache_dir_for_id, "dataset.feather.attributes.pkl.py3") # noqa: PTH118 data = pd.read_feather(feather_file) - assert os.path.isfile(feather_file), "Feather file is missing" - assert os.path.isfile(pickle_file), "Attributes pickle file is missing" + assert os.path.isfile(feather_file), "Feather file is missing" # noqa: PTH113 + assert os.path.isfile(pickle_file), "Attributes pickle file is missing" # noqa: PTH113 assert data.shape == (150, 5) # Check if get_data is able to retrieve feather data - assert type(dataset) == OpenMLDataset + assert type(dataset) == OpenMLDataset # noqa: E721 assert dataset.name == "iris" assert len(dataset.features) > 1 assert len(dataset.qualities) > 4 - X, y, categorical, attribute_names = dataset.get_data() + X, _y, categorical, attribute_names = dataset.get_data() assert isinstance(X, pd.DataFrame) assert X.shape == (150, 5) assert len(categorical) == X.shape[1] assert len(attribute_names) == X.shape[1] - def test_data_edit_non_critical_field(self): - # Case 1 - # All users can edit non-critical fields of datasets - desc = ( - "This data sets consists of 3 different types of irises' " - "(Setosa, Versicolour, and Virginica) petal and sepal length," - " stored in a 150x4 numpy.ndarray" - ) - did = 128 - result = edit_dataset( - did, - description=desc, - creator="R.A.Fisher", - collection_date="1937", - citation="The use of multiple measurements in taxonomic problems", - language="English", - ) - assert did == result - edited_dataset = openml.datasets.get_dataset(did) - assert edited_dataset.description == desc - + @pytest.mark.test_server def test_data_edit_critical_field(self): - # Case 2 - # only owners (or admin) can edit all critical fields of datasets + # only owners (or admin) can edit all fields of datasets # for this, we need to first clone a dataset to do changes did = fork_dataset(1) self._wait_for_dataset_being_processed(did) @@ -1422,25 +1435,26 @@ def test_data_edit_critical_field(self): raise e time.sleep(10) # Delete the cache dir to get the newer version of the dataset + shutil.rmtree( - os.path.join(self.workdir, "org", "openml", "test", "datasets", str(did)), + os.path.join(openml.config.get_cache_directory(), "datasets", str(did)), # noqa: PTH118 ) def test_data_edit_requires_field(self): # Check server exception when no field to edit is provided - self.assertRaisesRegex( - OpenMLServerException, - "Please provide atleast one field among description, creator, " - "contributor, collection_date, language, citation, " - "original_data_url, default_target_attribute, row_id_attribute, " - "ignore_attribute or paper_url to edit.", - edit_dataset, - data_id=64, # blood-transfusion-service-center - ) + with pytest.raises( + ValueError, + match=r"Please provide atleast one field among description, creator, " + r"contributor, collection_date, language, citation, " + r"original_data_url, default_target_attribute, row_id_attribute, " + r"ignore_attribute or paper_url to edit\.", + ): + edit_dataset(data_id=64) # blood-transfusion-service-center + @pytest.mark.test_server def test_data_edit_requires_valid_dataset(self): # Check server exception when unknown dataset is provided - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 OpenMLServerException, "Unknown dataset", edit_dataset, @@ -1448,6 +1462,7 @@ def test_data_edit_requires_valid_dataset(self): description="xor operation dataset", ) + @pytest.mark.test_server def test_data_edit_cannot_edit_critical_field_if_dataset_has_task(self): # Need to own a dataset to be able to edit meta-data # Will be creating a forked version of an existing dataset to allow the unit test user @@ -1465,7 +1480,7 @@ def test_data_edit_cannot_edit_critical_field_if_dataset_has_task(self): task = task.publish() TestBase._mark_entity_for_removal("task", task.task_id) # Check server exception when owner/admin edits critical fields of dataset with tasks - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 OpenMLServerException, "Critical features default_target_attribute, row_id_attribute and ignore_attribute " "can only be edited for datasets without any tasks.", @@ -1474,31 +1489,32 @@ def test_data_edit_cannot_edit_critical_field_if_dataset_has_task(self): default_target_attribute="y", ) - def test_edit_data_user_cannot_edit_critical_field_of_other_users_dataset(self): + @pytest.mark.test_server + def test_edit_data_user_cannot_edit_field_of_other_users_dataset(self): # Check server exception when a non-owner or non-admin tries to edit critical fields - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 OpenMLServerException, - "Critical features default_target_attribute, row_id_attribute and ignore_attribute " - "can be edited only by the owner. Fork the dataset if changes are required.", + "Dataset can only be edited by the owner or an administrator. " + "Fork the dataset if changes are required.", edit_dataset, data_id=128, default_target_attribute="y", ) + @pytest.mark.test_server def test_data_fork(self): did = 1 result = fork_dataset(did) assert did != result # Check server exception when unknown dataset is provided - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 OpenMLServerException, "Unknown dataset", fork_dataset, data_id=999999, ) - - @pytest.mark.production() + @pytest.mark.production_server def test_list_datasets_with_high_size_parameter(self): # Testing on prod since concurrent deletion of uploded datasets make the test fail self.use_production_server() @@ -1507,7 +1523,6 @@ def test_list_datasets_with_high_size_parameter(self): datasets_b = openml.datasets.list_datasets(size=np.inf) # Reverting to test server - openml.config.server = self.test_server assert len(datasets_a) == len(datasets_b) @@ -1682,8 +1697,9 @@ def test_delete_dataset(self): @mock.patch.object(requests.Session, "delete") -def test_delete_dataset_not_owned(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +def test_delete_dataset_not_owned( + mock_delete, test_files_directory, test_server_v1, test_apikey_v1 +): content_file = ( test_files_directory / "mock_responses" / "datasets" / "data_delete_not_owned.xml" ) @@ -1694,18 +1710,17 @@ def test_delete_dataset_not_owned(mock_delete, test_files_directory, test_api_ke with pytest.raises( OpenMLNotAuthorizedError, - match="The data can not be deleted because it was not uploaded by you.", + match=r"The data can not be deleted because it was not uploaded by you.", ): openml.datasets.delete_dataset(40_000) - dataset_url = "https://test.openml.org/api/v1/xml/data/40000" + dataset_url = test_server_v1 + "data/40000" assert dataset_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert test_apikey_v1 == mock_delete.call_args.kwargs.get("params", {}).get("api_key") @mock.patch.object(requests.Session, "delete") -def test_delete_dataset_with_run(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +def test_delete_dataset_with_run(mock_delete, test_files_directory, test_server_v1, test_apikey_v1): content_file = ( test_files_directory / "mock_responses" / "datasets" / "data_delete_has_tasks.xml" ) @@ -1720,14 +1735,13 @@ def test_delete_dataset_with_run(mock_delete, test_files_directory, test_api_key ): openml.datasets.delete_dataset(40_000) - dataset_url = "https://test.openml.org/api/v1/xml/data/40000" + dataset_url = test_server_v1 + "data/40000" assert dataset_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert test_apikey_v1 == mock_delete.call_args.kwargs.get("params", {}).get("api_key") @mock.patch.object(requests.Session, "delete") -def test_delete_dataset_success(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +def test_delete_dataset_success(mock_delete, test_files_directory, test_server_v1, test_apikey_v1): content_file = ( test_files_directory / "mock_responses" / "datasets" / "data_delete_successful.xml" ) @@ -1739,14 +1753,13 @@ def test_delete_dataset_success(mock_delete, test_files_directory, test_api_key) success = openml.datasets.delete_dataset(40000) assert success - dataset_url = "https://test.openml.org/api/v1/xml/data/40000" + dataset_url = test_server_v1 + "data/40000" assert dataset_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert test_apikey_v1 == mock_delete.call_args.kwargs.get("params", {}).get("api_key") @mock.patch.object(requests.Session, "delete") -def test_delete_unknown_dataset(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +def test_delete_unknown_dataset(mock_delete, test_files_directory, test_server_v1, test_apikey_v1): content_file = ( test_files_directory / "mock_responses" / "datasets" / "data_delete_not_exist.xml" ) @@ -1761,9 +1774,9 @@ def test_delete_unknown_dataset(mock_delete, test_files_directory, test_api_key) ): openml.datasets.delete_dataset(9_999_999) - dataset_url = "https://test.openml.org/api/v1/xml/data/9999999" + dataset_url = test_server_v1 + "data/9999999" assert dataset_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert test_apikey_v1 == mock_delete.call_args.kwargs.get("params", {}).get("api_key") def _assert_datasets_have_id_and_valid_status(datasets: pd.DataFrame): @@ -1776,6 +1789,7 @@ def all_datasets(): return openml.datasets.list_datasets() +@pytest.mark.test_server def test_list_datasets(all_datasets: pd.DataFrame): # We can only perform a smoke test here because we test on dynamic # data from the internet... @@ -1784,42 +1798,49 @@ def test_list_datasets(all_datasets: pd.DataFrame): _assert_datasets_have_id_and_valid_status(all_datasets) +@pytest.mark.test_server def test_list_datasets_by_tag(all_datasets: pd.DataFrame): tag_datasets = openml.datasets.list_datasets(tag="study_14") assert 0 < len(tag_datasets) < len(all_datasets) _assert_datasets_have_id_and_valid_status(tag_datasets) +@pytest.mark.test_server def test_list_datasets_by_size(): datasets = openml.datasets.list_datasets(size=5) assert len(datasets) == 5 _assert_datasets_have_id_and_valid_status(datasets) +@pytest.mark.test_server def test_list_datasets_by_number_instances(all_datasets: pd.DataFrame): small_datasets = openml.datasets.list_datasets(number_instances="5..100") assert 0 < len(small_datasets) <= len(all_datasets) _assert_datasets_have_id_and_valid_status(small_datasets) +@pytest.mark.test_server def test_list_datasets_by_number_features(all_datasets: pd.DataFrame): wide_datasets = openml.datasets.list_datasets(number_features="50..100") assert 8 <= len(wide_datasets) < len(all_datasets) _assert_datasets_have_id_and_valid_status(wide_datasets) +@pytest.mark.test_server def test_list_datasets_by_number_classes(all_datasets: pd.DataFrame): five_class_datasets = openml.datasets.list_datasets(number_classes="5") assert 3 <= len(five_class_datasets) < len(all_datasets) _assert_datasets_have_id_and_valid_status(five_class_datasets) +@pytest.mark.test_server def test_list_datasets_by_number_missing_values(all_datasets: pd.DataFrame): na_datasets = openml.datasets.list_datasets(number_missing_values="5..100") assert 5 <= len(na_datasets) < len(all_datasets) _assert_datasets_have_id_and_valid_status(na_datasets) +@pytest.mark.test_server def test_list_datasets_combined_filters(all_datasets: pd.DataFrame): combined_filter_datasets = openml.datasets.list_datasets( tag="study_14", @@ -1848,16 +1869,15 @@ def _dataset_features_is_downloaded(did: int): def _dataset_data_file_is_downloaded(did: int): - parquet_present = _dataset_file_is_downloaded(did, "dataset.pq") - arff_present = _dataset_file_is_downloaded(did, "dataset.arff") - return parquet_present or arff_present + cache_directory = Path(openml.config.get_cache_directory()) / "datasets" / str(did) + return any(f.suffix in (".pq", ".arff") for f in cache_directory.iterdir()) def _assert_datasets_retrieved_successfully( dids: Iterable[int], - with_qualities: bool = False, - with_features: bool = False, - with_data: bool = False, + with_qualities: bool = False, # noqa: FBT002 + with_features: bool = False, # noqa: FBT002 + with_data: bool = False, # noqa: FBT002 ): """Checks that all files for the given dids have been downloaded. @@ -1880,7 +1900,7 @@ def _assert_datasets_retrieved_successfully( assert has_data if with_data else not has_data -@pytest.fixture() +@pytest.fixture def isolate_for_test(): t = TestOpenMLDataset() t.setUp(tmpdir_suffix=uuid.uuid4().hex) @@ -1892,8 +1912,12 @@ def isolate_for_test(): ("with_data", "with_qualities", "with_features"), itertools.product([True, False], repeat=3), ) +@pytest.mark.test_server def test_get_dataset_lazy_behavior( - isolate_for_test, with_data: bool, with_qualities: bool, with_features: bool + isolate_for_test, # noqa: ARG001 + with_data: bool, + with_qualities: bool, + with_features: bool, # noqa: ARG001, RUF100 ): dataset = openml.datasets.get_dataset( 1, @@ -1901,7 +1925,7 @@ def test_get_dataset_lazy_behavior( download_qualities=with_qualities, download_features_meta_data=with_features, ) - assert type(dataset) == OpenMLDataset + assert type(dataset) == OpenMLDataset # noqa: E721 assert dataset.name == "anneal" _assert_datasets_retrieved_successfully( @@ -1918,9 +1942,10 @@ def test_get_dataset_lazy_behavior( ) +@pytest.mark.test_server def test_get_dataset_with_invalid_id() -> None: INVALID_ID = 123819023109238 # Well, at some point this will probably be valid... - with pytest.raises(OpenMLServerNoResult, match="Unknown dataset") as e: + with pytest.raises(OpenMLServerNoResult, match="Unknown dataset") as e: # noqa: PT012 openml.datasets.get_dataset(INVALID_ID) assert e.value.code == 111 @@ -1930,7 +1955,9 @@ def test__get_dataset_parquet_not_cached(): "oml:parquet_url": "http://data.openml.org/dataset20/dataset_20.pq", "oml:id": "20", } - path = _get_dataset_parquet(description, cache_directory=Path(openml.config.get_cache_directory())) + path = _get_dataset_parquet( + description, cache_directory=Path(openml.config.get_cache_directory()) + ) assert isinstance(path, Path), "_get_dataset_parquet returns a path" assert path.is_file(), "_get_dataset_parquet returns path to real file" @@ -1941,20 +1968,19 @@ def test_read_features_from_xml_with_whitespace() -> None: features_file = ( Path(__file__).parent.parent / "files" / "misc" / "features_with_whitespaces.xml" ) - dict = _read_features(features_file) + dict = _read_features(features_file) # noqa: A001 assert dict[1].nominal_values == [" - 50000.", " 50000+."] -def test_get_dataset_parquet(requests_mock, test_files_directory): +@pytest.mark.test_server +def test_get_dataset_parquet(requests_mock, test_files_directory, test_server_v1): # Parquet functionality is disabled on the test server # There is no parquet-copy of the test server yet. - content_file = ( - test_files_directory / "mock_responses" / "datasets" / "data_description_61.xml" - ) + content_file = test_files_directory / "mock_responses" / "datasets" / "data_description_61.xml" # While the mocked example is from production, unit tests by default connect to the test server. - requests_mock.get("https://test.openml.org/api/v1/xml/data/61", text=content_file.read_text()) + requests_mock.get(test_server_v1 + "data/61", text=content_file.read_text()) dataset = openml.datasets.get_dataset(61, download_data=True) assert dataset._parquet_url is not None assert dataset.parquet_file is not None - assert os.path.isfile(dataset.parquet_file) + assert os.path.isfile(dataset.parquet_file) # noqa: PTH113 assert dataset.data_file is None # is alias for arff path diff --git a/tests/test_evaluations/test_evaluation_functions.py b/tests/test_evaluations/test_evaluation_functions.py index ffd3d9f78..9af9d537d 100644 --- a/tests/test_evaluations/test_evaluation_functions.py +++ b/tests/test_evaluations/test_evaluation_functions.py @@ -30,13 +30,13 @@ def _check_list_evaluation_setups(self, **kwargs): # Check if length is accurate assert len(evals_setups) == len(evals) # Check if output from sort is sorted in the right order - self.assertSequenceEqual( + self.assertSequenceEqual( # noqa: PT009 sorted(evals_setups["value"].tolist(), reverse=True), evals_setups["value"].tolist(), ) # Check if output and order of list_evaluations is preserved - self.assertSequenceEqual(evals_setups["run_id"].tolist(), evals["run_id"].tolist()) + self.assertSequenceEqual(evals_setups["run_id"].tolist(), evals["run_id"].tolist()) # noqa: PT009 if not self.long_version: evals_setups = evals_setups.head(1) @@ -47,10 +47,10 @@ def _check_list_evaluation_setups(self, **kwargs): list1 = [param["oml:value"] for param in params] list2 = list(row["parameters"].values()) # check if all values are equal - self.assertSequenceEqual(sorted(list1), sorted(list2)) + self.assertSequenceEqual(sorted(list1), sorted(list2)) # noqa: PT009 return evals_setups - @pytest.mark.production() + @pytest.mark.production_server def test_evaluation_list_filter_task(self): self.use_production_server() @@ -70,7 +70,7 @@ def test_evaluation_list_filter_task(self): assert evaluations[run_id].value is not None assert evaluations[run_id].values is None - @pytest.mark.production() + @pytest.mark.production_server def test_evaluation_list_filter_uploader_ID_16(self): self.use_production_server() @@ -85,7 +85,7 @@ def test_evaluation_list_filter_uploader_ID_16(self): assert len(evaluations) > 50 - @pytest.mark.production() + @pytest.mark.production_server def test_evaluation_list_filter_uploader_ID_10(self): self.use_production_server() @@ -104,7 +104,7 @@ def test_evaluation_list_filter_uploader_ID_10(self): assert evaluations[run_id].value is not None assert evaluations[run_id].values is None - @pytest.mark.production() + @pytest.mark.production_server def test_evaluation_list_filter_flow(self): self.use_production_server() @@ -124,7 +124,7 @@ def test_evaluation_list_filter_flow(self): assert evaluations[run_id].value is not None assert evaluations[run_id].values is None - @pytest.mark.production() + @pytest.mark.production_server def test_evaluation_list_filter_run(self): self.use_production_server() @@ -144,7 +144,7 @@ def test_evaluation_list_filter_run(self): assert evaluations[run_id].value is not None assert evaluations[run_id].values is None - @pytest.mark.production() + @pytest.mark.production_server def test_evaluation_list_limit(self): self.use_production_server() @@ -155,6 +155,8 @@ def test_evaluation_list_limit(self): ) assert len(evaluations) == 100 + @pytest.mark.skip(reason="Affected by lowered result limit #1757") + @pytest.mark.test_server def test_list_evaluations_empty(self): evaluations = openml.evaluations.list_evaluations("unexisting_measure") if len(evaluations) > 0: @@ -162,13 +164,14 @@ def test_list_evaluations_empty(self): assert isinstance(evaluations, dict) - @pytest.mark.production() + @pytest.mark.production_server def test_evaluation_list_per_fold(self): self.use_production_server() size = 1000 task_ids = [6] uploader_ids = [1] flow_ids = [6969] + runs = [6070667, 6070666] evaluations = openml.evaluations.list_evaluations( "predictive_accuracy", @@ -177,10 +180,11 @@ def test_evaluation_list_per_fold(self): tasks=task_ids, flows=flow_ids, uploaders=uploader_ids, + runs=runs, per_fold=True, ) - assert len(evaluations) == size + assert len(evaluations) == len(runs) for run_id in evaluations: assert evaluations[run_id].value is None assert evaluations[run_id].values is not None @@ -194,13 +198,15 @@ def test_evaluation_list_per_fold(self): tasks=task_ids, flows=flow_ids, uploaders=uploader_ids, + runs=runs, per_fold=False, ) for run_id in evaluations: assert evaluations[run_id].value is not None assert evaluations[run_id].values is None - @pytest.mark.production() + @pytest.mark.skip(reason="Affected by lowered result limit #1757") + @pytest.mark.production_server def test_evaluation_list_sort(self): self.use_production_server() size = 10 @@ -232,12 +238,13 @@ def test_evaluation_list_sort(self): test_output = sorted(unsorted_output, reverse=True) assert test_output[:size] == sorted_output + @pytest.mark.test_server def test_list_evaluation_measures(self): measures = openml.evaluations.list_evaluation_measures() assert isinstance(measures, list) is True assert all(isinstance(s, str) for s in measures) is True - @pytest.mark.production() + @pytest.mark.production_server def test_list_evaluations_setups_filter_flow(self): self.use_production_server() flow_id = [405] @@ -255,7 +262,8 @@ def test_list_evaluations_setups_filter_flow(self): keys = list(evals["parameters"].values[0].keys()) assert all(elem in columns for elem in keys) - @pytest.mark.production() + @pytest.mark.production_server + @pytest.mark.xfail(reason="failures_issue_1544", strict=False) def test_list_evaluations_setups_filter_task(self): self.use_production_server() task_id = [6] diff --git a/tests/test_evaluations/test_evaluations_example.py b/tests/test_evaluations/test_evaluations_example.py index a9ad7e8c1..eff976389 100644 --- a/tests/test_evaluations/test_evaluations_example.py +++ b/tests/test_evaluations/test_evaluations_example.py @@ -3,14 +3,17 @@ import unittest -from openml.config import overwrite_config_context +import pytest + +import openml class TestEvaluationsExample(unittest.TestCase): + @pytest.mark.skip(reason="Affected by lowered result limit #1757") def test_example_python_paper(self): # Example script which will appear in the upcoming OpenML-Python paper # This test ensures that the example will keep running! - with overwrite_config_context( + with openml.config.overwrite_config_context( # noqa: F823 { "server": "https://www.openml.org/api/v1/xml", "apikey": None, @@ -18,6 +21,7 @@ def test_example_python_paper(self): ): import matplotlib.pyplot as plt import numpy as np + import openml df = openml.evaluations.list_evaluations_setups( diff --git a/tests/test_extensions/test_functions.py b/tests/test_extensions/test_functions.py index ac4610a15..dbaaf1f36 100644 --- a/tests/test_extensions/test_functions.py +++ b/tests/test_extensions/test_functions.py @@ -2,11 +2,19 @@ from __future__ import annotations import inspect +from collections import OrderedDict +from unittest.mock import patch +import numpy as np import pytest import openml.testing -from openml.extensions import get_extension_by_flow, get_extension_by_model, register_extension +from openml.extensions import ( + Extension, + get_extension_by_flow, + get_extension_by_model, + register_extension, +) class DummyFlow: @@ -22,72 +30,216 @@ class DummyModel: class DummyExtension1: @staticmethod - def can_handle_flow(flow): + def can_handle_flow(flow): # noqa: ARG004 return inspect.stack()[2].filename.endswith("test_functions.py") @staticmethod - def can_handle_model(model): + def can_handle_model(model): # noqa: ARG004 return inspect.stack()[2].filename.endswith("test_functions.py") class DummyExtension2: @staticmethod - def can_handle_flow(flow): + def can_handle_flow(flow): # noqa: ARG004 return False @staticmethod - def can_handle_model(model): + def can_handle_model(model): # noqa: ARG004 return False -def _unregister(): - # "Un-register" the test extensions - while True: - rem_dum_ext1 = False - rem_dum_ext2 = False - try: - openml.extensions.extensions.remove(DummyExtension1) - rem_dum_ext1 = True - except ValueError: - pass - try: - openml.extensions.extensions.remove(DummyExtension2) - rem_dum_ext2 = True - except ValueError: - pass - if not rem_dum_ext1 and not rem_dum_ext2: - break +class DummyExtension(Extension): + @classmethod + def can_handle_flow(cls, flow): + return isinstance(flow, DummyFlow) + + @classmethod + def can_handle_model(cls, model): + return isinstance(model, DummyModel) + + def flow_to_model( + self, + flow, + initialize_with_defaults=False, # noqa: FBT002 + strict_version=True, # noqa: FBT002 + ): + if not isinstance(flow, DummyFlow): + raise ValueError("Invalid flow") + + model = DummyModel() + model.defaults = initialize_with_defaults + model.strict_version = strict_version + return model + + def model_to_flow(self, model): + if not isinstance(model, DummyModel): + raise ValueError("Invalid model") + return DummyFlow() + + def get_version_information(self): + return ["dummy==1.0"] + + def create_setup_string(self, model): # noqa: ARG002 + return "DummyModel()" + + def is_estimator(self, model): + return isinstance(model, DummyModel) + + def seed_model(self, model, seed): + model.seed = seed + return model + + def _run_model_on_fold( # noqa: PLR0913,PLR0917 + self, + model, # noqa: ARG002 + task, # noqa: ARG002 + X_train, + rep_no, # noqa: ARG002 + fold_no, # noqa: ARG002 + y_train=None, # noqa: ARG002 + X_test=None, # noqa: ARG002 + ): + preds = np.zeros(len(X_train)) + probs = None + measures = OrderedDict() + trace = None + return preds, probs, measures, trace + + def obtain_parameter_values(self, flow, model=None): # noqa: ARG002 + return [] + + def check_if_model_fitted(self, model): # noqa: ARG002 + return False + def instantiate_model_from_hpo_class(self, model, trace_iteration): # noqa: ARG002 + return DummyModel() -class TestInit(openml.testing.TestBase): - def setUp(self): - super().setUp() - _unregister() +class TestInit(openml.testing.TestBase): def test_get_extension_by_flow(self): - assert get_extension_by_flow(DummyFlow()) is None - with pytest.raises(ValueError, match="No extension registered which can handle flow:"): - get_extension_by_flow(DummyFlow(), raise_if_no_extension=True) - register_extension(DummyExtension1) - assert isinstance(get_extension_by_flow(DummyFlow()), DummyExtension1) - register_extension(DummyExtension2) - assert isinstance(get_extension_by_flow(DummyFlow()), DummyExtension1) - register_extension(DummyExtension1) - with pytest.raises( - ValueError, match="Multiple extensions registered which can handle flow:" - ): - get_extension_by_flow(DummyFlow()) + # We replace the global list with a new empty list [] ONLY for this block + with patch("openml.extensions.extensions", []): + assert get_extension_by_flow(DummyFlow()) is None + + with pytest.raises(ValueError, match="No extension registered which can handle flow:"): + get_extension_by_flow(DummyFlow(), raise_if_no_extension=True) + + register_extension(DummyExtension1) + assert isinstance(get_extension_by_flow(DummyFlow()), DummyExtension1) + + register_extension(DummyExtension2) + assert isinstance(get_extension_by_flow(DummyFlow()), DummyExtension1) + + register_extension(DummyExtension1) + with pytest.raises( + ValueError, match="Multiple extensions registered which can handle flow:" + ): + get_extension_by_flow(DummyFlow()) def test_get_extension_by_model(self): - assert get_extension_by_model(DummyModel()) is None - with pytest.raises(ValueError, match="No extension registered which can handle model:"): - get_extension_by_model(DummyModel(), raise_if_no_extension=True) - register_extension(DummyExtension1) - assert isinstance(get_extension_by_model(DummyModel()), DummyExtension1) - register_extension(DummyExtension2) - assert isinstance(get_extension_by_model(DummyModel()), DummyExtension1) - register_extension(DummyExtension1) - with pytest.raises( - ValueError, match="Multiple extensions registered which can handle model:" - ): - get_extension_by_model(DummyModel()) + # Again, we start with a fresh empty list automatically + with patch("openml.extensions.extensions", []): + assert get_extension_by_model(DummyModel()) is None + + with pytest.raises(ValueError, match="No extension registered which can handle model:"): + get_extension_by_model(DummyModel(), raise_if_no_extension=True) + + register_extension(DummyExtension1) + assert isinstance(get_extension_by_model(DummyModel()), DummyExtension1) + + register_extension(DummyExtension2) + assert isinstance(get_extension_by_model(DummyModel()), DummyExtension1) + + register_extension(DummyExtension1) + with pytest.raises( + ValueError, match="Multiple extensions registered which can handle model:" + ): + get_extension_by_model(DummyModel()) + + +def test_flow_to_model_with_defaults(): + """Test flow_to_model with initialize_with_defaults=True.""" + ext = DummyExtension() + flow = DummyFlow() + + model = ext.flow_to_model(flow, initialize_with_defaults=True) + + assert isinstance(model, DummyModel) + assert model.defaults is True + + +def test_flow_to_model_strict_version(): + """Test flow_to_model with strict_version parameter.""" + ext = DummyExtension() + flow = DummyFlow() + + model_strict = ext.flow_to_model(flow, strict_version=True) + model_non_strict = ext.flow_to_model(flow, strict_version=False) + + assert isinstance(model_strict, DummyModel) + assert model_strict.strict_version is True + + assert isinstance(model_non_strict, DummyModel) + assert model_non_strict.strict_version is False + + +def test_model_to_flow_conversion(): + """Test converting a model back to flow representation.""" + ext = DummyExtension() + model = DummyModel() + + flow = ext.model_to_flow(model) + + assert isinstance(flow, DummyFlow) + + +def test_invalid_flow_raises_error(): + """Test that invalid flow raises appropriate error.""" + + class InvalidFlow: + pass + + ext = DummyExtension() + flow = InvalidFlow() + + with pytest.raises(ValueError, match="Invalid flow"): + ext.flow_to_model(flow) + + +@patch("openml.extensions.extensions", []) +def test_extension_not_found_error_message(): + """Test error message contains helpful information.""" + + class UnknownModel: + pass + + with pytest.raises(ValueError, match="No extension registered"): + get_extension_by_model(UnknownModel(), raise_if_no_extension=True) + + +def test_register_same_extension_twice(): + """Test behavior when registering same extension twice.""" + # Using a context manager here to isolate the list + with patch("openml.extensions.extensions", []): + register_extension(DummyExtension) + register_extension(DummyExtension) + + matches = [ext for ext in openml.extensions.extensions if ext is DummyExtension] + assert len(matches) == 2 + + +@patch("openml.extensions.extensions", []) +def test_extension_priority_order(): + """Test that extensions are checked in registration order.""" + + class DummyExtensionA(DummyExtension): + pass + + class DummyExtensionB(DummyExtension): + pass + + register_extension(DummyExtensionA) + register_extension(DummyExtensionB) + + assert openml.extensions.extensions[0] is DummyExtensionA + assert openml.extensions.extensions[1] is DummyExtensionB diff --git a/tests/test_flows/dummy_learn/dummy_forest.py b/tests/test_flows/dummy_learn/dummy_forest.py index 65e79e760..fe8550a0b 100644 --- a/tests/test_flows/dummy_learn/dummy_forest.py +++ b/tests/test_flows/dummy_learn/dummy_forest.py @@ -3,14 +3,14 @@ class DummyRegressor: - def fit(self, X, y): + def fit(self, X, y): # noqa: ARG002 return self def predict(self, X): return X[:, 0] - def get_params(self, deep=False): + def get_params(self, deep=False): # noqa: FBT002, ARG002 return {} - def set_params(self, params): + def set_params(self, params): # noqa: ARG002 return self diff --git a/tests/test_flows/test_flow.py b/tests/test_flows/test_flow.py index 0b034c3b4..e7a34e5b0 100644 --- a/tests/test_flows/test_flow.py +++ b/tests/test_flows/test_flow.py @@ -6,10 +6,10 @@ import hashlib import re import time -from packaging.version import Version from unittest import mock import pytest +import requests import scipy.stats import sklearn import sklearn.datasets @@ -23,17 +23,15 @@ import sklearn.preprocessing import sklearn.tree import xmltodict - from openml_sklearn import SklearnExtension +from packaging.version import Version import openml import openml.exceptions import openml.utils -from openml._api_calls import _perform_api_call from openml.testing import SimpleImputer, TestBase - class TestFlow(TestBase): _multiprocess_can_split_ = True @@ -44,7 +42,7 @@ def setUp(self): def tearDown(self): super().tearDown() - @pytest.mark.production() + @pytest.mark.production_server def test_get_flow(self): # We need to use the production server here because 4024 is not the # test server @@ -77,7 +75,8 @@ def test_get_flow(self): assert subflow_3.parameters["L"] == "-1" assert len(subflow_3.components) == 0 - @pytest.mark.production() + @pytest.mark.production_server + @pytest.mark.xfail(reason="failures_issue_1544", strict=False) def test_get_structure(self): # also responsible for testing: flow.get_subflow # We need to use the production server here because 4024 is not the @@ -102,6 +101,7 @@ def test_get_structure(self): subflow = flow.get_subflow(structure) assert subflow.flow_id == sub_flow_id + @pytest.mark.test_server def test_tagging(self): flows = openml.flows.list_flows(size=1) flow_id = flows["id"].iloc[0] @@ -114,11 +114,12 @@ def test_tagging(self): flow.push_tag(tag) flows = openml.flows.list_flows(tag=tag) assert len(flows) == 1 - assert flow_id in flows["id"] + assert flow_id in flows["id"].values flow.remove_tag(tag) flows = openml.flows.list_flows(tag=tag) assert len(flows) == 0 + @pytest.mark.test_server def test_from_xml_to_xml(self): # Get the raw xml thing # TODO maybe get this via get_flow(), which would have to be refactored @@ -130,7 +131,7 @@ def test_from_xml_to_xml(self): 7, 9, ]: - flow_xml = _perform_api_call("flow/%d" % flow_id, request_method="get") + flow_xml = openml._backend.http_client.get(f"flow/{flow_id}").text flow_dict = xmltodict.parse(flow_xml) flow = openml.OpenMLFlow._from_dict(flow_dict) @@ -155,7 +156,7 @@ def test_from_xml_to_xml(self): assert new_xml == flow_xml - @pytest.mark.sklearn() + @pytest.mark.sklearn def test_to_xml_from_xml(self): scaler = sklearn.preprocessing.StandardScaler(with_mean=False) estimator_name = ( @@ -177,7 +178,8 @@ def test_to_xml_from_xml(self): openml.flows.functions.assert_flows_equal(new_flow, flow) assert new_flow is not flow - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_publish_flow(self): flow = openml.OpenMLFlow( name="sklearn.dummy.DummyClassifier", @@ -203,7 +205,7 @@ def test_publish_flow(self): TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {flow.flow_id}") assert isinstance(flow.flow_id, int) - @pytest.mark.sklearn() + @pytest.mark.sklearn @mock.patch("openml.flows.functions.flow_exists") def test_publish_existing_flow(self, flow_exists_mock): clf = sklearn.tree.DecisionTreeClassifier(max_depth=2) @@ -218,7 +220,8 @@ def test_publish_existing_flow(self, flow_exists_mock): f"collected from {__file__.split('/')[-1]}: {flow.flow_id}", ) - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_publish_flow_with_similar_components(self): clf = sklearn.ensemble.VotingClassifier( [("lr", sklearn.linear_model.LogisticRegression(solver="lbfgs"))], @@ -268,7 +271,8 @@ def test_publish_flow_with_similar_components(self): TestBase._mark_entity_for_removal("flow", flow3.flow_id, flow3.name) TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {flow3.flow_id}") - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_semi_legal_flow(self): # TODO: Test if parameters are set correctly! # should not throw error as it contains two differentiable forms of @@ -292,23 +296,31 @@ def test_semi_legal_flow(self): TestBase._mark_entity_for_removal("flow", flow.flow_id, flow.name) TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {flow.flow_id}") - @pytest.mark.sklearn() + @pytest.mark.sklearn @mock.patch("openml.flows.functions.get_flow") @mock.patch("openml.flows.functions.flow_exists") - @mock.patch("openml._api_calls._perform_api_call") - def test_publish_error(self, api_call_mock, flow_exists_mock, get_flow_mock): + @mock.patch("requests.Session.request") + def test_publish_error(self, mock_request, flow_exists_mock, get_flow_mock): model = sklearn.ensemble.RandomForestClassifier() flow = self.extension.model_to_flow(model) - api_call_mock.return_value = ( - "\n" " 1\n" "" + + # Create mock response directly + response = requests.Response() + response.status_code = 200 + response._content = ( + b'\n' + b" 1\n" + b"" ) - flow_exists_mock.return_value = False + mock_request.return_value = response + flow_exists_mock.return_value = False # Flow doesn't exist yet, so try to publish get_flow_mock.return_value = flow flow.publish() - # Not collecting flow_id for deletion since this is a test for failed upload + # The first publish succeeds, so we don't collect flow_id + # for deletion since this is a mocked test. - assert api_call_mock.call_count == 1 + assert mock_request.call_count == 1 assert get_flow_mock.call_count == 1 assert flow_exists_mock.call_count == 1 @@ -348,7 +360,7 @@ def test_publish_error(self, api_call_mock, flow_exists_mock, get_flow_mock): assert get_flow_mock.call_count == 2 - @pytest.mark.sklearn() + @pytest.mark.sklearn def test_illegal_flow(self): # should throw error as it contains two imputers illegal = sklearn.pipeline.Pipeline( @@ -358,14 +370,15 @@ def test_illegal_flow(self): ("classif", sklearn.tree.DecisionTreeClassifier()), ], ) - self.assertRaises(ValueError, self.extension.model_to_flow, illegal) + self.assertRaises(ValueError, self.extension.model_to_flow, illegal) # noqa: PT027 + @pytest.mark.test_server def test_nonexisting_flow_exists(self): def get_sentinel(): # Create a unique prefix for the flow. Necessary because the flow # is identified by its name and external version online. Having a # unique name allows us to publish the same flow in each test run - md5 = hashlib.md5() + md5 = hashlib.md5() # noqa: S324 md5.update(str(time.time()).encode("utf-8")) sentinel = md5.hexdigest()[:10] return f"TEST{sentinel}" @@ -376,7 +389,8 @@ def get_sentinel(): flow_id = openml.flows.flow_exists(name, version) assert not flow_id - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_existing_flow_exists(self): # create a flow nb = sklearn.naive_bayes.GaussianNB() @@ -416,7 +430,8 @@ def test_existing_flow_exists(self): ) assert downloaded_flow_id == flow.flow_id - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_sklearn_to_upload_to_flow(self): iris = sklearn.datasets.load_iris() X = iris.data @@ -551,12 +566,12 @@ def test_extract_tags(self): tags = openml.utils.extract_xml_tags("oml:tag", flow_dict) assert tags == ["study_14"] - flow_xml = "OpenmlWeka\n" "weka" + flow_xml = "OpenmlWeka\nweka" flow_dict = xmltodict.parse(flow_xml) tags = openml.utils.extract_xml_tags("oml:tag", flow_dict["oml:flow"]) assert tags == ["OpenmlWeka", "weka"] - @pytest.mark.production() + @pytest.mark.production_server def test_download_non_scikit_learn_flows(self): self.use_production_server() diff --git a/tests/test_flows/test_flow_functions.py b/tests/test_flows/test_flow_functions.py index ef4759e54..3a0842c36 100644 --- a/tests/test_flows/test_flow_functions.py +++ b/tests/test_flows/test_flow_functions.py @@ -5,10 +5,6 @@ import functools import unittest from collections import OrderedDict -from multiprocessing.managers import Value - -from openml_sklearn import SklearnExtension -from packaging.version import Version from unittest import mock from unittest.mock import patch @@ -16,6 +12,8 @@ import pytest import requests import sklearn +from openml_sklearn import SklearnExtension +from packaging.version import Version from sklearn import ensemble import openml @@ -34,19 +32,20 @@ def tearDown(self): super().tearDown() def _check_flow(self, flow): - assert type(flow) == dict + assert type(flow) == dict # noqa: E721 assert len(flow) == 6 assert isinstance(flow["id"], int) assert isinstance(flow["name"], str) assert isinstance(flow["full_name"], str) assert isinstance(flow["version"], str) # There are some runs on openml.org that can have an empty external version + ext_version = flow["external_version"] ext_version_str_or_none = ( - isinstance(flow["external_version"], str) or flow["external_version"] is None + isinstance(ext_version, str) or ext_version is None or pd.isna(ext_version) ) assert ext_version_str_or_none - @pytest.mark.production() + @pytest.mark.production_server def test_list_flows(self): self.use_production_server() # We can only perform a smoke test here because we test on dynamic @@ -57,7 +56,7 @@ def test_list_flows(self): for flow in flows.to_dict(orient="index").values(): self._check_flow(flow) - @pytest.mark.production() + @pytest.mark.production_server def test_list_flows_output_format(self): self.use_production_server() # We can only perform a smoke test here because we test on dynamic @@ -66,14 +65,13 @@ def test_list_flows_output_format(self): assert isinstance(flows, pd.DataFrame) assert len(flows) >= 1500 - @pytest.mark.production() + @pytest.mark.production_server def test_list_flows_empty(self): self.use_production_server() - openml.config.server = self.production_server flows = openml.flows.list_flows(tag="NoOneEverUsesThisTag123") assert flows.empty - @pytest.mark.production() + @pytest.mark.production_server def test_list_flows_by_tag(self): self.use_production_server() flows = openml.flows.list_flows(tag="weka") @@ -81,7 +79,7 @@ def test_list_flows_by_tag(self): for flow in flows.to_dict(orient="index").values(): self._check_flow(flow) - @pytest.mark.production() + @pytest.mark.production_server def test_list_flows_paginate(self): self.use_production_server() size = 10 @@ -121,7 +119,7 @@ def test_are_flows_equal(self): new_flow = copy.deepcopy(flow) setattr(new_flow, attribute, new_value) assert getattr(flow, attribute) != getattr(new_flow, attribute) - self.assertRaises( + self.assertRaises( # noqa: PT027 ValueError, openml.flows.functions.assert_flows_equal, flow, @@ -152,14 +150,14 @@ def test_are_flows_equal(self): openml.flows.functions.assert_flows_equal(flow, flow) new_flow = copy.deepcopy(flow) new_flow.parameters["abc"] = 3.0 - self.assertRaises(ValueError, openml.flows.functions.assert_flows_equal, flow, new_flow) + self.assertRaises(ValueError, openml.flows.functions.assert_flows_equal, flow, new_flow) # noqa: PT027 # Now test for components (subflows) parent_flow = copy.deepcopy(flow) subflow = copy.deepcopy(flow) parent_flow.components["subflow"] = subflow openml.flows.functions.assert_flows_equal(parent_flow, parent_flow) - self.assertRaises( + self.assertRaises( # noqa: PT027 ValueError, openml.flows.functions.assert_flows_equal, parent_flow, @@ -167,7 +165,7 @@ def test_are_flows_equal(self): ) new_flow = copy.deepcopy(parent_flow) new_flow.components["subflow"].name = "Subflow name" - self.assertRaises( + self.assertRaises( # noqa: PT027 ValueError, openml.flows.functions.assert_flows_equal, parent_flow, @@ -198,22 +196,20 @@ def test_are_flows_equal_ignore_parameter_values(self): new_flow = copy.deepcopy(flow) new_flow.parameters["a"] = 7 - with pytest.raises(ValueError) as excinfo: + with pytest.raises(ValueError) as excinfo: # noqa: PT011 openml.flows.functions.assert_flows_equal(flow, new_flow) - assert str(paramaters) in str(excinfo.value) and str(new_flow.parameters) in str( - excinfo.value - ) + assert str(paramaters) in str(excinfo.value) + assert str(new_flow.parameters) in str(excinfo.value) openml.flows.functions.assert_flows_equal(flow, new_flow, ignore_parameter_values=True) del new_flow.parameters["a"] - with pytest.raises(ValueError) as excinfo: + with pytest.raises(ValueError) as excinfo: # noqa: PT011 openml.flows.functions.assert_flows_equal(flow, new_flow) - assert str(paramaters) in str(excinfo.value) and str(new_flow.parameters) in str( - excinfo.value - ) + assert str(paramaters) in str(excinfo.value) + assert str(new_flow.parameters) in str(excinfo.value) - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 ValueError, r"Flow Test: parameter set of flow differs from the parameters " r"stored on the server.", @@ -249,14 +245,14 @@ def test_are_flows_equal_ignore_if_older(self): assert_flows_equal(flow, flow, ignore_parameter_values_on_older_children=None) new_flow = copy.deepcopy(flow) new_flow.parameters["a"] = 7 - self.assertRaises( + self.assertRaises( # noqa: PT027 ValueError, assert_flows_equal, flow, new_flow, ignore_parameter_values_on_older_children=flow_upload_date, ) - self.assertRaises( + self.assertRaises( # noqa: PT027 ValueError, assert_flows_equal, flow, @@ -265,7 +261,7 @@ def test_are_flows_equal_ignore_if_older(self): ) new_flow.upload_date = "2016-01-31T12-01-01" - self.assertRaises( + self.assertRaises( # noqa: PT027 ValueError, assert_flows_equal, flow, @@ -274,12 +270,14 @@ def test_are_flows_equal_ignore_if_older(self): ) assert_flows_equal(flow, flow, ignore_parameter_values_on_older_children=None) - @pytest.mark.sklearn() + @pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) < Version("0.20"), reason="OrdinalEncoder introduced in 0.20. " "No known models with list of lists parameters in older versions.", ) + @pytest.mark.test_server + @pytest.mark.xfail(reason="failures_issue_1544", strict=False) def test_sklearn_to_flow_list_of_lists(self): from sklearn.preprocessing import OrdinalEncoder @@ -299,7 +297,7 @@ def test_sklearn_to_flow_list_of_lists(self): assert server_flow.parameters["categories"] == "[[0, 1], [0, 1]]" assert server_flow.model.categories == flow.model.categories - @pytest.mark.production() + @pytest.mark.production_server def test_get_flow1(self): # Regression test for issue #305 # Basically, this checks that a flow without an external version can be loaded @@ -307,7 +305,8 @@ def test_get_flow1(self): flow = openml.flows.get_flow(1) assert flow.external_version is None - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_get_flow_reinstantiate_model(self): model = ensemble.RandomForestClassifier(n_estimators=33) extension = openml.extensions.get_extension_by_model(model) @@ -319,27 +318,28 @@ def test_get_flow_reinstantiate_model(self): downloaded_flow = openml.flows.get_flow(flow.flow_id, reinstantiate=True) assert isinstance(downloaded_flow.model, sklearn.ensemble.RandomForestClassifier) + @pytest.mark.test_server def test_get_flow_reinstantiate_model_no_extension(self): # Flow 10 is a WEKA flow - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 ValueError, - ".* flow: 10 \(weka.SMO\). ", + r".* flow: 10 \(weka.SMO\). ", openml.flows.get_flow, flow_id=10, reinstantiate=True, ) - @pytest.mark.sklearn() + @pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) == Version("0.19.1"), reason="Requires scikit-learn!=0.19.1, because target flow is from that version.", ) - @pytest.mark.production() + @pytest.mark.production_server def test_get_flow_with_reinstantiate_strict_with_wrong_version_raises_exception(self): self.use_production_server() flow = 8175 expected = "Trying to deserialize a model with dependency sklearn==0.19.1 not satisfied." - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 ValueError, expected, openml.flows.get_flow, @@ -348,47 +348,48 @@ def test_get_flow_with_reinstantiate_strict_with_wrong_version_raises_exception( strict_version=True, ) - @pytest.mark.sklearn() + @pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) >= Version("1.0.0"), reason="Requires scikit-learn < 1.0.0.", # Because scikit-learn dropped min_impurity_split hyperparameter in 1.0, # and the requested flow is from 1.0.0 exactly. ) - @pytest.mark.production() + @pytest.mark.production_server def test_get_flow_reinstantiate_flow_not_strict_post_1(self): self.use_production_server() flow = openml.flows.get_flow(flow_id=19190, reinstantiate=True, strict_version=False) assert flow.flow_id is None assert "sklearn==1.0.0" not in flow.dependencies - @pytest.mark.sklearn() + @pytest.mark.sklearn @unittest.skipIf( (Version(sklearn.__version__) < Version("0.23.2")) or (Version(sklearn.__version__) >= Version("1.0")), reason="Requires scikit-learn 0.23.2 or ~0.24.", # Because these still have min_impurity_split, but with new scikit-learn module structure." ) - @pytest.mark.production() + @pytest.mark.production_server def test_get_flow_reinstantiate_flow_not_strict_023_and_024(self): self.use_production_server() flow = openml.flows.get_flow(flow_id=18587, reinstantiate=True, strict_version=False) assert flow.flow_id is None assert "sklearn==0.23.1" not in flow.dependencies - @pytest.mark.sklearn() + @pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) > Version("0.23"), reason="Requires scikit-learn<=0.23, because the scikit-learn module structure changed.", ) - @pytest.mark.production() + @pytest.mark.production_server def test_get_flow_reinstantiate_flow_not_strict_pre_023(self): self.use_production_server() flow = openml.flows.get_flow(flow_id=8175, reinstantiate=True, strict_version=False) assert flow.flow_id is None assert "sklearn==0.19.1" not in flow.dependencies - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_get_flow_id(self): if self.long_version: list_all = openml.utils._list_all @@ -417,9 +418,13 @@ def test_get_flow_id(self): name=flow.name, exact_version=False, ) - assert flow_ids_exact_version_True == flow_ids_exact_version_False assert flow.flow_id in flow_ids_exact_version_True + assert set(flow_ids_exact_version_True).issubset(set(flow_ids_exact_version_False)) + # instead of the assertion above, the assertion below used to be used. + pytest.skip(reason="Not sure why there should only be one version of this flow.") + assert flow_ids_exact_version_True == flow_ids_exact_version_False + @pytest.mark.test_server def test_delete_flow(self): flow = openml.OpenMLFlow( name="sklearn.dummy.DummyClassifier", @@ -442,31 +447,29 @@ def test_delete_flow(self): assert openml.flows.delete_flow(_flow_id) -@mock.patch.object(requests.Session, "delete") -def test_delete_flow_not_owned(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +@mock.patch.object(requests.Session, "request") +def test_delete_flow_not_owned(mock_request, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "flows" / "flow_delete_not_owned.xml" - mock_delete.return_value = create_request_response( + mock_request.return_value = create_request_response( status_code=412, content_filepath=content_file, ) with pytest.raises( OpenMLNotAuthorizedError, - match="The flow can not be deleted because it was not uploaded by you.", + match=r"The flow can not be deleted because it was not uploaded by you.", ): openml.flows.delete_flow(40_000) - flow_url = "https://test.openml.org/api/v1/xml/flow/40000" - assert flow_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert mock_request.call_args.kwargs.get("method") == "DELETE" + assert mock_request.call_args.kwargs.get("url") == f"{test_server_v1}flow/40000" + assert test_apikey_v1 == mock_request.call_args.kwargs.get("params", {}).get("api_key") -@mock.patch.object(requests.Session, "delete") -def test_delete_flow_with_run(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +@mock.patch.object(requests.Session, "request") +def test_delete_flow_with_run(mock_request, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "flows" / "flow_delete_has_runs.xml" - mock_delete.return_value = create_request_response( + mock_request.return_value = create_request_response( status_code=412, content_filepath=content_file, ) @@ -477,16 +480,15 @@ def test_delete_flow_with_run(mock_delete, test_files_directory, test_api_key): ): openml.flows.delete_flow(40_000) - flow_url = "https://test.openml.org/api/v1/xml/flow/40000" - assert flow_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert mock_request.call_args.kwargs.get("method") == "DELETE" + assert mock_request.call_args.kwargs.get("url") == f"{test_server_v1}flow/40000" + assert test_apikey_v1 == mock_request.call_args.kwargs.get("params", {}).get("api_key") -@mock.patch.object(requests.Session, "delete") -def test_delete_subflow(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +@mock.patch.object(requests.Session, "request") +def test_delete_subflow(mock_request, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "flows" / "flow_delete_is_subflow.xml" - mock_delete.return_value = create_request_response( + mock_request.return_value = create_request_response( status_code=412, content_filepath=content_file, ) @@ -497,16 +499,15 @@ def test_delete_subflow(mock_delete, test_files_directory, test_api_key): ): openml.flows.delete_flow(40_000) - flow_url = "https://test.openml.org/api/v1/xml/flow/40000" - assert flow_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert mock_request.call_args.kwargs.get("method") == "DELETE" + assert mock_request.call_args.kwargs.get("url") == f"{test_server_v1}flow/40000" + assert test_apikey_v1 == mock_request.call_args.kwargs.get("params", {}).get("api_key") -@mock.patch.object(requests.Session, "delete") -def test_delete_flow_success(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +@mock.patch.object(requests.Session, "request") +def test_delete_flow_success(mock_request, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "flows" / "flow_delete_successful.xml" - mock_delete.return_value = create_request_response( + mock_request.return_value = create_request_response( status_code=200, content_filepath=content_file, ) @@ -514,16 +515,16 @@ def test_delete_flow_success(mock_delete, test_files_directory, test_api_key): success = openml.flows.delete_flow(33364) assert success - flow_url = "https://test.openml.org/api/v1/xml/flow/33364" - assert flow_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert mock_request.call_args.kwargs.get("method") == "DELETE" + assert mock_request.call_args.kwargs.get("url") == f"{test_server_v1}flow/33364" + assert test_apikey_v1 == mock_request.call_args.kwargs.get("params", {}).get("api_key") -@mock.patch.object(requests.Session, "delete") -def test_delete_unknown_flow(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +@mock.patch.object(requests.Session, "request") +@pytest.mark.xfail(reason="failures_issue_1544", strict=False) +def test_delete_unknown_flow(mock_request, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "flows" / "flow_delete_not_exist.xml" - mock_delete.return_value = create_request_response( + mock_request.return_value = create_request_response( status_code=412, content_filepath=content_file, ) @@ -534,6 +535,6 @@ def test_delete_unknown_flow(mock_delete, test_files_directory, test_api_key): ): openml.flows.delete_flow(9_999_999) - flow_url = "https://test.openml.org/api/v1/xml/flow/9999999" - assert flow_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert mock_request.call_args.kwargs.get("method") == "DELETE" + assert mock_request.call_args.kwargs.get("url") == f"{test_server_v1}flow/9999999" + assert test_apikey_v1 == mock_request.call_args.kwargs.get("params", {}).get("api_key") diff --git a/tests/test_openml/test_api_calls.py b/tests/test_openml/test_api_calls.py index da6857b6e..b22505047 100644 --- a/tests/test_openml/test_api_calls.py +++ b/tests/test_openml/test_api_calls.py @@ -1,27 +1,29 @@ from __future__ import annotations import unittest.mock +from collections.abc import Iterable, Iterator from pathlib import Path -from typing import NamedTuple, Iterable, Iterator +from typing import NamedTuple from unittest import mock import minio import pytest import openml -from openml.config import ConfigurationForExamples import openml.testing -from openml._api_calls import _download_minio_bucket, API_TOKEN_HELP_LINK +from openml._api_calls import API_TOKEN_HELP_LINK, _download_minio_bucket class TestConfig(openml.testing.TestBase): + @pytest.mark.test_server def test_too_long_uri(self): with pytest.raises(openml.exceptions.OpenMLServerError, match="URI too long!"): openml.datasets.list_datasets(data_id=list(range(10000))) @unittest.mock.patch("time.sleep") @unittest.mock.patch("requests.Session") - def test_retry_on_database_error(self, Session_class_mock, _): + @pytest.mark.test_server + def test_retry_on_database_error(self, Session_class_mock, _): # noqa: PT019 response_mock = unittest.mock.Mock() response_mock.text = ( "\n" @@ -48,10 +50,10 @@ class FakeMinio: def __init__(self, objects: Iterable[FakeObject] | None = None): self._objects = objects or [] - def list_objects(self, *args, **kwargs) -> Iterator[FakeObject]: + def list_objects(self, *args, **kwargs) -> Iterator[FakeObject]: # noqa: ARG002 yield from self._objects - def fget_object(self, object_name: str, file_path: str, *args, **kwargs) -> None: + def fget_object(self, object_name: str, file_path: str, *args, **kwargs) -> None: # noqa: ARG002 if object_name in [obj.object_name for obj in self._objects]: Path(file_path).write_text("foo") return @@ -89,7 +91,7 @@ def test_download_minio_failure(mock_minio, tmp_path: Path) -> None: ], ) - with pytest.raises(ValueError): + with pytest.raises(ValueError): # noqa: PT011 _download_minio_bucket(source=some_url, destination=tmp_path) mock_minio.return_value = FakeMinio( @@ -98,28 +100,29 @@ def test_download_minio_failure(mock_minio, tmp_path: Path) -> None: ], ) - with pytest.raises(ValueError): + with pytest.raises(ValueError): # noqa: PT011 _download_minio_bucket(source=some_url, destination=tmp_path) @pytest.mark.parametrize( - "endpoint, method", + ("endpoint", "method"), [ # https://github.com/openml/OpenML/blob/develop/openml_OS/views/pages/api_new/v1/xml/pre.php ("flow/exists", "post"), # 102 ("dataset", "post"), # 137 ("dataset/42", "delete"), # 350 - # ("flow/owned", "post"), # 310 - Couldn't find what would trigger this + # ("flow/owned", "post"), # 310 - Couldn't find what would trigger this # noqa: ERA001 ("flow/42", "delete"), # 320 ("run/42", "delete"), # 400 ("task/42", "delete"), # 460 ], ) +@pytest.mark.test_server def test_authentication_endpoints_requiring_api_key_show_relevant_help_link( endpoint: str, method: str, ) -> None: # We need to temporarily disable the API key to test the error message - with openml.config.overwrite_config_context({"apikey": None}): - with pytest.raises(openml.exceptions.OpenMLNotAuthorizedError, match=API_TOKEN_HELP_LINK): + with openml.config.overwrite_config_context({"apikey": None}): # noqa: SIM117 + with pytest.raises(openml.exceptions.OpenMLAuthenticationError, match=API_TOKEN_HELP_LINK): openml._api_calls._perform_api_call(call=endpoint, request_method=method, data=None) diff --git a/tests/test_openml/test_cli.py b/tests/test_openml/test_cli.py new file mode 100644 index 000000000..57b62ffe5 --- /dev/null +++ b/tests/test_openml/test_cli.py @@ -0,0 +1,43 @@ +# License: BSD 3-Clause +from __future__ import annotations + +import shutil +import subprocess +import sys + +import pytest + +import openml + + +def test_cli_version_prints_package_version(): + # Invoke the CLI via module to avoid relying on console script installation + result = subprocess.run( + [sys.executable, "-m", "openml.cli", "--version"], + capture_output=True, + text=True, + check=False, + ) + + # Ensure successful exit and version present in stdout only + assert result.returncode == 0 + assert result.stderr == "" + assert openml.__version__ in result.stdout + + +def test_console_script_version_prints_package_version(): + # Try to locate the console script; skip if not installed in PATH + console = shutil.which("openml") + if console is None: + pytest.skip("'openml' console script not found in PATH") + + result = subprocess.run( # noqa: S603 + [console, "--version"], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0 + assert result.stderr == "" + assert openml.__version__ in result.stdout diff --git a/tests/test_openml/test_config.py b/tests/test_openml/test_config.py index 0324545a7..fa03a9985 100644 --- a/tests/test_openml/test_config.py +++ b/tests/test_openml/test_config.py @@ -1,19 +1,21 @@ # License: BSD 3-Clause from __future__ import annotations -from contextlib import contextmanager import os +import platform import tempfile import unittest.mock +from collections.abc import Iterator +from contextlib import contextmanager from copy import copy -from typing import Any, Iterator from pathlib import Path -import platform +from typing import Any import pytest -import openml.config +import openml import openml.testing +from openml.enums import APIVersion, ServerMode @contextmanager @@ -36,7 +38,7 @@ def safe_environ_patcher(key: str, value: Any) -> Iterator[None]: class TestConfig(openml.testing.TestBase): @unittest.mock.patch("openml.config.openml_logger.warning") - @unittest.mock.patch("openml.config._create_log_handlers") + @unittest.mock.patch("openml._config.OpenMLConfigManager._create_log_handlers") @unittest.skipIf(os.name == "nt", "https://github.com/openml/openml-python/issues/1033") @unittest.skipIf( platform.uname().release.endswith(("-Microsoft", "microsoft-standard-WSL2")), @@ -44,7 +46,7 @@ class TestConfig(openml.testing.TestBase): ) def test_non_writable_home(self, log_handler_mock, warnings_mock): with tempfile.TemporaryDirectory(dir=self.workdir) as td: - os.chmod(td, 0o444) + os.chmod(td, 0o444) # noqa: PTH101 _dd = copy(openml.config._defaults) _dd["cachedir"] = Path(td) / "something-else" openml.config._setup(_dd) @@ -76,22 +78,24 @@ def test_get_config_as_dict(self): """Checks if the current configuration is returned accurately as a dict.""" config = openml.config.get_config_as_dict() _config = {} - _config["apikey"] = "610344db6388d9ba34f6db45a3cf71de" - _config["server"] = "https://test.openml.org/api/v1/xml" + _config["api_version"] = APIVersion.V1 + _config["fallback_api_version"] = None + _config["servers"] = openml.config.get_test_servers() _config["cachedir"] = self.workdir _config["avoid_duplicate_runs"] = False _config["connection_n_retries"] = 20 _config["retry_policy"] = "robot" _config["show_progress"] = False assert isinstance(config, dict) - assert len(config) == 7 - self.assertDictEqual(config, _config) + assert len(config) == 8 + self.assertDictEqual(config, _config) # noqa: PT009 def test_setup_with_config(self): """Checks if the OpenML configuration can be updated using _setup().""" _config = {} - _config["apikey"] = "610344db6388d9ba34f6db45a3cf71de" - _config["server"] = "https://www.openml.org/api/v1/xml" + _config["api_version"] = APIVersion.V1 + _config["fallback_api_version"] = None + _config["servers"] = openml.config.get_test_servers() _config["cachedir"] = self.workdir _config["avoid_duplicate_runs"] = True _config["retry_policy"] = "human" @@ -101,59 +105,52 @@ def test_setup_with_config(self): openml.config._setup(_config) updated_config = openml.config.get_config_as_dict() openml.config._setup(orig_config) # important to not affect other unit tests - self.assertDictEqual(_config, updated_config) + self.assertDictEqual(_config, updated_config) # noqa: PT009 class TestConfigurationForExamples(openml.testing.TestBase): - @pytest.mark.production() + @pytest.mark.production_server def test_switch_to_example_configuration(self): """Verifies the test configuration is loaded properly.""" - # Below is the default test key which would be used anyway, but just for clarity: - openml.config.apikey = "610344db6388d9ba34f6db45a3cf71de" - openml.config.server = self.production_server + openml.config.use_production_servers() openml.config.start_using_configuration_for_example() - assert openml.config.apikey == "c0c42819af31e706efe1f4b88c23c6c1" - assert openml.config.server == self.test_server + assert openml.config.servers == openml.config.get_test_servers() - @pytest.mark.production() + @pytest.mark.production_server def test_switch_from_example_configuration(self): """Verifies the previous configuration is loaded after stopping.""" # Below is the default test key which would be used anyway, but just for clarity: - openml.config.apikey = "610344db6388d9ba34f6db45a3cf71de" - openml.config.server = self.production_server + openml.config.use_production_servers() openml.config.start_using_configuration_for_example() openml.config.stop_using_configuration_for_example() - assert openml.config.apikey == "610344db6388d9ba34f6db45a3cf71de" - assert openml.config.server == self.production_server + assert openml.config.servers == openml.config.get_production_servers() def test_example_configuration_stop_before_start(self): """Verifies an error is raised if `stop_...` is called before `start_...`.""" error_regex = ".*stop_use_example_configuration.*start_use_example_configuration.*first" # Tests do not reset the state of this class. Thus, we ensure it is in # the original state before the test. - openml.config.ConfigurationForExamples._start_last_called = False - self.assertRaisesRegex( + openml.config._examples._start_last_called = False + self.assertRaisesRegex( # noqa: PT027 RuntimeError, error_regex, openml.config.stop_using_configuration_for_example, ) - @pytest.mark.production() + @pytest.mark.production_server def test_example_configuration_start_twice(self): """Checks that the original config can be returned to if `start..` is called twice.""" - openml.config.apikey = "610344db6388d9ba34f6db45a3cf71de" - openml.config.server = self.production_server + openml.config.use_production_servers() openml.config.start_using_configuration_for_example() openml.config.start_using_configuration_for_example() openml.config.stop_using_configuration_for_example() - assert openml.config.apikey == "610344db6388d9ba34f6db45a3cf71de" - assert openml.config.server == self.production_server + assert openml.config.servers == openml.config.get_production_servers() def test_configuration_file_not_overwritten_on_load(): @@ -170,7 +167,7 @@ def test_configuration_file_not_overwritten_on_load(): new_file_content = config_file.read() assert config_file_content == new_file_content - assert "abcd" == read_config["apikey"] + assert read_config["apikey"] == "abcd" def test_configuration_loads_booleans(tmp_path): @@ -190,5 +187,71 @@ def test_openml_cache_dir_env_var(tmp_path: Path) -> None: with safe_environ_patcher("OPENML_CACHE_DIR", str(expected_path)): openml.config._setup() + assert openml.config._root_cache_directory == expected_path assert openml.config.get_cache_directory() == str(expected_path / "org" / "openml" / "www") + + +@pytest.mark.parametrize("mode", list(ServerMode)) +@pytest.mark.parametrize("api_version", [APIVersion.V1, APIVersion.V2]) +def test_get_servers(mode, api_version): + orig_servers = openml.config._get_servers(mode) + + openml.config._set_servers(mode) + openml.config.set_api_version(api_version) + openml.config.server = "temp-server1" + openml.config.apikey = "temp-apikey1" + openml.config._get_servers(mode)["server"] = "temp-server2" + openml.config._get_servers(mode)["apikey"] = "temp-server2" + + assert openml.config._get_servers(mode) == orig_servers + + +@pytest.mark.parametrize("mode", list(ServerMode)) +@pytest.mark.parametrize("api_version", [APIVersion.V1, APIVersion.V2]) +def test_set_servers(mode, api_version): + openml.config._set_servers(mode) + openml.config.set_api_version(api_version) + + assert openml.config.servers == openml.config._get_servers(mode) + assert openml.config.api_version == api_version + + openml.config.server = "temp-server" + openml.config.apikey = "temp-apikey" + + assert openml.config.server == openml.config.servers[api_version]["server"] + assert openml.config.apikey == openml.config.servers[api_version]["apikey"] + + for version, servers in openml.config.servers.items(): + if version == api_version: + assert servers != openml.config._get_servers(mode)[version] + else: + assert servers == openml.config._get_servers(mode)[version] + + +def test_get_production_servers(): + assert openml.config.get_production_servers() == openml.config._get_servers("production") + + +def test_get_test_servers(): + assert openml.config.get_test_servers() == openml.config._get_servers("test") + + +def test_use_production_servers(): + openml.config.use_production_servers() + servers_1 = openml.config.servers + + openml.config._set_servers("production") + servers_2 = openml.config.servers + + assert servers_1 == servers_2 + + +def test_use_test_servers(): + openml.config.use_test_servers() + servers_1 = openml.config.servers + + openml.config._set_servers("test") + servers_2 = openml.config.servers + + assert servers_1 == servers_2 diff --git a/tests/test_openml/test_openml.py b/tests/test_openml/test_openml.py index 998046726..9fd94d007 100644 --- a/tests/test_openml/test_openml.py +++ b/tests/test_openml/test_openml.py @@ -24,20 +24,35 @@ def test_populate_cache( ): openml.populate_cache(task_ids=[1, 2], dataset_ids=[3, 4], flow_ids=[5, 6], run_ids=[7, 8]) assert run_mock.call_count == 2 - for argument, fixture in zip(run_mock.call_args_list, [(7,), (8,)]): + for argument, fixture in zip(run_mock.call_args_list, [(7,), (8,)], strict=False): assert argument[0] == fixture assert flow_mock.call_count == 2 - for argument, fixture in zip(flow_mock.call_args_list, [(5,), (6,)]): + for argument, fixture in zip(flow_mock.call_args_list, [(5,), (6,)], strict=False): assert argument[0] == fixture assert dataset_mock.call_count == 2 for argument, fixture in zip( dataset_mock.call_args_list, [(3,), (4,)], + strict=False, ): assert argument[0] == fixture assert task_mock.call_count == 2 - for argument, fixture in zip(task_mock.call_args_list, [(1,), (2,)]): + for argument, fixture in zip(task_mock.call_args_list, [(1,), (2,)], strict=False): assert argument[0] == fixture + + def test_top_level_getters_aliases(self): + # Ensure top-level convenience aliases point to existing implementations. + assert openml.list_datasets is openml.datasets.list_datasets + assert openml.get_dataset is openml.datasets.get_dataset + + assert openml.list_flows is openml.flows.list_flows + assert openml.get_flow is openml.flows.get_flow + + assert openml.list_runs is openml.runs.list_runs + assert openml.get_run is openml.runs.get_run + + assert openml.list_tasks is openml.tasks.list_tasks + assert openml.get_task is openml.tasks.get_task diff --git a/tests/test_runs/test_run.py b/tests/test_runs/test_run.py index 034b731aa..99ae684c2 100644 --- a/tests/test_runs/test_run.py +++ b/tests/test_runs/test_run.py @@ -25,6 +25,8 @@ class TestRun(TestBase): # Splitting not helpful, these test's don't rely on the server and take # less than 1 seconds + @pytest.mark.skip(reason="Affected by lowered result limit #1757") + @pytest.mark.test_server def test_tagging(self): runs = openml.runs.list_runs(size=1) assert not runs.empty, "Test server state is incorrect" @@ -65,12 +67,12 @@ def _test_prediction_data_equal(run, run_prime): def _test_run_obj_equals(self, run, run_prime): for dictionary in ["evaluations", "fold_evaluations", "sample_evaluations"]: if getattr(run, dictionary) is not None: - self.assertDictEqual(getattr(run, dictionary), getattr(run_prime, dictionary)) + self.assertDictEqual(getattr(run, dictionary), getattr(run_prime, dictionary)) # noqa: PT009 else: # should be none or empty other = getattr(run_prime, dictionary) if other is not None: - self.assertDictEqual(other, {}) + self.assertDictEqual(other, {}) # noqa: PT009 assert run._to_xml() == run_prime._to_xml() self._test_prediction_data_equal(run, run_prime) @@ -104,7 +106,7 @@ def _check_array(array, type_): ) bool_part = [line[4] for line in run_trace_content] bool_part_prime = [line[4] for line in run_prime_trace_content] - for bp, bpp in zip(bool_part, bool_part_prime): + for bp, bpp in zip(bool_part, bool_part_prime, strict=False): assert bp in ["true", "false"] assert bpp in ["true", "false"] string_part = np.array(run_trace_content)[:, 5:] @@ -117,7 +119,8 @@ def _check_array(array, type_): else: assert run_prime_trace_content is None - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_to_from_filesystem_vanilla(self): model = Pipeline( [ @@ -133,7 +136,7 @@ def test_to_from_filesystem_vanilla(self): upload_flow=True, ) - cache_path = os.path.join( + cache_path = os.path.join( # noqa: PTH118 self.workdir, "runs", str(random.getrandbits(128)), @@ -151,8 +154,9 @@ def test_to_from_filesystem_vanilla(self): f"collected from {__file__.split('/')[-1]}: {run_prime.run_id}", ) - @pytest.mark.sklearn() - @pytest.mark.flaky() + @pytest.mark.sklearn + @pytest.mark.flaky + @pytest.mark.test_server def test_to_from_filesystem_search(self): model = Pipeline( [ @@ -175,7 +179,7 @@ def test_to_from_filesystem_search(self): add_local_measures=False, ) - cache_path = os.path.join(self.workdir, "runs", str(random.getrandbits(128))) + cache_path = os.path.join(self.workdir, "runs", str(random.getrandbits(128))) # noqa: PTH118 run.to_filesystem(cache_path) run_prime = openml.runs.OpenMLRun.from_filesystem(cache_path) @@ -186,7 +190,8 @@ def test_to_from_filesystem_search(self): f"collected from {__file__.split('/')[-1]}: {run_prime.run_id}", ) - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_to_from_filesystem_no_model(self): model = Pipeline( [("imputer", SimpleImputer(strategy="mean")), ("classifier", DummyClassifier())], @@ -194,12 +199,12 @@ def test_to_from_filesystem_no_model(self): task = openml.tasks.get_task(119) # diabetes; crossvalidation run = openml.runs.run_model_on_task(model=model, task=task, add_local_measures=False) - cache_path = os.path.join(self.workdir, "runs", str(random.getrandbits(128))) + cache_path = os.path.join(self.workdir, "runs", str(random.getrandbits(128))) # noqa: PTH118 run.to_filesystem(cache_path, store_model=False) # obtain run from filesystem openml.runs.OpenMLRun.from_filesystem(cache_path, expect_model=False) # assert default behaviour is throwing an error - with self.assertRaises(ValueError, msg="Could not find model.pkl"): + with self.assertRaises(ValueError, msg="Could not find model.pkl"): # noqa: PT027 openml.runs.OpenMLRun.from_filesystem(cache_path) @staticmethod @@ -262,7 +267,7 @@ def assert_run_prediction_data(task, run, model): for fold_id in range(n_folds): # Get data for fold _, test_indices = task.get_train_test_split_indices(repeat=0, fold=fold_id, sample=0) - train_mask = np.full(len(X), True) + train_mask = np.full(len(X), True) # noqa: FBT003 train_mask[test_indices] = False # Get train / test @@ -291,7 +296,8 @@ def assert_run_prediction_data(task, run, model): assert_method(y_pred, saved_y_pred) assert_method(y_test, saved_y_test) - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_publish_with_local_loaded_flow(self): """ Publish a run tied to a local flow after it has first been saved to @@ -318,7 +324,7 @@ def test_publish_with_local_loaded_flow(self): # Make sure that the prediction data stored in the run is correct. self.assert_run_prediction_data(task, run, clone(model)) - cache_path = os.path.join(self.workdir, "runs", str(random.getrandbits(128))) + cache_path = os.path.join(self.workdir, "runs", str(random.getrandbits(128))) # noqa: PTH118 run.to_filesystem(cache_path) # obtain run from filesystem loaded_run = openml.runs.OpenMLRun.from_filesystem(cache_path) @@ -334,7 +340,9 @@ def test_publish_with_local_loaded_flow(self): assert openml.flows.flow_exists(flow.name, flow.external_version) openml.runs.get_run(loaded_run.run_id) - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server + @pytest.mark.skip(reason="https://github.com/openml/openml-python/issues/1586") def test_offline_and_online_run_identical(self): extension = SklearnExtension() @@ -355,7 +363,7 @@ def test_offline_and_online_run_identical(self): assert not openml.flows.flow_exists(flow.name, flow.external_version) # Load from filesystem - cache_path = os.path.join(self.workdir, "runs", str(random.getrandbits(128))) + cache_path = os.path.join(self.workdir, "runs", str(random.getrandbits(128))) # noqa: PTH118 run.to_filesystem(cache_path) loaded_run = openml.runs.OpenMLRun.from_filesystem(cache_path) diff --git a/tests/test_runs/test_run_functions.py b/tests/test_runs/test_run_functions.py index b02acdf51..8e12762a2 100644 --- a/tests/test_runs/test_run_functions.py +++ b/tests/test_runs/test_run_functions.py @@ -7,9 +7,7 @@ import time import unittest import warnings - -from openml_sklearn import SklearnExtension, cat, cont -from packaging.version import Version +from collections import OrderedDict from unittest import mock import arff @@ -20,6 +18,9 @@ import requests import sklearn from joblib import parallel_backend +from openml_sklearn import SklearnExtension, cat, cont +from packaging.version import Version +from sklearn.compose import ColumnTransformer from sklearn.dummy import DummyClassifier from sklearn.ensemble import BaggingClassifier, RandomForestClassifier from sklearn.feature_selection import VarianceThreshold @@ -31,7 +32,6 @@ from sklearn.preprocessing import OneHotEncoder, StandardScaler from sklearn.svm import SVC from sklearn.tree import DecisionTreeClassifier -from sklearn.compose import ColumnTransformer import openml import openml._api_calls @@ -40,9 +40,10 @@ OpenMLNotAuthorizedError, OpenMLServerException, ) -#from openml.extensions.sklearn import cat, cont from openml.runs.functions import ( + _create_run_from_results, _run_task_get_arffcontent, + _sync_flow_with_server, delete_run, format_prediction, run_exists, @@ -60,7 +61,7 @@ class TestRun(TestBase): _multiprocess_can_split_ = True - TEST_SERVER_TASK_MISSING_VALS = { + TEST_SERVER_TASK_MISSING_VALS = { # noqa: RUF012 "task_id": 96, "n_missing_vals": 67, "n_test_obs": 227, @@ -73,7 +74,7 @@ class TestRun(TestBase): "target_name": "class", }, } - TEST_SERVER_TASK_SIMPLE = { + TEST_SERVER_TASK_SIMPLE = { # noqa: RUF012 "task_id": 119, "n_missing_vals": 0, "n_test_obs": 253, @@ -86,7 +87,7 @@ class TestRun(TestBase): "target_name": "class", }, } - TEST_SERVER_TASK_REGRESSION = { + TEST_SERVER_TASK_REGRESSION = { # noqa: RUF012 "task_id": 1605, "n_missing_vals": 0, "n_test_obs": 2178, @@ -111,6 +112,61 @@ def setUp(self): super().setUp() self.extension = SklearnExtension() + def test_sync_flow_with_server_returns_early_without_sync_flags(self): + flow = mock.MagicMock() + flow.name = "dummy-flow" + flow.external_version = "1" + flow.flow_id = None + task = mock.MagicMock() + + with mock.patch("openml.runs.functions.flow_exists") as flow_exists_mock: + flow_id = _sync_flow_with_server( + flow=flow, + task=task, + upload_flow=False, + avoid_duplicate_runs=False, + ) + + assert flow_id is None + flow_exists_mock.assert_not_called() + + def test_create_run_from_results_keeps_required_constructor_fields(self): + model = object() + flow = mock.MagicMock() + flow.model = model + flow.name = "dummy-flow" + flow.flow_id = None + flow.extension.create_setup_string.return_value = "dummy-setup-string" + + task = mock.MagicMock() + task.task_id = 123 + task.task_type_id = TaskType.SUPERVISED_CLASSIFICATION + dataset = mock.MagicMock() + dataset.dataset_id = 456 + task.get_dataset.return_value = dataset + + run = _create_run_from_results( + task=task, + flow=flow, + flow_id=None, + data_content=[[0, 0, 0, "prediction", "truth"]], + trace=None, + fold_evaluations=OrderedDict(), + sample_evaluations=OrderedDict(), + tags=["openml-python", "python"], + run_environment=["python", "3.11"], + upload_flow=False, + avoid_duplicate_runs=False, + ) + + assert run.task_id == 123 + assert run.dataset_id == 456 + assert run.model is model + assert run.flow_name == "dummy-flow" + assert run.setup_string == "dummy-setup-string" + assert run.description_text is not None + assert "Created by run_flow_on_task" in run.description_text + def _wait_for_processed_run(self, run_id, max_waiting_time_seconds): # it can take a while for a run to be processed on the OpenML (test) # server however, sometimes it is good to wait (a bit) for this, to @@ -156,7 +212,7 @@ def _assert_predictions_equal(self, predictions, predictions_prime): val_1 = predictions["data"][idx][col_idx] val_2 = predictions_prime["data"][idx][col_idx] if isinstance(val_1, float) or isinstance(val_2, float): - self.assertAlmostEqual( + self.assertAlmostEqual( # noqa: PT009 float(val_1), float(val_2), places=6, @@ -199,7 +255,7 @@ def _rerun_model_and_compare_predictions(self, run_id, model_prime, seed, create check_dtype=False, # Loaded ARFF reads NUMERIC as float, even if integer. ) - def _perform_run( + def _perform_run( # noqa: PLR0913,PLR0917, PLR0915 self, task_id, num_instances, @@ -207,7 +263,7 @@ def _perform_run( clf, flow_expected_rsv=None, seed=1, - check_setup=True, + check_setup=True, # noqa: FBT002 sentinel=None, ): """ @@ -270,7 +326,7 @@ def _remove_random_state(flow): task = openml.tasks.get_task(task_id) - X, y = task.get_X_and_y() + X, _y = task.get_X_and_y() assert X.isna().sum().sum() == n_missing_vals run = openml.runs.run_flow_on_task( flow=flow, @@ -305,10 +361,8 @@ def _remove_random_state(flow): flow_server = self.extension.model_to_flow(clf_server) if flow.class_name not in classes_without_random_state: - error_msg = "Flow class %s (id=%d) does not have a random state parameter" % ( - flow.class_name, - flow.flow_id, - ) + error_msg = f"""Flow class {flow.class_name} (id={flow.flow_id}) does not have + a random state parameter""" assert "random_state" in flow.parameters, error_msg # If the flow is initialized from a model without a random # state, the flow is on the server without any random state @@ -332,8 +386,8 @@ def _remove_random_state(flow): _remove_random_state(flow_server2) openml.flows.assert_flows_equal(flow_local, flow_server2) - # self.assertEqual(clf.get_params(), clf_prime.get_params()) - # self.assertEqual(clf, clf_prime) + # self.assertEqual(clf.get_params(), clf_prime.get_params()) # noqa: ERA001 + # self.assertEqual(clf, clf_prime) # noqa: ERA001 downloaded = openml.runs.get_run(run_.run_id) assert "openml-python" in downloaded.tags @@ -342,8 +396,8 @@ def _remove_random_state(flow): # downloading a run? Or make sure that the trace object is created when # running a flow on a task (and not only the arff object is created, # so that the two objects can actually be compared): - # downloaded_run_trace = downloaded._generate_trace_arff_dict() - # self.assertEqual(run_trace, downloaded_run_trace) + # downloaded_run_trace = downloaded._generate_trace_arff_dict() # noqa: ERA001 + # self.assertEqual(run_trace, downloaded_run_trace) # noqa: ERA001 return run def _check_sample_evaluations( @@ -397,7 +451,8 @@ def _check_sample_evaluations( assert evaluation > 0 assert evaluation < max_time_allowed - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_run_regression_on_classif_task(self): task_id = 259 # collins; crossvalidation; has numeric targets @@ -413,7 +468,8 @@ def test_run_regression_on_classif_task(self): task=task, ) - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_check_erronous_sklearn_flow_fails(self): task_id = 115 # diabetes; crossvalidation task = openml.tasks.get_task(task_id) @@ -423,7 +479,7 @@ def test_check_erronous_sklearn_flow_fails(self): # The exact error message depends on scikit-learn version. # Because the sklearn-extension module is to be separated, # I will simply relax specifics of the raised Error. - # old: r"Penalty term must be positive; got \(C=u?'abc'\)" + # old: r"Penalty term must be positive; got \(C=u?'abc'\)" # noqa: ERA001 # new: sklearn.utils._param_validation.InvalidParameterError: # The 'C' parameter of LogisticRegression must be a float in the range (0, inf]. Got 'abc' instead. # noqa: E501 try: @@ -441,16 +497,16 @@ def test_check_erronous_sklearn_flow_fails(self): ########################################################################### # These unit tests are meant to test the following functions, using a # variety of flows: - # - openml.runs.run_task() - # - openml.runs.OpenMLRun.publish() - # - openml.runs.initialize_model() + # - openml.runs.run_task() # noqa: ERA001 + # - openml.runs.OpenMLRun.publish() # noqa: ERA001 + # - openml.runs.initialize_model() # noqa: ERA001 # - [implicitly] openml.setups.initialize_model() - # - openml.runs.initialize_model_from_trace() + # - openml.runs.initialize_model_from_trace() # noqa: ERA001 # They're split among several actual functions to allow for parallel # execution of the unit tests without the need to add an additional module # like unittest2 - def _run_and_upload( + def _run_and_upload( # noqa: C901, PLR0913,PLR0917 self, clf, task_id, @@ -471,13 +527,12 @@ def determine_grid_size(param_grid): for param in param_grid: grid_iterations *= len(param_grid[param]) return grid_iterations - elif isinstance(param_grid, list): + if isinstance(param_grid, list): grid_iterations = 0 for sub_grid in param_grid: grid_iterations += determine_grid_size(sub_grid) return grid_iterations - else: - raise TypeError("Param Grid should be of type list (GridSearch only) or dict") + raise TypeError("Param Grid should be of type list (GridSearch only) or dict") run = self._perform_run( task_id, @@ -518,7 +573,7 @@ def determine_grid_size(param_grid): fold=0, ) except openml.exceptions.OpenMLServerException as e: - e.message = "%s; run_id %d" % (e.message, run.run_id) + e.message = f"{e.message}; run_id {run.run_id}" raise e self._rerun_model_and_compare_predictions( @@ -567,7 +622,7 @@ def determine_grid_size(param_grid): return run - def _run_and_upload_classification( + def _run_and_upload_classification( # noqa: PLR0913,PLR0917 self, clf, task_id, @@ -596,7 +651,7 @@ def _run_and_upload_classification( sentinel=sentinel, ) - def _run_and_upload_regression( + def _run_and_upload_regression( # noqa: PLR0913,PLR0917 self, clf, task_id, @@ -625,7 +680,8 @@ def _run_and_upload_regression( sentinel=sentinel, ) - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_run_and_upload_logistic_regression(self): lr = LogisticRegression(solver="lbfgs", max_iter=1000) task_id = self.TEST_SERVER_TASK_SIMPLE["task_id"] @@ -633,7 +689,8 @@ def test_run_and_upload_logistic_regression(self): n_test_obs = self.TEST_SERVER_TASK_SIMPLE["n_test_obs"] self._run_and_upload_classification(lr, task_id, n_missing_vals, n_test_obs, "62501") - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_run_and_upload_linear_regression(self): lr = LinearRegression() task_id = self.TEST_SERVER_TASK_REGRESSION["task_id"] @@ -651,10 +708,10 @@ def test_run_and_upload_linear_regression(self): except OpenMLServerException as e: if e.code == 614: # Task already exists # the exception message contains the task_id that was matched in the format - # 'Task already exists. - matched id(s): [xxxx]' + # 'Task already exists. - matched id(s): [xxxx]' # noqa: ERA001 task_id = ast.literal_eval(e.message.split("matched id(s):")[-1].strip())[0] else: - raise Exception(repr(e)) + raise Exception(repr(e)) # noqa: B904 # mark to remove the uploaded task TestBase._mark_entity_for_removal("task", task_id) TestBase.logger.info(f"collected from test_run_functions: {task_id}") @@ -663,7 +720,8 @@ def test_run_and_upload_linear_regression(self): n_test_obs = self.TEST_SERVER_TASK_REGRESSION["n_test_obs"] self._run_and_upload_regression(lr, task_id, n_missing_vals, n_test_obs, "62501") - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_run_and_upload_pipeline_dummy_pipeline(self): pipeline1 = Pipeline( steps=[ @@ -676,11 +734,12 @@ def test_run_and_upload_pipeline_dummy_pipeline(self): n_test_obs = self.TEST_SERVER_TASK_SIMPLE["n_test_obs"] self._run_and_upload_classification(pipeline1, task_id, n_missing_vals, n_test_obs, "62501") - @pytest.mark.sklearn() + @pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) < Version("0.20"), reason="columntransformer introduction in 0.20.0", ) + @pytest.mark.test_server def test_run_and_upload_column_transformer_pipeline(self): import sklearn.compose import sklearn.impute @@ -740,7 +799,7 @@ def get_ct_cf(nominal_indices, numeric_indices): sentinel=sentinel, ) - @pytest.mark.sklearn() + @pytest.mark.sklearn @unittest.skip("https://github.com/openml/OpenML/issues/1180") @unittest.skipIf( Version(sklearn.__version__) < Version("0.20"), @@ -792,7 +851,8 @@ def test_run_and_upload_knn_pipeline(self, warnings_mock): call_count += 1 assert call_count == 3 - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_run_and_upload_gridsearch(self): estimator_name = ( "base_estimator" if Version(sklearn.__version__) < Version("1.4") else "estimator" @@ -814,7 +874,8 @@ def test_run_and_upload_gridsearch(self): ) assert len(run.trace.trace_iterations) == 9 - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_run_and_upload_randomsearch(self): randomsearch = RandomizedSearchCV( RandomForestClassifier(n_estimators=5), @@ -846,7 +907,8 @@ def test_run_and_upload_randomsearch(self): trace = openml.runs.get_run_trace(run.run_id) assert len(trace.trace_iterations) == 5 - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_run_and_upload_maskedarrays(self): # This testcase is important for 2 reasons: # 1) it verifies the correct handling of masked arrays (not all @@ -873,7 +935,8 @@ def test_run_and_upload_maskedarrays(self): ########################################################################## - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_learning_curve_task_1(self): task_id = 801 # diabates dataset num_test_instances = 6144 # for learning curve @@ -897,7 +960,8 @@ def test_learning_curve_task_1(self): ) self._check_sample_evaluations(run.sample_evaluations, num_repeats, num_folds, num_samples) - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_learning_curve_task_2(self): task_id = 801 # diabates dataset num_test_instances = 6144 # for learning curve @@ -933,11 +997,12 @@ def test_learning_curve_task_2(self): ) self._check_sample_evaluations(run.sample_evaluations, num_repeats, num_folds, num_samples) - @pytest.mark.sklearn() + @pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) < Version("0.21"), reason="Pipelines don't support indexing (used for the assert check)", ) + @pytest.mark.test_server def test_initialize_cv_from_run(self): randomsearch = Pipeline( [ @@ -1011,7 +1076,8 @@ def _test_local_evaluations(self, run): assert alt_scores[idx] >= 0 assert alt_scores[idx] <= 1 - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_local_run_swapped_parameter_order_model(self): clf = DecisionTreeClassifier() australian_task = 595 # Australian; crossvalidation @@ -1026,11 +1092,13 @@ def test_local_run_swapped_parameter_order_model(self): self._test_local_evaluations(run) - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.skip("https://github.com/openml/openml-python/issues/1586") @unittest.skipIf( Version(sklearn.__version__) < Version("0.20"), reason="SimpleImputer doesn't handle mixed type DataFrame as input", ) + @pytest.mark.test_server def test_local_run_swapped_parameter_order_flow(self): # construct sci-kit learn classifier clf = Pipeline( @@ -1054,11 +1122,13 @@ def test_local_run_swapped_parameter_order_flow(self): self._test_local_evaluations(run) - @pytest.mark.sklearn() + @pytest.mark.skip(reason="https://github.com/openml/openml-python/issues/1586") + @pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) < Version("0.20"), reason="SimpleImputer doesn't handle mixed type DataFrame as input", ) + @pytest.mark.test_server def test_local_run_metric_score(self): # construct sci-kit learn classifier clf = Pipeline( @@ -1081,7 +1151,7 @@ def test_local_run_metric_score(self): self._test_local_evaluations(run) - @pytest.mark.production() + @pytest.mark.production_server def test_online_run_metric_score(self): self.use_production_server() @@ -1091,11 +1161,12 @@ def test_online_run_metric_score(self): self._test_local_evaluations(run) - @pytest.mark.sklearn() + @pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) < Version("0.20"), reason="SimpleImputer doesn't handle mixed type DataFrame as input", ) + @pytest.mark.test_server def test_initialize_model_from_run(self): clf = sklearn.pipeline.Pipeline( steps=[ @@ -1122,10 +1193,10 @@ def test_initialize_model_from_run(self): except OpenMLServerException as e: if e.code == 614: # Task already exists # the exception message contains the task_id that was matched in the format - # 'Task already exists. - matched id(s): [xxxx]' + # 'Task already exists. - matched id(s): [xxxx]' # noqa: ERA001 task_id = ast.literal_eval(e.message.split("matched id(s):")[-1].strip())[0] else: - raise Exception(repr(e)) + raise Exception(repr(e)) # noqa: B904 # mark to remove the uploaded task TestBase._mark_entity_for_removal("task", task_id) TestBase.logger.info(f"collected from test_run_functions: {task_id}") @@ -1152,11 +1223,13 @@ def test_initialize_model_from_run(self): assert flowS.components["Imputer"].parameters["strategy"] == '"most_frequent"' assert flowS.components["VarianceThreshold"].parameters["threshold"] == "0.05" - @pytest.mark.sklearn() + @pytest.mark.skip(reason="Affected by lowered result limit #1757") + @pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) < Version("0.20"), reason="SimpleImputer doesn't handle mixed type DataFrame as input", ) + @pytest.mark.test_server def test__run_exists(self): # would be better to not sentinel these clfs, # so we do not have to perform the actual runs @@ -1211,7 +1284,8 @@ def test__run_exists(self): run_ids = run_exists(task.task_id, setup_exists) assert run_ids, (run_ids, clf) - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_run_with_illegal_flow_id(self): # check the case where the user adds an illegal flow id to a # non-existing flo @@ -1230,7 +1304,8 @@ def test_run_with_illegal_flow_id(self): avoid_duplicate_runs=True, ) - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_run_with_illegal_flow_id_after_load(self): # Same as `test_run_with_illegal_flow_id`, but test this error is also # caught if the run is stored to and loaded from disk first. @@ -1245,7 +1320,7 @@ def test_run_with_illegal_flow_id_after_load(self): upload_flow=False, ) - cache_path = os.path.join( + cache_path = os.path.join( # noqa: PTH118 self.workdir, "runs", str(random.getrandbits(128)), @@ -1256,12 +1331,13 @@ def test_run_with_illegal_flow_id_after_load(self): expected_message_regex = ( r"Flow does not exist on the server, but 'flow.flow_id' is not None." ) - with pytest.raises(openml.exceptions.PyOpenMLError, match=expected_message_regex): + with pytest.raises(openml.exceptions.PyOpenMLError, match=expected_message_regex): # noqa: PT012 loaded_run.publish() TestBase._mark_entity_for_removal("run", loaded_run.run_id) TestBase.logger.info(f"collected from test_run_functions: {loaded_run.run_id}") - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_run_with_illegal_flow_id_1(self): # Check the case where the user adds an illegal flow id to an existing # flow. Comes to a different value error than the previous test @@ -1286,7 +1362,8 @@ def test_run_with_illegal_flow_id_1(self): avoid_duplicate_runs=True, ) - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_run_with_illegal_flow_id_1_after_load(self): # Same as `test_run_with_illegal_flow_id_1`, but test this error is # also caught if the run is stored to and loaded from disk first. @@ -1309,7 +1386,7 @@ def test_run_with_illegal_flow_id_1_after_load(self): upload_flow=False, ) - cache_path = os.path.join( + cache_path = os.path.join( # noqa: PTH118 self.workdir, "runs", str(random.getrandbits(128)), @@ -1318,17 +1395,18 @@ def test_run_with_illegal_flow_id_1_after_load(self): loaded_run = openml.runs.OpenMLRun.from_filesystem(cache_path) expected_message_regex = "Local flow_id does not match server flow_id: '-1' vs '[0-9]+'" - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 openml.exceptions.PyOpenMLError, expected_message_regex, loaded_run.publish, ) - @pytest.mark.sklearn() + @pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) < Version("0.20"), reason="OneHotEncoder cannot handle mixed type DataFrame as input", ) + @pytest.mark.test_server def test__run_task_get_arffcontent(self): task = openml.tasks.get_task(7) # kr-vs-kp; crossvalidation num_instances = 3196 @@ -1378,14 +1456,14 @@ def test__run_task_get_arffcontent(self): assert arff_line[4] in ["won", "nowin"] assert arff_line[5] in ["won", "nowin"] # check confidences - self.assertAlmostEqual(sum(arff_line[6:]), 1.0) + self.assertAlmostEqual(sum(arff_line[6:]), 1.0) # noqa: PT009 def test__create_trace_from_arff(self): - with open(self.static_cache_dir / "misc" / "trace.arff") as arff_file: + with open(self.static_cache_dir / "misc" / "trace.arff") as arff_file: # noqa: PTH123 trace_arff = arff.load(arff_file) OpenMLRunTrace.trace_from_arff(trace_arff) - @pytest.mark.production() + @pytest.mark.production_server def test_get_run(self): # this run is not available on test self.use_production_server() @@ -1407,9 +1485,8 @@ def test_get_run(self): assert run.fold_evaluations["f_measure"][0][i] == value assert "weka" in run.tags assert "weka_3.7.12" in run.tags - assert run.predictions_url == ( - "https://api.openml.org/data/download/1667125/" - "weka_generated_predictions4575715871712251329.arff" + assert run.predictions_url.endswith( + "/data/download/1667125/weka_generated_predictions4575715871712251329.arff" ) def _check_run(self, run): @@ -1421,20 +1498,22 @@ def _check_run(self, run): assert isinstance(run, dict) assert len(run) == 8, str(run) - @pytest.mark.production() + @pytest.mark.production_server def test_get_runs_list(self): # TODO: comes from live, no such lists on test self.use_production_server() - runs = openml.runs.list_runs(id=[2], display_errors=True) + runs = openml.runs.list_runs(id=[2], display_errors=True, size=10) assert len(runs) == 1 for run in runs.to_dict(orient="index").values(): self._check_run(run) + @pytest.mark.test_server def test_list_runs_empty(self): - runs = openml.runs.list_runs(task=[0]) + runs = openml.runs.list_runs(task=[0], size=1) assert runs.empty - @pytest.mark.production() + @pytest.mark.skip(reason="Affected by lowered result limit #1757") + @pytest.mark.production_server def test_get_runs_list_by_task(self): # TODO: comes from live, no such lists on test self.use_production_server() @@ -1453,7 +1532,8 @@ def test_get_runs_list_by_task(self): assert run["task_id"] in task_ids self._check_run(run) - @pytest.mark.production() + @pytest.mark.skip(reason="Affected by lowered result limit #1757") + @pytest.mark.production_server def test_get_runs_list_by_uploader(self): # TODO: comes from live, no such lists on test self.use_production_server() @@ -1475,7 +1555,8 @@ def test_get_runs_list_by_uploader(self): assert run["uploader"] in uploader_ids self._check_run(run) - @pytest.mark.production() + @pytest.mark.skip(reason="Affected by lowered result limit #1757") + @pytest.mark.production_server def test_get_runs_list_by_flow(self): # TODO: comes from live, no such lists on test self.use_production_server() @@ -1494,20 +1575,20 @@ def test_get_runs_list_by_flow(self): assert run["flow_id"] in flow_ids self._check_run(run) - @pytest.mark.production() + @pytest.mark.production_server def test_get_runs_pagination(self): # TODO: comes from live, no such lists on test self.use_production_server() uploader_ids = [1] size = 10 - max = 100 + max = 100 # noqa: A001 for i in range(0, max, size): runs = openml.runs.list_runs(offset=i, size=size, uploader=uploader_ids) assert size >= len(runs) for run in runs.to_dict(orient="index").values(): assert run["uploader"] in uploader_ids - @pytest.mark.production() + @pytest.mark.production_server def test_get_runs_list_by_filters(self): # TODO: comes from live, no such lists on test self.use_production_server() @@ -1525,39 +1606,41 @@ def test_get_runs_list_by_filters(self): # self.assertRaises(openml.exceptions.OpenMLServerError, # openml.runs.list_runs) - runs = openml.runs.list_runs(id=ids) + runs = openml.runs.list_runs(id=ids, size=100) assert len(runs) == 2 - runs = openml.runs.list_runs(task=tasks) + runs = openml.runs.list_runs(task=tasks, size=100) assert len(runs) >= 2 - runs = openml.runs.list_runs(uploader=uploaders_2) + runs = openml.runs.list_runs(uploader=uploaders_2, size=100) assert len(runs) >= 10 - runs = openml.runs.list_runs(flow=flows) + runs = openml.runs.list_runs(flow=flows, size=100) assert len(runs) >= 100 runs = openml.runs.list_runs( id=ids, task=tasks, uploader=uploaders_1, + size=100, ) assert len(runs) == 2 - @pytest.mark.production() + @pytest.mark.production_server + @pytest.mark.xfail(reason="failures_issue_1544", strict=False) def test_get_runs_list_by_tag(self): - # TODO: comes from live, no such lists on test - # Unit test works on production server only - + # We don't have tagged runs on the test server self.use_production_server() - runs = openml.runs.list_runs(tag="curves") + # Don't remove the size restriction: this query is too expensive without + runs = openml.runs.list_runs(tag="curves", size=2) assert len(runs) >= 1 - @pytest.mark.sklearn() + @pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) < Version("0.20"), reason="columntransformer introduction in 0.20.0", ) + @pytest.mark.test_server def test_run_on_dataset_with_missing_labels_dataframe(self): # Check that _run_task_get_arffcontent works when one of the class # labels only declared in the arff file, but is not present in the @@ -1589,11 +1672,12 @@ def test_run_on_dataset_with_missing_labels_dataframe(self): # repeat, fold, row_id, 6 confidences, prediction and correct label assert len(row) == 12 - @pytest.mark.sklearn() + @pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) < Version("0.20"), reason="columntransformer introduction in 0.20.0", ) + @pytest.mark.test_server def test_run_on_dataset_with_missing_labels_array(self): # Check that _run_task_get_arffcontent works when one of the class # labels only declared in the arff file, but is not present in the @@ -1632,16 +1716,23 @@ def test_run_on_dataset_with_missing_labels_array(self): # repeat, fold, row_id, 6 confidences, prediction and correct label assert len(row) == 12 - def test_get_cached_run(self): + @pytest.mark.test_server + @mock.patch.object(requests.Session, "request") + def test_get_cached_run(self, mock_request): openml.config.set_root_cache_directory(self.static_cache_dir) - openml.runs.functions._get_cached_run(1) + mock_request.side_effect = Exception("Mocked Exception") + openml.runs.get_run(1) - def test_get_uncached_run(self): + @pytest.mark.test_server + @mock.patch.object(requests.Session, "request") + def test_get_uncached_run(self, mock_request): openml.config.set_root_cache_directory(self.static_cache_dir) - with pytest.raises(openml.exceptions.OpenMLCacheException): - openml.runs.functions._get_cached_run(10) + mock_request.side_effect = Exception("Mocked Exception") + with pytest.raises(Exception, match="Mocked Exception"): + openml.runs.get_run(10) - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_run_flow_on_task_downloaded_flow(self): model = sklearn.ensemble.RandomForestClassifier(n_estimators=33) flow = self.extension.model_to_flow(model) @@ -1661,7 +1752,7 @@ def test_run_flow_on_task_downloaded_flow(self): TestBase._mark_entity_for_removal("run", run.run_id) TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {run.run_id}") - @pytest.mark.production() + @pytest.mark.production_server def test_format_prediction_non_supervised(self): # non-supervised tasks don't exist on the test server self.use_production_server() @@ -1672,6 +1763,7 @@ def test_format_prediction_non_supervised(self): ): format_prediction(clustering, *ignored_input) + @pytest.mark.test_server def test_format_prediction_classification_no_probabilities(self): classification = openml.tasks.get_task( self.TEST_SERVER_TASK_SIMPLE["task_id"], @@ -1681,16 +1773,18 @@ def test_format_prediction_classification_no_probabilities(self): with pytest.raises(ValueError, match="`proba` is required for classification task"): format_prediction(classification, *ignored_input, proba=None) + @pytest.mark.test_server def test_format_prediction_classification_incomplete_probabilities(self): classification = openml.tasks.get_task( self.TEST_SERVER_TASK_SIMPLE["task_id"], download_data=False, ) ignored_input = [0] * 5 - incomplete_probabilities = {c: 0.2 for c in classification.class_labels[1:]} + incomplete_probabilities = dict.fromkeys(classification.class_labels[1:], 0.2) with pytest.raises(ValueError, match="Each class should have a predicted probability"): format_prediction(classification, *ignored_input, proba=incomplete_probabilities) + @pytest.mark.test_server def test_format_prediction_task_without_classlabels_set(self): classification = openml.tasks.get_task( self.TEST_SERVER_TASK_SIMPLE["task_id"], @@ -1701,13 +1795,15 @@ def test_format_prediction_task_without_classlabels_set(self): with pytest.raises(ValueError, match="The classification task must have class labels set"): format_prediction(classification, *ignored_input, proba={}) + @pytest.mark.test_server def test_format_prediction_task_learning_curve_sample_not_set(self): learning_curve = openml.tasks.get_task(801, download_data=False) # diabetes;crossvalidation - probabilities = {c: 0.2 for c in learning_curve.class_labels} + probabilities = dict.fromkeys(learning_curve.class_labels, 0.2) ignored_input = [0] * 5 with pytest.raises(ValueError, match="`sample` can not be none for LearningCurveTask"): format_prediction(learning_curve, *ignored_input, sample=None, proba=probabilities) + @pytest.mark.test_server def test_format_prediction_task_regression(self): task_meta_data = self.TEST_SERVER_TASK_REGRESSION["task_meta_data"] _task_id = check_task_existence(**task_meta_data) @@ -1722,10 +1818,10 @@ def test_format_prediction_task_regression(self): except OpenMLServerException as e: if e.code == 614: # Task already exists # the exception message contains the task_id that was matched in the format - # 'Task already exists. - matched id(s): [xxxx]' + # 'Task already exists. - matched id(s): [xxxx]' # noqa: ERA001 task_id = ast.literal_eval(e.message.split("matched id(s):")[-1].strip())[0] else: - raise Exception(repr(e)) + raise Exception(repr(e)) # noqa: B904 # mark to remove the uploaded task TestBase._mark_entity_for_removal("task", task_id) TestBase.logger.info(f"collected from test_run_functions: {task_id}") @@ -1733,17 +1829,17 @@ def test_format_prediction_task_regression(self): regression = openml.tasks.get_task(task_id, download_data=False) ignored_input = [0] * 5 res = format_prediction(regression, *ignored_input) - self.assertListEqual(res, [0] * 5) - - + self.assertListEqual(res, [0] * 5) # noqa: PT009 + @pytest.mark.skip(reason="Affected by lowered result limit #1757") @unittest.skipIf( Version(sklearn.__version__) < Version("0.20"), reason="SimpleImputer doesn't handle mixed type DataFrame as input", ) - @pytest.mark.sklearn() + @pytest.mark.sklearn + @pytest.mark.test_server def test_delete_run(self): - rs = np.random.randint(1, 2**31 - 1) + rs = np.random.randint(1, 2**31 - 1) # noqa: NPY002 clf = sklearn.pipeline.Pipeline( steps=[ (f"test_server_imputer_{rs}", SimpleImputer()), @@ -1753,7 +1849,9 @@ def test_delete_run(self): task = openml.tasks.get_task(32) # diabetes; crossvalidation run = openml.runs.run_model_on_task( - model=clf, task=task, seed=rs, + model=clf, + task=task, + seed=rs, ) run.publish() @@ -1766,43 +1864,43 @@ def test_delete_run(self): _run_id = run.run_id assert delete_run(_run_id) + @pytest.mark.skip(reason="run id is in problematic state on test server due to PR#1454") @unittest.skipIf( Version(sklearn.__version__) < Version("0.20"), reason="SimpleImputer doesn't handle mixed type DataFrame as input", ) - @pytest.mark.sklearn() + @pytest.mark.sklearn def test_initialize_model_from_run_nonstrict(self): # We cannot guarantee that a run with an older version exists on the server. # Thus, we test it simply with a run that we know exists that might not be loose. - # This tests all lines of code for OpenML but not the initialization, which we do not want to guarantee anyhow. + # This tests all lines of code for OpenML but not the initialization, which we do not want to guarantee anyhow. # noqa: E501 _ = openml.runs.initialize_model_from_run(run_id=1, strict_version=False) -@mock.patch.object(requests.Session, "delete") -def test_delete_run_not_owned(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +@mock.patch.object(requests.Session, "request") +def test_delete_run_not_owned(mock_request, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "runs" / "run_delete_not_owned.xml" - mock_delete.return_value = create_request_response( + mock_request.return_value = create_request_response( status_code=412, content_filepath=content_file, ) with pytest.raises( OpenMLNotAuthorizedError, - match="The run can not be deleted because it was not uploaded by you.", + match=r"The run can not be deleted because it was not uploaded by you.", ): openml.runs.delete_run(40_000) - run_url = "https://test.openml.org/api/v1/xml/run/40000" - assert run_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + run_url = test_server_v1 + "run/40000" + assert run_url == mock_request.call_args.kwargs.get("url") + assert mock_request.call_args.kwargs.get("method") == "DELETE" + assert test_apikey_v1 == mock_request.call_args.kwargs.get("params", {}).get("api_key") -@mock.patch.object(requests.Session, "delete") -def test_delete_run_success(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +@mock.patch.object(requests.Session, "request") +def test_delete_run_success(mock_request, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "runs" / "run_delete_successful.xml" - mock_delete.return_value = create_request_response( + mock_request.return_value = create_request_response( status_code=200, content_filepath=content_file, ) @@ -1810,16 +1908,16 @@ def test_delete_run_success(mock_delete, test_files_directory, test_api_key): success = openml.runs.delete_run(10591880) assert success - run_url = "https://test.openml.org/api/v1/xml/run/10591880" - assert run_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + run_url = test_server_v1 + "run/10591880" + assert run_url == mock_request.call_args.kwargs.get("url") + assert mock_request.call_args.kwargs.get("method") == "DELETE" + assert test_apikey_v1 == mock_request.call_args.kwargs.get("params", {}).get("api_key") -@mock.patch.object(requests.Session, "delete") -def test_delete_unknown_run(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +@mock.patch.object(requests.Session, "request") +def test_delete_unknown_run(mock_request, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "runs" / "run_delete_not_exist.xml" - mock_delete.return_value = create_request_response( + mock_request.return_value = create_request_response( status_code=412, content_filepath=content_file, ) @@ -1830,21 +1928,27 @@ def test_delete_unknown_run(mock_delete, test_files_directory, test_api_key): ): openml.runs.delete_run(9_999_999) - run_url = "https://test.openml.org/api/v1/xml/run/9999999" - assert run_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + run_url = test_server_v1 + "run/9999999" + assert run_url == mock_request.call_args.kwargs.get("url") + assert mock_request.call_args.kwargs.get("method") == "DELETE" + assert test_apikey_v1 == mock_request.call_args.kwargs.get("params", {}).get("api_key") -@pytest.mark.sklearn() +@pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) < Version("0.21"), reason="couldn't perform local tests successfully w/o bloating RAM", - ) +) +@unittest.skipIf( + Version(sklearn.__version__) >= Version("1.8"), + reason="predictions differ significantly", +) @mock.patch("openml_sklearn.SklearnExtension._prevent_optimize_n_jobs") +@pytest.mark.test_server def test__run_task_get_arffcontent_2(parallel_mock): """Tests if a run executed in parallel is collated correctly.""" task = openml.tasks.get_task(7) # Supervised Classification on kr-vs-kp - x, y = task.get_X_and_y() + x, _y = task.get_X_and_y() num_instances = x.shape[0] line_length = 6 + len(task.class_labels) loss = "log" if Version(sklearn.__version__) < Version("1.3") else "log_loss" @@ -1869,6 +1973,7 @@ def test__run_task_get_arffcontent_2(parallel_mock): n_jobs = 2 backend = "loky" if Version(joblib.__version__) > Version("0.11") else "multiprocessing" from openml_sklearn import SklearnExtension + extension = SklearnExtension() with parallel_backend(backend, n_jobs=n_jobs): res = openml.runs.functions._run_task_get_arffcontent( @@ -1912,11 +2017,11 @@ def test__run_task_get_arffcontent_2(parallel_mock): ) -@pytest.mark.sklearn() +@pytest.mark.sklearn @unittest.skipIf( Version(sklearn.__version__) < Version("0.21"), reason="couldn't perform local tests successfully w/o bloating RAM", - ) +) @mock.patch("openml_sklearn.SklearnExtension._prevent_optimize_n_jobs") @pytest.mark.parametrize( ("n_jobs", "backend", "call_count"), @@ -1929,17 +2034,16 @@ def test__run_task_get_arffcontent_2(parallel_mock): (1, "sequential", 10), (1, "threading", 10), (-1, "threading", 10), # the threading backend does preserve mocks even with parallelizing - ] + ], ) +@pytest.mark.test_server def test_joblib_backends(parallel_mock, n_jobs, backend, call_count): """Tests evaluation of a run using various joblib backends and n_jobs.""" if backend is None: - backend = ( - "loky" if Version(joblib.__version__) > Version("0.11") else "multiprocessing" - ) + backend = "loky" if Version(joblib.__version__) > Version("0.11") else "multiprocessing" task = openml.tasks.get_task(7) # Supervised Classification on kr-vs-kp - x, y = task.get_X_and_y() + x, _y = task.get_X_and_y() num_instances = x.shape[0] line_length = 6 + len(task.class_labels) @@ -1980,6 +2084,7 @@ def test_joblib_backends(parallel_mock, n_jobs, backend, call_count): n_jobs=n_jobs, ) from openml_sklearn import SklearnExtension + extension = SklearnExtension() with parallel_backend(backend, n_jobs=n_jobs): res = openml.runs.functions._run_task_get_arffcontent( @@ -1989,7 +2094,7 @@ def test_joblib_backends(parallel_mock, n_jobs, backend, call_count): add_local_measures=True, n_jobs=n_jobs, ) - assert type(res[0]) == list + assert type(res[0]) == list # noqa: E721 assert len(res[0]) == num_instances assert len(res[0][0]) == line_length # usercpu_time_millis_* not recorded when n_jobs > 1 diff --git a/tests/test_runs/test_trace.py b/tests/test_runs/test_trace.py index bdf9de42d..bc2439a3d 100644 --- a/tests/test_runs/test_trace.py +++ b/tests/test_runs/test_trace.py @@ -17,7 +17,7 @@ def test_get_selected_iteration(self): repeat=i, fold=j, iteration=5, - setup_string="parameter_%d%d%d" % (i, j, k), + setup_string=f"parameter_{i}{j}{k}", evaluation=1.0 * i + 0.1 * j + 0.01 * k, selected=(i == j and i == k and i == 2), parameters=None, @@ -34,13 +34,13 @@ def test_get_selected_iteration(self): def test_initialization(self): """Check all different ways to fail the initialization""" - with pytest.raises(ValueError, match="Trace content not available."): + with pytest.raises(ValueError, match=r"Trace content not available."): OpenMLRunTrace.generate(attributes="foo", content=None) - with pytest.raises(ValueError, match="Trace attributes not available."): + with pytest.raises(ValueError, match=r"Trace attributes not available."): OpenMLRunTrace.generate(attributes=None, content="foo") - with pytest.raises(ValueError, match="Trace content is empty."): + with pytest.raises(ValueError, match=r"Trace content is empty."): OpenMLRunTrace.generate(attributes="foo", content=[]) - with pytest.raises(ValueError, match="Trace_attributes and trace_content not compatible:"): + with pytest.raises(ValueError, match=r"Trace_attributes and trace_content not compatible:"): OpenMLRunTrace.generate(attributes=["abc"], content=[[1, 2]]) def test_duplicate_name(self): @@ -57,7 +57,7 @@ def test_duplicate_name(self): trace_content = [[0, 0, 0, 0.5, "true", 1], [0, 0, 0, 0.9, "false", 2]] with pytest.raises( ValueError, - match="Either `setup_string` or `parameters` needs to be passed as argument.", + match=r"Either `setup_string` or `parameters` needs to be passed as argument.", ): OpenMLRunTrace.generate(trace_attributes, trace_content) diff --git a/tests/test_setups/test_setup_functions.py b/tests/test_setups/test_setup_functions.py index 6fd11638f..1b57cf3f0 100644 --- a/tests/test_setups/test_setup_functions.py +++ b/tests/test_setups/test_setup_functions.py @@ -7,7 +7,6 @@ import pandas as pd import pytest -import sklearn.base import sklearn.naive_bayes import sklearn.tree from openml_sklearn import SklearnExtension @@ -21,165 +20,176 @@ def get_sentinel(): # Create a unique prefix for the flow. Necessary because the flow is # identified by its name and external version online. Having a unique # name allows us to publish the same flow in each test run - md5 = hashlib.md5() + md5 = hashlib.md5() # noqa: S324 md5.update(str(time.time()).encode("utf-8")) sentinel = md5.hexdigest()[:10] return f"TEST{sentinel}" -class TestSetupFunctions(TestBase): - _multiprocess_can_split_ = True - - def setUp(self): - self.extension = SklearnExtension() - super().setUp() - - @pytest.mark.sklearn() - def test_nonexisting_setup_exists(self): - # first publish a non-existing flow - sentinel = get_sentinel() - # because of the sentinel, we can not use flows that contain subflows - dectree = sklearn.tree.DecisionTreeClassifier() - flow = self.extension.model_to_flow(dectree) - flow.name = f"TEST{sentinel}{flow.name}" - flow.publish() - TestBase._mark_entity_for_removal("flow", flow.flow_id, flow.name) - TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {flow.flow_id}") - - # although the flow exists (created as of previous statement), - # we can be sure there are no setups (yet) as it was just created - # and hasn't been ran - setup_id = openml.setups.setup_exists(flow) - assert not setup_id - - def _existing_setup_exists(self, classif): - flow = self.extension.model_to_flow(classif) - flow.name = f"TEST{get_sentinel()}{flow.name}" - flow.publish() - TestBase._mark_entity_for_removal("flow", flow.flow_id, flow.name) - TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {flow.flow_id}") - - # although the flow exists, we can be sure there are no - # setups (yet) as it hasn't been ran - setup_id = openml.setups.setup_exists(flow) - assert not setup_id - setup_id = openml.setups.setup_exists(flow) - assert not setup_id - - # now run the flow on an easy task: - task = openml.tasks.get_task(115) # diabetes; crossvalidation - run = openml.runs.run_flow_on_task(flow, task) - # spoof flow id, otherwise the sentinel is ignored - run.flow_id = flow.flow_id - run.publish() - TestBase._mark_entity_for_removal("run", run.run_id) - TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {run.run_id}") - # download the run, as it contains the right setup id - run = openml.runs.get_run(run.run_id) - - # execute the function we are interested in - setup_id = openml.setups.setup_exists(flow) - assert setup_id == run.setup_id - - @pytest.mark.sklearn() - def test_existing_setup_exists_1(self): - def side_effect(self): - self.var_smoothing = 1e-9 - self.priors = None - - with unittest.mock.patch.object( - sklearn.naive_bayes.GaussianNB, - "__init__", - side_effect, - ): - # Check a flow with zero hyperparameters - nb = sklearn.naive_bayes.GaussianNB() - self._existing_setup_exists(nb) - - @pytest.mark.sklearn() - def test_exisiting_setup_exists_2(self): - # Check a flow with one hyperparameter - self._existing_setup_exists(sklearn.naive_bayes.GaussianNB()) - - @pytest.mark.sklearn() - def test_existing_setup_exists_3(self): - # Check a flow with many hyperparameters - self._existing_setup_exists( - sklearn.tree.DecisionTreeClassifier( - max_depth=5, - min_samples_split=3, - # Not setting the random state will make this flow fail as running it - # will add a random random_state. - random_state=1, - ), - ) - - @pytest.mark.production() - def test_get_setup(self): - # no setups in default test server - openml.config.server = "https://www.openml.org/api/v1/xml/" - - # contains all special cases, 0 params, 1 param, n params. - # Non scikitlearn flows. - setups = [18, 19, 20, 118] - num_params = [8, 0, 3, 1] - - for idx in range(len(setups)): - current = openml.setups.get_setup(setups[idx]) - assert current.flow_id > 0 - if num_params[idx] == 0: - assert current.parameters is None - else: - assert len(current.parameters) == num_params[idx] - - @pytest.mark.production() - def test_setup_list_filter_flow(self): - self.use_production_server() - - flow_id = 5873 - - setups = openml.setups.list_setups(flow=flow_id) - - assert len(setups) > 0 # TODO: please adjust 0 - for setup_id in setups: - assert setups[setup_id].flow_id == flow_id - - def test_list_setups_empty(self): - setups = openml.setups.list_setups(setup=[0]) - if len(setups) > 0: - raise ValueError("UnitTest Outdated, got somehow results") - - assert isinstance(setups, dict) - - @pytest.mark.production() - def test_list_setups_output_format(self): - self.use_production_server() - flow_id = 6794 - setups = openml.setups.list_setups(flow=flow_id, size=10) - assert isinstance(setups, dict) - assert isinstance(setups[next(iter(setups.keys()))], openml.setups.setup.OpenMLSetup) - assert len(setups) == 10 - - setups = openml.setups.list_setups(flow=flow_id, size=10, output_format="dataframe") - assert isinstance(setups, pd.DataFrame) - assert len(setups) == 10 - - def test_setuplist_offset(self): - size = 10 - setups = openml.setups.list_setups(offset=0, size=size) - assert len(setups) == size - setups2 = openml.setups.list_setups(offset=size, size=size) - assert len(setups2) == size - - all = set(setups.keys()).union(setups2.keys()) - - assert len(all) == size * 2 - - def test_get_cached_setup(self): - openml.config.set_root_cache_directory(self.static_cache_dir) - openml.setups.functions._get_cached_setup(1) - - def test_get_uncached_setup(self): - openml.config.set_root_cache_directory(self.static_cache_dir) - with pytest.raises(openml.exceptions.OpenMLCacheException): - openml.setups.functions._get_cached_setup(10) +@pytest.fixture +def sklearn_extension(): + """Fixture to provide SklearnExtension instance.""" + return SklearnExtension() + + +def _existing_setup_exists(sklearn_extension, classif): + """Helper function to test existing setup exists.""" + flow = sklearn_extension.model_to_flow(classif) + flow.name = f"TEST{get_sentinel()}{flow.name}" + flow.publish() + TestBase._mark_entity_for_removal("flow", flow.flow_id, flow.name) + TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {flow.flow_id}") + + # although the flow exists, we can be sure there are no + # setups (yet) as it hasn't been ran + setup_id = openml.setups.setup_exists(flow) + assert not setup_id + + # now run the flow on an easy task: + task = openml.tasks.get_task(115) # diabetes; crossvalidation + run = openml.runs.run_flow_on_task(flow, task) + # spoof flow id, otherwise the sentinel is ignored + run.flow_id = flow.flow_id + run.publish() + TestBase._mark_entity_for_removal("run", run.run_id) + TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {run.run_id}") + # download the run, as it contains the right setup id + run = openml.runs.get_run(run.run_id) + + # execute the function we are interested in + setup_id = openml.setups.setup_exists(flow) + assert setup_id == run.setup_id + + +@pytest.mark.sklearn +@pytest.mark.test_server +def test_nonexisting_setup_exists(sklearn_extension): + # first publish a non-existing flow + sentinel = get_sentinel() + # because of the sentinel, we can not use flows that contain subflows + dectree = sklearn.tree.DecisionTreeClassifier() + flow = sklearn_extension.model_to_flow(dectree) + flow.name = f"TEST{sentinel}{flow.name}" + flow.publish() + TestBase._mark_entity_for_removal("flow", flow.flow_id, flow.name) + TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {flow.flow_id}") + + # although the flow exists (created as of previous statement), + # we can be sure there are no setups (yet) as it was just created + # and hasn't been ran + setup_id = openml.setups.setup_exists(flow) + assert not setup_id + + +@pytest.mark.test_server +@pytest.mark.sklearn +def test_existing_setup_exists_1(sklearn_extension): + def side_effect(self): + self.var_smoothing = 1e-9 + self.priors = None + + with unittest.mock.patch.object( + sklearn.naive_bayes.GaussianNB, + "__init__", + side_effect, + ): + # Check a flow with zero hyperparameters + nb = sklearn.naive_bayes.GaussianNB() + _existing_setup_exists(sklearn_extension, nb) + + +@pytest.mark.test_server +@pytest.mark.sklearn +def test_exisiting_setup_exists_2(sklearn_extension): + # Check a flow with one hyperparameter + _existing_setup_exists(sklearn_extension, sklearn.naive_bayes.GaussianNB()) + + +@pytest.mark.test_server +@pytest.mark.sklearn +def test_existing_setup_exists_3(sklearn_extension): + # Check a flow with many hyperparameters + _existing_setup_exists( + sklearn_extension, + sklearn.tree.DecisionTreeClassifier( + max_depth=5, + min_samples_split=3, + # Not setting the random state will make this flow fail as running it + # will add a random random_state. + random_state=1, + ), + ) + + +@pytest.mark.production_server +@pytest.mark.parametrize( + ("setup_id", "num_params"), + # contains all special cases, 0 params, 1 param, n params. + # Non scikitlearn flows. + [(18, 8), (19, 0), (20, 3), (118, 1)], +) +def test_get_setup(setup_id: int, num_params: int): + # no setups in default test server + openml.config.server = "https://www.openml.org/api/v1/xml/" + + current = openml.setups.get_setup(setup_id) + assert current.flow_id > 0 + if num_params == 0: + assert current.parameters is None + else: + assert len(current.parameters) == num_params + + +@pytest.mark.test_server +def test_get_cached_setup(test_files_directory): + openml.config.set_root_cache_directory(test_files_directory) + + with unittest.mock.patch("requests.sessions.Session.request") as mock_request: + setup = openml.setups.get_setup(1) + mock_request.assert_not_called() + assert setup is not None + + +@pytest.mark.production_server +def test_setup_list_filter_flow(): + openml.config.server = "https://www.openml.org/api/v1/xml/" + flow_id = 5873 + setups = openml.setups.list_setups(flow=flow_id) + + assert len(setups) > 0 # TODO: please adjust 0 + for setup_id in setups: + assert setups[setup_id].flow_id == flow_id + + +@pytest.mark.test_server +def test_list_setups_empty(): + setups = openml.setups.list_setups(setup=[0]) + assert not setups + assert isinstance(setups, dict) + + +@pytest.mark.production_server +def test_list_setups_output_format(): + openml.config.server = "https://www.openml.org/api/v1/xml/" + flow_id = 6794 + setups = openml.setups.list_setups(flow=flow_id, size=10) + assert isinstance(setups, dict) + assert isinstance(setups[next(iter(setups.keys()))], openml.setups.setup.OpenMLSetup) + assert len(setups) == 10 + + setups = openml.setups.list_setups(flow=flow_id, size=10, output_format="dataframe") + assert isinstance(setups, pd.DataFrame) + assert len(setups) == 10 + + +@pytest.mark.production_server +def test_setuplist_offset(): + size = 10 + setups = openml.setups.list_setups(offset=0, size=size) + assert len(setups) == size + setups2 = openml.setups.list_setups(offset=size, size=size) + assert len(setups2) == size + + all_setups = set(setups.keys()).union(setups2.keys()) + + assert len(all_setups) == size * 2 diff --git a/tests/test_study/test_study_functions.py b/tests/test_study/test_study_functions.py index 40026592f..cd7e6acab 100644 --- a/tests/test_study/test_study_functions.py +++ b/tests/test_study/test_study_functions.py @@ -2,257 +2,250 @@ from __future__ import annotations import pytest -import unittest import openml import openml.study from openml.testing import TestBase -class TestStudyFunctions(TestBase): - _multiprocess_can_split_ = True - - @pytest.mark.production() - def test_get_study_old(self): - self.use_production_server() - - study = openml.study.get_study(34) - assert len(study.data) == 105 - assert len(study.tasks) == 105 - assert len(study.flows) == 27 - assert len(study.setups) == 30 - assert study.runs is None - - @pytest.mark.production() - def test_get_study_new(self): - self.use_production_server() - - study = openml.study.get_study(123) - assert len(study.data) == 299 - assert len(study.tasks) == 299 - assert len(study.flows) == 5 - assert len(study.setups) == 1253 - assert len(study.runs) == 1693 - - @pytest.mark.production() - def test_get_openml100(self): - self.use_production_server() - - study = openml.study.get_study("OpenML100", "tasks") - assert isinstance(study, openml.study.OpenMLBenchmarkSuite) - study_2 = openml.study.get_suite("OpenML100") - assert isinstance(study_2, openml.study.OpenMLBenchmarkSuite) - assert study.study_id == study_2.study_id - - @pytest.mark.production() - def test_get_study_error(self): - self.use_production_server() - - with pytest.raises( - ValueError, match="Unexpected entity type 'task' reported by the server, expected 'run'" - ): - openml.study.get_study(99) - - @pytest.mark.production() - def test_get_suite(self): - self.use_production_server() - - study = openml.study.get_suite(99) - assert len(study.data) == 72 - assert len(study.tasks) == 72 - assert study.flows is None - assert study.runs is None - assert study.setups is None - - @pytest.mark.production() - def test_get_suite_error(self): - self.use_production_server() - - with pytest.raises( - ValueError, match="Unexpected entity type 'run' reported by the server, expected 'task'" - ): - openml.study.get_suite(123) - - def test_publish_benchmark_suite(self): - fixture_alias = None - fixture_name = "unit tested benchmark suite" - fixture_descr = "bla" - fixture_task_ids = [1, 2, 3] - - study = openml.study.create_benchmark_suite( - alias=fixture_alias, - name=fixture_name, - description=fixture_descr, - task_ids=fixture_task_ids, - ) - study.publish() - TestBase._mark_entity_for_removal("study", study.id) - TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {study.id}") - - assert study.id > 0 - - # verify main meta data - study_downloaded = openml.study.get_suite(study.id) - assert study_downloaded.alias == fixture_alias - assert study_downloaded.name == fixture_name - assert study_downloaded.description == fixture_descr - assert study_downloaded.main_entity_type == "task" - # verify resources - assert study_downloaded.flows is None - assert study_downloaded.setups is None - assert study_downloaded.runs is None - assert len(study_downloaded.data) > 0 - assert len(study_downloaded.data) <= len(fixture_task_ids) - self.assertSetEqual(set(study_downloaded.tasks), set(fixture_task_ids)) - - # attach more tasks - tasks_additional = [4, 5, 6] - openml.study.attach_to_study(study.id, tasks_additional) - study_downloaded = openml.study.get_suite(study.id) - # verify again - self.assertSetEqual(set(study_downloaded.tasks), set(fixture_task_ids + tasks_additional)) - # test detach function - openml.study.detach_from_study(study.id, fixture_task_ids) - study_downloaded = openml.study.get_suite(study.id) - self.assertSetEqual(set(study_downloaded.tasks), set(tasks_additional)) - - # test status update function - openml.study.update_suite_status(study.id, "deactivated") - study_downloaded = openml.study.get_suite(study.id) - assert study_downloaded.status == "deactivated" - # can't delete study, now it's not longer in preparation - - def _test_publish_empty_study_is_allowed(self, explicit: bool): - runs: list[int] | None = [] if explicit else None - kind = "explicit" if explicit else "implicit" - - study = openml.study.create_study( - name=f"empty-study-{kind}", - description=f"a study with no runs attached {kind}ly", - run_ids=runs, - ) - - study.publish() - TestBase._mark_entity_for_removal("study", study.id) - TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {study.id}") - - assert study.id > 0 - study_downloaded = openml.study.get_study(study.id) - assert study_downloaded.main_entity_type == "run" - assert study_downloaded.runs is None - - def test_publish_empty_study_explicit(self): - self._test_publish_empty_study_is_allowed(explicit=True) - - def test_publish_empty_study_implicit(self): - self._test_publish_empty_study_is_allowed(explicit=False) - - @pytest.mark.flaky() - def test_publish_study(self): - # get some random runs to attach - run_list = openml.evaluations.list_evaluations("predictive_accuracy", size=10) - assert len(run_list) == 10 - - fixt_alias = None - fixt_name = "unit tested study" - fixt_descr = "bla" - fixt_flow_ids = {evaluation.flow_id for evaluation in run_list.values()} - fixt_task_ids = {evaluation.task_id for evaluation in run_list.values()} - fixt_setup_ids = {evaluation.setup_id for evaluation in run_list.values()} - - study = openml.study.create_study( - alias=fixt_alias, - benchmark_suite=None, - name=fixt_name, - description=fixt_descr, - run_ids=list(run_list.keys()), - ) - study.publish() - TestBase._mark_entity_for_removal("study", study.id) - TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {study.id}") - assert study.id > 0 - study_downloaded = openml.study.get_study(study.id) - assert study_downloaded.alias == fixt_alias - assert study_downloaded.name == fixt_name - assert study_downloaded.description == fixt_descr - assert study_downloaded.main_entity_type == "run" - - self.assertSetEqual(set(study_downloaded.runs), set(run_list.keys())) - self.assertSetEqual(set(study_downloaded.setups), set(fixt_setup_ids)) - self.assertSetEqual(set(study_downloaded.flows), set(fixt_flow_ids)) - self.assertSetEqual(set(study_downloaded.tasks), set(fixt_task_ids)) - - # test whether the list run function also handles study data fine - run_ids = openml.runs.list_runs(study=study.id) # returns DF - self.assertSetEqual(set(run_ids["run_id"]), set(study_downloaded.runs)) - - # test whether the list evaluation function also handles study data fine - run_ids = openml.evaluations.list_evaluations( # returns list of objects - "predictive_accuracy", - size=None, - study=study.id, - output_format="object", # making the default explicit - ) - self.assertSetEqual(set(run_ids), set(study_downloaded.runs)) - - # attach more runs, since we fetch 11 here, at least one is non-overlapping - run_list_additional = openml.runs.list_runs(size=11, offset=10) - run_list_additional = set(run_list_additional["run_id"]) - set(run_ids) - openml.study.attach_to_study(study.id, list(run_list_additional)) - study_downloaded = openml.study.get_study(study.id) - # verify again - all_run_ids = run_list_additional | set(run_list.keys()) - self.assertSetEqual(set(study_downloaded.runs), all_run_ids) - - # test detach function - openml.study.detach_from_study(study.id, list(run_list.keys())) - study_downloaded = openml.study.get_study(study.id) - self.assertSetEqual(set(study_downloaded.runs), run_list_additional) - - # test status update function - openml.study.update_study_status(study.id, "deactivated") - study_downloaded = openml.study.get_study(study.id) - assert study_downloaded.status == "deactivated" - - res = openml.study.delete_study(study.id) - assert res - - def test_study_attach_illegal(self): - run_list = openml.runs.list_runs(size=10) - assert len(run_list) == 10 - run_list_more = openml.runs.list_runs(size=20) - assert len(run_list_more) == 20 - - study = openml.study.create_study( - alias=None, - benchmark_suite=None, - name="study with illegal runs", - description="none", - run_ids=list(run_list["run_id"]), - ) - study.publish() - TestBase._mark_entity_for_removal("study", study.id) - TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {study.id}") - study_original = openml.study.get_study(study.id) - - with pytest.raises( - openml.exceptions.OpenMLServerException, - match="Problem attaching entities.", - ): - # run id does not exists - openml.study.attach_to_study(study.id, [0]) - - with pytest.raises( - openml.exceptions.OpenMLServerException, - match="Problem attaching entities.", - ): - # some runs already attached - openml.study.attach_to_study(study.id, list(run_list_more["run_id"])) - study_downloaded = openml.study.get_study(study.id) - self.assertListEqual(study_original.runs, study_downloaded.runs) - - @unittest.skip("It is unclear when we can expect the test to pass or fail.") - def test_study_list(self): - study_list = openml.study.list_studies(status="in_preparation") - # might fail if server is recently reset - assert len(study_list) >= 2 +@pytest.mark.production_server +@pytest.mark.xfail(reason="failures_issue_1544", strict=False) +def test_get_study_old(): + study = openml.study.get_study(34) + assert len(study.data) == 105 + assert len(study.tasks) == 105 + assert len(study.flows) == 27 + assert len(study.setups) == 30 + assert study.runs is None + + +@pytest.mark.production_server +def test_get_study_new(): + study = openml.study.get_study(123) + assert len(study.data) == 299 + assert len(study.tasks) == 299 + assert len(study.flows) == 5 + assert len(study.setups) == 1253 + assert len(study.runs) == 1693 + + +@pytest.mark.production_server +def test_get_openml100(): + study = openml.study.get_study("OpenML100", "tasks") + assert isinstance(study, openml.study.OpenMLBenchmarkSuite) + study_2 = openml.study.get_suite("OpenML100") + assert isinstance(study_2, openml.study.OpenMLBenchmarkSuite) + assert study.study_id == study_2.study_id + + +@pytest.mark.production_server +def test_get_study_error(): + with pytest.raises( + ValueError, match=r"Unexpected entity type 'task' reported by the server, expected 'run'" + ): + openml.study.get_study(99) + + +@pytest.mark.production_server +def test_get_suite(): + study = openml.study.get_suite(99) + assert len(study.data) == 72 + assert len(study.tasks) == 72 + assert study.flows is None + assert study.runs is None + assert study.setups is None + + +@pytest.mark.production_server +def test_get_suite_error(): + with pytest.raises( + ValueError, match=r"Unexpected entity type 'run' reported by the server, expected 'task'" + ): + openml.study.get_suite(123) + + +@pytest.mark.test_server +def test_publish_benchmark_suite(): + fixture_alias = None + fixture_name = "unit tested benchmark suite" + fixture_descr = "bla" + fixture_task_ids = [1, 2, 3] + + study = openml.study.create_benchmark_suite( + alias=fixture_alias, + name=fixture_name, + description=fixture_descr, + task_ids=fixture_task_ids, + ) + study.publish() + TestBase._mark_entity_for_removal("study", study.id) + TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {study.id}") + + assert study.id > 0 + + # verify main meta data + study_downloaded = openml.study.get_suite(study.id) + assert study_downloaded.alias == fixture_alias + assert study_downloaded.name == fixture_name + assert study_downloaded.description == fixture_descr + assert study_downloaded.main_entity_type == "task" + # verify resources + assert study_downloaded.flows is None + assert study_downloaded.setups is None + assert study_downloaded.runs is None + assert len(study_downloaded.data) > 0 + assert len(study_downloaded.data) <= len(fixture_task_ids) + assert set(study_downloaded.tasks) == set(fixture_task_ids) + + # attach more tasks + tasks_additional = [4, 5, 6] + openml.study.attach_to_study(study.id, tasks_additional) + study_downloaded = openml.study.get_suite(study.id) + # verify again + assert set(study_downloaded.tasks) == set(fixture_task_ids + tasks_additional) + # test detach function + openml.study.detach_from_study(study.id, fixture_task_ids) + study_downloaded = openml.study.get_suite(study.id) + assert set(study_downloaded.tasks) == set(tasks_additional) + + # test status update function + openml.study.update_suite_status(study.id, "deactivated") + study_downloaded = openml.study.get_suite(study.id) + assert study_downloaded.status == "deactivated" + # can't delete study, now it's not longer in preparation + + +@pytest.mark.test_server +@pytest.mark.parametrize("explicit", [True, False]) +def _test_publish_empty_study_is_allowed(explicit: bool): + runs: list[int] | None = [] if explicit else None + kind = "explicit" if explicit else "implicit" + study = openml.study.create_study( + name=f"empty-study-{kind}", + description=f"a study with no runs attached {kind}ly", + run_ids=runs, + ) + + study.publish() + TestBase._mark_entity_for_removal("study", study.id) + TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {study.id}") + + assert study.id > 0 + study_downloaded = openml.study.get_study(study.id) + assert study_downloaded.main_entity_type == "run" + assert study_downloaded.runs is None + + +@pytest.mark.skip(reason="Affected by lowered result limit #1757") +@pytest.mark.flaky +@pytest.mark.test_server +def test_publish_study(): + # get some random runs to attach + run_list = openml.evaluations.list_evaluations("predictive_accuracy", size=10) + assert len(run_list) == 10 + + fixt_alias = None + fixt_name = "unit tested study" + fixt_descr = "bla" + fixt_flow_ids = {evaluation.flow_id for evaluation in run_list.values()} + fixt_task_ids = {evaluation.task_id for evaluation in run_list.values()} + fixt_setup_ids = {evaluation.setup_id for evaluation in run_list.values()} + + study = openml.study.create_study( + alias=fixt_alias, + benchmark_suite=None, + name=fixt_name, + description=fixt_descr, + run_ids=list(run_list.keys()), + ) + study.publish() + TestBase._mark_entity_for_removal("study", study.id) + TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {study.id}") + assert study.id > 0 + study_downloaded = openml.study.get_study(study.id) + assert study_downloaded.alias == fixt_alias + assert study_downloaded.name == fixt_name + assert study_downloaded.description == fixt_descr + assert study_downloaded.main_entity_type == "run" + + assert set(study_downloaded.runs) == set(run_list.keys()) + assert set(study_downloaded.setups) == set(fixt_setup_ids) + assert set(study_downloaded.flows) == set(fixt_flow_ids) + assert set(study_downloaded.tasks) == set(fixt_task_ids) + + # test whether the list run function also handles study data fine + run_ids = openml.runs.list_runs(study=study.id) # returns DF + assert set(run_ids["run_id"]) == set(study_downloaded.runs) + + # test whether the list evaluation function also handles study data fine + run_ids = openml.evaluations.list_evaluations( # returns list of objects + "predictive_accuracy", + size=None, + study=study.id, + output_format="object", # making the default explicit + ) + assert set(run_ids) == set(study_downloaded.runs) + + # attach more runs, since we fetch 11 here, at least one is non-overlapping + run_list_additional = openml.runs.list_runs(size=11, offset=10) + run_list_additional = set(run_list_additional["run_id"]) - set(run_ids) + openml.study.attach_to_study(study.id, list(run_list_additional)) + study_downloaded = openml.study.get_study(study.id) + # verify again + all_run_ids = run_list_additional | set(run_list.keys()) + assert set(study_downloaded.runs), set(all_run_ids) + + # test detach function + openml.study.detach_from_study(study.id, list(run_list.keys())) + study_downloaded = openml.study.get_study(study.id) + assert set(study_downloaded.runs) == set(run_list_additional) + + # test status update function + openml.study.update_study_status(study.id, "deactivated") + study_downloaded = openml.study.get_study(study.id) + assert study_downloaded.status == "deactivated" + + res = openml.study.delete_study(study.id) + assert res + + +def test_study_attach_illegal(): + run_list = openml.runs.list_runs(size=10) + assert len(run_list) == 10 + run_list_more = openml.runs.list_runs(size=20) + assert len(run_list_more) == 20 + + study = openml.study.create_study( + alias=None, + benchmark_suite=None, + name="study with illegal runs", + description="none", + run_ids=list(run_list["run_id"]), + ) + study.publish() + TestBase._mark_entity_for_removal("study", study.id) + TestBase.logger.info(f"collected from {__file__.split('/')[-1]}: {study.id}") + study_original = openml.study.get_study(study.id) + + with pytest.raises( + openml.exceptions.OpenMLServerException, + match=r"Problem attaching entities.", + ): + # run id does not exists + openml.study.attach_to_study(study.id, [0]) + + with pytest.raises( + openml.exceptions.OpenMLServerException, + match=r"Problem attaching entities.", + ): + # some runs already attached + openml.study.attach_to_study(study.id, list(run_list_more["run_id"])) + study_downloaded = openml.study.get_study(study.id) + assert set(study_original.runs) == set(study_downloaded.runs) + + +@pytest.mark.skip(reason="It is unclear when we can expect the test to pass or fail.") +def test_study_list(): + study_list = openml.study.list_studies(status="in_preparation") + # might fail if server is recently reset + assert len(study_list) >= 2 diff --git a/tests/test_tasks/__init__.py b/tests/test_tasks/__init__.py index 26488a8cc..e6691e848 100644 --- a/tests/test_tasks/__init__.py +++ b/tests/test_tasks/__init__.py @@ -4,6 +4,6 @@ from .test_task import OpenMLTaskTest __all__ = [ - "OpenMLTaskTest", "OpenMLSupervisedTaskTest", + "OpenMLTaskTest", ] diff --git a/tests/test_tasks/test_classification_task.py b/tests/test_tasks/test_classification_task.py index d4f2ed9d7..592b42a5e 100644 --- a/tests/test_tasks/test_classification_task.py +++ b/tests/test_tasks/test_classification_task.py @@ -12,12 +12,13 @@ class OpenMLClassificationTaskTest(OpenMLSupervisedTaskTest): __test__ = True - def setUp(self, n_levels: int = 1): + def setUp(self, n_levels: int = 1): # noqa: ARG002 super().setUp() self.task_id = 119 # diabetes self.task_type = TaskType.SUPERVISED_CLASSIFICATION self.estimation_procedure = 5 + @pytest.mark.test_server def test_download_task(self): task = super().test_download_task() assert task.task_id == self.task_id @@ -25,12 +26,13 @@ def test_download_task(self): assert task.dataset_id == 20 assert task.estimation_procedure_id == self.estimation_procedure + @pytest.mark.test_server def test_class_labels(self): task = get_task(self.task_id) assert task.class_labels == ["tested_negative", "tested_positive"] -@pytest.mark.server() +@pytest.mark.test_server def test_get_X_and_Y(): task = get_task(119) X, Y = task.get_X_and_y() diff --git a/tests/test_tasks/test_clustering_task.py b/tests/test_tasks/test_clustering_task.py index dcc024388..cfa74e862 100644 --- a/tests/test_tasks/test_clustering_task.py +++ b/tests/test_tasks/test_clustering_task.py @@ -14,20 +14,21 @@ class OpenMLClusteringTaskTest(OpenMLTaskTest): __test__ = True - def setUp(self, n_levels: int = 1): + def setUp(self, n_levels: int = 1): # noqa: ARG002 super().setUp() self.task_id = 146714 self.task_type = TaskType.CLUSTERING self.estimation_procedure = 17 - @pytest.mark.production() + @pytest.mark.production_server def test_get_dataset(self): # no clustering tasks on test server self.use_production_server() task = openml.tasks.get_task(self.task_id) task.get_dataset() - @pytest.mark.production() + @pytest.mark.production_server + @pytest.mark.test_server def test_download_task(self): # no clustering tasks on test server self.use_production_server() @@ -36,6 +37,7 @@ def test_download_task(self): assert task.task_type_id == TaskType.CLUSTERING assert task.dataset_id == 36 + @pytest.mark.test_server def test_upload_task(self): compatible_datasets = self._get_compatible_rand_dataset() for i in range(100): @@ -57,11 +59,10 @@ def test_upload_task(self): except OpenMLServerException as e: # Error code for 'task already exists' # Should be 533 according to the docs - # (# https://www.openml.org/api_docs#!/task/post_task) + # (https://www.openml.org/api_docs#!/task/post_task) if e.code == 614: continue - else: - raise e + raise e else: raise ValueError( f"Could not create a valid task for task type ID {self.task_type}", diff --git a/tests/test_tasks/test_learning_curve_task.py b/tests/test_tasks/test_learning_curve_task.py index 885f80a27..a44feff85 100644 --- a/tests/test_tasks/test_learning_curve_task.py +++ b/tests/test_tasks/test_learning_curve_task.py @@ -2,6 +2,7 @@ from __future__ import annotations import pandas as pd +import pytest from openml.tasks import TaskType, get_task @@ -11,12 +12,13 @@ class OpenMLLearningCurveTaskTest(OpenMLSupervisedTaskTest): __test__ = True - def setUp(self, n_levels: int = 1): + def setUp(self, n_levels: int = 1): # noqa: ARG002 super().setUp() self.task_id = 801 # diabetes self.task_type = TaskType.LEARNING_CURVE self.estimation_procedure = 13 + @pytest.mark.test_server def test_get_X_and_Y(self): X, Y = super().test_get_X_and_Y() assert X.shape == (768, 8) @@ -25,12 +27,14 @@ def test_get_X_and_Y(self): assert isinstance(Y, pd.Series) assert pd.api.types.is_categorical_dtype(Y) + @pytest.mark.test_server def test_download_task(self): task = super().test_download_task() assert task.task_id == self.task_id assert task.task_type_id == TaskType.LEARNING_CURVE assert task.dataset_id == 20 + @pytest.mark.test_server def test_class_labels(self): task = get_task(self.task_id) assert task.class_labels == ["tested_negative", "tested_positive"] diff --git a/tests/test_tasks/test_regression_task.py b/tests/test_tasks/test_regression_task.py index 14ed59470..97a317cc2 100644 --- a/tests/test_tasks/test_regression_task.py +++ b/tests/test_tasks/test_regression_task.py @@ -4,6 +4,7 @@ import ast import pandas as pd +import pytest import openml from openml.exceptions import OpenMLServerException @@ -16,13 +17,13 @@ class OpenMLRegressionTaskTest(OpenMLSupervisedTaskTest): __test__ = True - def setUp(self, n_levels: int = 1): + def setUp(self, n_levels: int = 1): # noqa: ARG002 super().setUp() self.estimation_procedure = 9 task_meta_data = { "task_type": TaskType.SUPERVISED_REGRESSION, "dataset_id": 105, # wisconsin - "estimation_procedure_id": self.estimation_procedure, # non default value to test estimation procedure id + "estimation_procedure_id": self.estimation_procedure, # non default value to test estimation procedure id # noqa: E501 "target_name": "time", } _task_id = check_task_existence(**task_meta_data) @@ -40,14 +41,14 @@ def setUp(self, n_levels: int = 1): except OpenMLServerException as e: if e.code == 614: # Task already exists # the exception message contains the task_id that was matched in the format - # 'Task already exists. - matched id(s): [xxxx]' + # 'Task already exists. - matched id(s): [xxxx]' # noqa: ERA001 task_id = ast.literal_eval(e.message.split("matched id(s):")[-1].strip())[0] else: - raise Exception(repr(e)) + raise Exception(repr(e)) # noqa: B904 self.task_id = task_id self.task_type = TaskType.SUPERVISED_REGRESSION - + @pytest.mark.test_server def test_get_X_and_Y(self): X, Y = super().test_get_X_and_Y() assert X.shape == (194, 32) @@ -56,6 +57,7 @@ def test_get_X_and_Y(self): assert isinstance(Y, pd.Series) assert pd.api.types.is_numeric_dtype(Y) + @pytest.mark.test_server def test_download_task(self): task = super().test_download_task() assert task.task_id == self.task_id diff --git a/tests/test_tasks/test_split.py b/tests/test_tasks/test_split.py index 12cb632d9..8e7f09439 100644 --- a/tests/test_tasks/test_split.py +++ b/tests/test_tasks/test_split.py @@ -1,8 +1,11 @@ # License: BSD 3-Clause from __future__ import annotations +import contextlib import inspect import os +import shutil +import tempfile from pathlib import Path import numpy as np @@ -17,9 +20,8 @@ class OpenMLSplitTest(TestBase): def setUp(self): __file__ = inspect.getfile(OpenMLSplitTest) - self.directory = os.path.dirname(__file__) - # This is for dataset - self.arff_filepath = ( + self.directory = os.path.dirname(__file__) # noqa: PTH120 + source_arff = ( Path(self.directory).parent / "files" / "org" @@ -29,18 +31,21 @@ def setUp(self): / "1882" / "datasplits.arff" ) + # Use a unique temp directory for each test to avoid race conditions + # when running tests in parallel (see issue #1641) + self._temp_dir = tempfile.TemporaryDirectory() + self.arff_filepath = Path(self._temp_dir.name) / "datasplits.arff" + shutil.copy(source_arff, self.arff_filepath) self.pd_filename = self.arff_filepath.with_suffix(".pkl.py3") def tearDown(self): - try: - os.remove(self.pd_filename) - except (OSError, FileNotFoundError): - # Replaced bare except. Not sure why these exceptions are acceptable. - pass + # Clean up the entire temp directory + with contextlib.suppress(OSError, FileNotFoundError): + self._temp_dir.cleanup() def test_eq(self): split = OpenMLSplit._from_arff_file(self.arff_filepath) - assert split == split + assert split == split # noqa: PLR0124 split2 = OpenMLSplit._from_arff_file(self.arff_filepath) split2.name = "a" @@ -82,14 +87,14 @@ def test_get_split(self): train_split, test_split = split.get(fold=5, repeat=2) assert train_split.shape[0] == 808 assert test_split.shape[0] == 90 - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 ValueError, "Repeat 10 not known", split.get, 10, 2, ) - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 ValueError, "Fold 10 not known", split.get, diff --git a/tests/test_tasks/test_supervised_task.py b/tests/test_tasks/test_supervised_task.py index 9c90b7e03..9a23a7696 100644 --- a/tests/test_tasks/test_supervised_task.py +++ b/tests/test_tasks/test_supervised_task.py @@ -4,6 +4,7 @@ import unittest import pandas as pd +import pytest from openml.tasks import get_task @@ -21,12 +22,13 @@ class OpenMLSupervisedTaskTest(OpenMLTaskTest): @classmethod def setUpClass(cls): if cls is OpenMLSupervisedTaskTest: - raise unittest.SkipTest("Skip OpenMLSupervisedTaskTest tests," " it's a base class") + raise unittest.SkipTest("Skip OpenMLSupervisedTaskTest tests, it's a base class") super().setUpClass() - def setUp(self, n_levels: int = 1): + def setUp(self, n_levels: int = 1): # noqa: ARG002 super().setUp() + @pytest.mark.test_server def test_get_X_and_Y(self) -> tuple[pd.DataFrame, pd.Series]: task = get_task(self.task_id) X, Y = task.get_X_and_y() diff --git a/tests/test_tasks/test_task.py b/tests/test_tasks/test_task.py index e4c9418f2..0f77dd505 100644 --- a/tests/test_tasks/test_task.py +++ b/tests/test_tasks/test_task.py @@ -4,6 +4,8 @@ import unittest from random import randint, shuffle +import pytest + from openml.datasets import ( get_dataset, list_datasets, @@ -24,15 +26,17 @@ class OpenMLTaskTest(TestBase): @classmethod def setUpClass(cls): if cls is OpenMLTaskTest: - raise unittest.SkipTest("Skip OpenMLTaskTest tests," " it's a base class") + raise unittest.SkipTest("Skip OpenMLTaskTest tests, it's a base class") super().setUpClass() - def setUp(self, n_levels: int = 1): + def setUp(self, n_levels: int = 1): # noqa: ARG002 super().setUp() + @pytest.mark.test_server def test_download_task(self): return get_task(self.task_id) + @pytest.mark.test_server def test_upload_task(self): # We don't know if the task in question already exists, so we try a few times. Checking # beforehand would not be an option because a concurrent unit test could potentially @@ -60,11 +64,10 @@ def test_upload_task(self): except OpenMLServerException as e: # Error code for 'task already exists' # Should be 533 according to the docs - # (# https://www.openml.org/api_docs#!/task/post_task) + # (https://www.openml.org/api_docs#!/task/post_task) if e.code == 614: continue - else: - raise e + raise e else: raise ValueError( f"Could not create a valid task for task type ID {self.task_type}", @@ -88,21 +91,20 @@ def _get_compatible_rand_dataset(self) -> list: shuffle(compatible_datasets) return compatible_datasets - # random_dataset_pos = randint(0, len(compatible_datasets) - 1) + # random_dataset_pos = randint(0, len(compatible_datasets) - 1) # noqa: ERA001 # - # return compatible_datasets[random_dataset_pos] + # return compatible_datasets[random_dataset_pos] # noqa: ERA001 def _get_random_feature(self, dataset_id: int) -> str: random_dataset = get_dataset(dataset_id) # necessary loop to overcome string and date type # features. while True: - random_feature_index = randint(0, len(random_dataset.features) - 1) + random_feature_index = randint(0, len(random_dataset.features) - 1) # noqa: S311 random_feature = random_dataset.features[random_feature_index] if self.task_type == TaskType.SUPERVISED_REGRESSION: if random_feature.data_type == "numeric": break - else: - if random_feature.data_type == "nominal": - break + elif random_feature.data_type == "nominal": + break return random_feature.name diff --git a/tests/test_tasks/test_task_functions.py b/tests/test_tasks/test_task_functions.py index 856352ac2..b207a39c7 100644 --- a/tests/test_tasks/test_task_functions.py +++ b/tests/test_tasks/test_task_functions.py @@ -3,10 +3,8 @@ import os import unittest -from typing import cast from unittest import mock -import pandas as pd import pytest import requests @@ -26,6 +24,7 @@ def setUp(self): def tearDown(self): super().tearDown() + @pytest.mark.test_server def test__get_cached_tasks(self): openml.config.set_root_cache_directory(self.static_cache_dir) tasks = openml.tasks.functions._get_cached_tasks() @@ -33,6 +32,7 @@ def test__get_cached_tasks(self): assert len(tasks) == 3 assert isinstance(next(iter(tasks.values())), OpenMLTask) + @pytest.mark.test_server def test__get_cached_task(self): openml.config.set_root_cache_directory(self.static_cache_dir) task = openml.tasks.functions._get_cached_task(1) @@ -40,28 +40,30 @@ def test__get_cached_task(self): def test__get_cached_task_not_cached(self): openml.config.set_root_cache_directory(self.static_cache_dir) - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 OpenMLCacheException, "Task file for tid 2 not cached", openml.tasks.functions._get_cached_task, 2, ) + @pytest.mark.test_server def test__get_estimation_procedure_list(self): estimation_procedures = openml.tasks.functions._get_estimation_procedure_list() assert isinstance(estimation_procedures, list) assert isinstance(estimation_procedures[0], dict) assert estimation_procedures[0]["task_type_id"] == TaskType.SUPERVISED_CLASSIFICATION - @pytest.mark.production() + @pytest.mark.production_server + @pytest.mark.xfail(reason="failures_issue_1544", strict=False) def test_list_clustering_task(self): + self.use_production_server() # as shown by #383, clustering tasks can give list/dict casting problems - openml.config.server = self.production_server openml.tasks.list_tasks(task_type=TaskType.CLUSTERING, size=10) # the expected outcome is that it doesn't crash. No assertions. def _check_task(self, task): - assert type(task) == dict + assert type(task) == dict # noqa: E721 assert len(task) >= 2 assert "did" in task assert isinstance(task["did"], int) @@ -69,6 +71,7 @@ def _check_task(self, task): assert isinstance(task["status"], str) assert task["status"] in ["in_preparation", "active", "deactivated"] + @pytest.mark.test_server def test_list_tasks_by_type(self): num_curves_tasks = 198 # number is flexible, check server if fails ttid = TaskType.LEARNING_CURVE @@ -78,40 +81,48 @@ def test_list_tasks_by_type(self): assert ttid == task["ttid"] self._check_task(task) + @pytest.mark.test_server def test_list_tasks_length(self): ttid = TaskType.LEARNING_CURVE tasks = openml.tasks.list_tasks(task_type=ttid) assert len(tasks) > 100 + @pytest.mark.test_server def test_list_tasks_empty(self): tasks = openml.tasks.list_tasks(tag="NoOneWillEverUseThisTag") assert tasks.empty + @pytest.mark.test_server def test_list_tasks_by_tag(self): - num_basic_tasks = 100 # number is flexible, check server if fails + # Server starts with 99 active tasks with the tag, and one 'in_preparation', + # so depending on the processing of the last dataset, there may be 99 or 100 matches. + num_basic_tasks = 99 tasks = openml.tasks.list_tasks(tag="OpenML100") assert len(tasks) >= num_basic_tasks for task in tasks.to_dict(orient="index").values(): self._check_task(task) + @pytest.mark.test_server def test_list_tasks(self): tasks = openml.tasks.list_tasks() assert len(tasks) >= 900 for task in tasks.to_dict(orient="index").values(): self._check_task(task) + @pytest.mark.test_server def test_list_tasks_paginate(self): size = 10 - max = 100 + max = 100 # noqa: A001 for i in range(0, max, size): tasks = openml.tasks.list_tasks(offset=i, size=size) assert size >= len(tasks) for task in tasks.to_dict(orient="index").values(): self._check_task(task) + @pytest.mark.test_server def test_list_tasks_per_type_paginate(self): size = 40 - max = 100 + max = 100 # noqa: A001 task_types = [ TaskType.SUPERVISED_CLASSIFICATION, TaskType.SUPERVISED_REGRESSION, @@ -125,6 +136,7 @@ def test_list_tasks_per_type_paginate(self): assert j == task["ttid"] self._check_task(task) + @pytest.mark.test_server def test__get_task(self): openml.config.set_root_cache_directory(self.static_cache_dir) openml.tasks.get_task(1882) @@ -132,73 +144,77 @@ def test__get_task(self): @unittest.skip( "Please await outcome of discussion: https://github.com/openml/OpenML/issues/776", ) - @pytest.mark.production() + @pytest.mark.production_server def test__get_task_live(self): + self.use_production_server() # Test the following task as it used to throw an Unicode Error. # https://github.com/openml/openml-python/issues/378 - openml.config.server = self.production_server openml.tasks.get_task(34536) + @pytest.mark.test_server def test_get_task(self): task = openml.tasks.get_task(1, download_data=True) # anneal; crossvalidation assert isinstance(task, OpenMLTask) - assert os.path.exists( - os.path.join(self.workdir, "org", "openml", "test", "tasks", "1", "task.xml") + assert os.path.exists( # noqa: PTH110 + os.path.join(openml.config.get_cache_directory(), "tasks", "1", "task.xml") # noqa: PTH118 ) - assert not os.path.exists( - os.path.join(self.workdir, "org", "openml", "test", "tasks", "1", "datasplits.arff") + assert not os.path.exists( # noqa: PTH110 + os.path.join(openml.config.get_cache_directory(), "tasks", "1", "datasplits.arff") # noqa: PTH118 ) - assert os.path.exists( - os.path.join(self.workdir, "org", "openml", "test", "datasets", "1", "dataset.arff") + assert os.path.exists( # noqa: PTH110 + os.path.join(openml.config.get_cache_directory(), "datasets", "1", "dataset_1.pq") # noqa: PTH118 ) + @pytest.mark.test_server def test_get_task_lazy(self): task = openml.tasks.get_task(2, download_data=False) # anneal; crossvalidation assert isinstance(task, OpenMLTask) - assert os.path.exists( - os.path.join(self.workdir, "org", "openml", "test", "tasks", "2", "task.xml") + assert os.path.exists( # noqa: PTH110 + os.path.join(openml.config.get_cache_directory(), "tasks", "2", "task.xml") # noqa: PTH118 ) assert task.class_labels == ["1", "2", "3", "4", "5", "U"] - assert not os.path.exists( - os.path.join(self.workdir, "org", "openml", "test", "tasks", "2", "datasplits.arff") + assert not os.path.exists( # noqa: PTH110 + os.path.join(openml.config.get_cache_directory(), "tasks", "2", "datasplits.arff") # noqa: PTH118 ) # Since the download_data=False is propagated to get_dataset - assert not os.path.exists( - os.path.join(self.workdir, "org", "openml", "test", "datasets", "2", "dataset.arff") + assert not os.path.exists( # noqa: PTH110 + os.path.join(openml.config.get_cache_directory(), "datasets", "2", "dataset.arff") # noqa: PTH118 ) task.download_split() - assert os.path.exists( - os.path.join(self.workdir, "org", "openml", "test", "tasks", "2", "datasplits.arff") + assert os.path.exists( # noqa: PTH110 + os.path.join(openml.config.get_cache_directory(), "tasks", "2", "datasplits.arff") # noqa: PTH118 ) @mock.patch("openml.tasks.functions.get_dataset") + @pytest.mark.test_server def test_removal_upon_download_failure(self, get_dataset): - class WeirdException(Exception): + class WeirdException(Exception): # noqa: N818 pass - def assert_and_raise(*args, **kwargs): + def assert_and_raise(*args, **kwargs): # noqa: ARG001 # Make sure that the file was created! - assert os.path.join(os.getcwd(), "tasks", "1", "tasks.xml") + assert os.path.join(os.getcwd(), "tasks", "1", "tasks.xml") # noqa: PTH109, PTH118 raise WeirdException() get_dataset.side_effect = assert_and_raise - try: + try: # noqa: SIM105 openml.tasks.get_task(1) # anneal; crossvalidation except WeirdException: pass # Now the file should no longer exist - assert not os.path.exists(os.path.join(os.getcwd(), "tasks", "1", "tasks.xml")) + assert not os.path.exists(os.path.join(os.getcwd(), "tasks", "1", "tasks.xml")) # noqa: PTH109, PTH110, PTH118 + @pytest.mark.test_server def test_get_task_with_cache(self): openml.config.set_root_cache_directory(self.static_cache_dir) task = openml.tasks.get_task(1) assert isinstance(task, OpenMLTask) - @pytest.mark.production() + @pytest.mark.production_server def test_get_task_different_types(self): - openml.config.server = self.production_server + self.use_production_server() # Regression task openml.tasks.functions.get_task(5001) # Learning curve @@ -206,12 +222,13 @@ def test_get_task_different_types(self): # Issue 538, get_task failing with clustering task. openml.tasks.functions.get_task(126033) + @pytest.mark.test_server def test_download_split(self): task = openml.tasks.get_task(1) # anneal; crossvalidation split = task.download_split() - assert type(split) == OpenMLSplit - assert os.path.exists( - os.path.join(self.workdir, "org", "openml", "test", "tasks", "1", "datasplits.arff") + assert type(split) == OpenMLSplit # noqa: E721 + assert os.path.exists( # noqa: PTH110 + os.path.join(openml.config.get_cache_directory(), "tasks", "1", "datasplits.arff") # noqa: PTH118 ) def test_deletion_of_cache_dir(self): @@ -220,14 +237,13 @@ def test_deletion_of_cache_dir(self): "tasks", 1, ) - assert os.path.exists(tid_cache_dir) + assert os.path.exists(tid_cache_dir) # noqa: PTH110 openml.utils._remove_cache_dir_for_id("tasks", tid_cache_dir) - assert not os.path.exists(tid_cache_dir) + assert not os.path.exists(tid_cache_dir) # noqa: PTH110 @mock.patch.object(requests.Session, "delete") -def test_delete_task_not_owned(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +def test_delete_task_not_owned(mock_delete, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "tasks" / "task_delete_not_owned.xml" mock_delete.return_value = create_request_response( status_code=412, @@ -236,18 +252,17 @@ def test_delete_task_not_owned(mock_delete, test_files_directory, test_api_key): with pytest.raises( OpenMLNotAuthorizedError, - match="The task can not be deleted because it was not uploaded by you.", + match=r"The task can not be deleted because it was not uploaded by you.", ): openml.tasks.delete_task(1) - task_url = "https://test.openml.org/api/v1/xml/task/1" + task_url = test_server_v1 + "task/1" assert task_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert test_apikey_v1 == mock_delete.call_args.kwargs.get("params", {}).get("api_key") @mock.patch.object(requests.Session, "delete") -def test_delete_task_with_run(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +def test_delete_task_with_run(mock_delete, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "tasks" / "task_delete_has_runs.xml" mock_delete.return_value = create_request_response( status_code=412, @@ -260,14 +275,13 @@ def test_delete_task_with_run(mock_delete, test_files_directory, test_api_key): ): openml.tasks.delete_task(3496) - task_url = "https://test.openml.org/api/v1/xml/task/3496" + task_url = test_server_v1 + "task/3496" assert task_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert test_apikey_v1 == mock_delete.call_args.kwargs.get("params", {}).get("api_key") @mock.patch.object(requests.Session, "delete") -def test_delete_success(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +def test_delete_success(mock_delete, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "tasks" / "task_delete_successful.xml" mock_delete.return_value = create_request_response( status_code=200, @@ -277,14 +291,13 @@ def test_delete_success(mock_delete, test_files_directory, test_api_key): success = openml.tasks.delete_task(361323) assert success - task_url = "https://test.openml.org/api/v1/xml/task/361323" + task_url = test_server_v1 + "task/361323" assert task_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert test_apikey_v1 == mock_delete.call_args.kwargs.get("params", {}).get("api_key") @mock.patch.object(requests.Session, "delete") -def test_delete_unknown_task(mock_delete, test_files_directory, test_api_key): - openml.config.start_using_configuration_for_example() +def test_delete_unknown_task(mock_delete, test_files_directory, test_server_v1, test_apikey_v1): content_file = test_files_directory / "mock_responses" / "tasks" / "task_delete_not_exist.xml" mock_delete.return_value = create_request_response( status_code=412, @@ -297,6 +310,6 @@ def test_delete_unknown_task(mock_delete, test_files_directory, test_api_key): ): openml.tasks.delete_task(9_999_999) - task_url = "https://test.openml.org/api/v1/xml/task/9999999" + task_url = test_server_v1 + "task/9999999" assert task_url == mock_delete.call_args.args[0] - assert test_api_key == mock_delete.call_args.kwargs.get("params", {}).get("api_key") + assert test_apikey_v1 == mock_delete.call_args.kwargs.get("params", {}).get("api_key") diff --git a/tests/test_tasks/test_task_methods.py b/tests/test_tasks/test_task_methods.py index 4480c2cbc..ceb1516c0 100644 --- a/tests/test_tasks/test_task_methods.py +++ b/tests/test_tasks/test_task_methods.py @@ -3,6 +3,8 @@ from time import time +import pytest + import openml from openml.testing import TestBase @@ -15,6 +17,7 @@ def setUp(self): def tearDown(self): super().tearDown() + @pytest.mark.test_server def test_tagging(self): task = openml.tasks.get_task(1) # anneal; crossvalidation # tags can be at most 64 alphanumeric (+ underscore) chars @@ -30,6 +33,7 @@ def test_tagging(self): tasks = openml.tasks.list_tasks(tag=tag) assert len(tasks) == 0 + @pytest.mark.test_server def test_get_train_and_test_split_indices(self): openml.config.set_root_cache_directory(self.static_cache_dir) task = openml.tasks.get_task(1882) @@ -43,14 +47,14 @@ def test_get_train_and_test_split_indices(self): assert train_indices[-1] == 681 assert test_indices[0] == 583 assert test_indices[-1] == 24 - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 ValueError, "Fold 10 not known", task.get_train_test_split_indices, 10, 0, ) - self.assertRaisesRegex( + self.assertRaisesRegex( # noqa: PT027 ValueError, "Repeat 10 not known", task.get_train_test_split_indices, diff --git a/tests/test_utils/test_utils.py b/tests/test_utils/test_utils.py index 3b4a34b57..60d80a82e 100644 --- a/tests/test_utils/test_utils.py +++ b/tests/test_utils/test_utils.py @@ -2,64 +2,66 @@ import os import unittest.mock + import pytest + import openml from openml.testing import _check_dataset -@pytest.fixture() +@pytest.fixture def min_number_tasks_on_test_server() -> int: """After a reset at least 1068 tasks are on the test server""" return 1068 -@pytest.fixture() +@pytest.fixture def min_number_datasets_on_test_server() -> int: """After a reset at least 127 datasets are on the test server""" return 127 -@pytest.fixture() +@pytest.fixture def min_number_flows_on_test_server() -> int: """After a reset at least 127 flows are on the test server""" return 15 -@pytest.fixture() +@pytest.fixture def min_number_setups_on_test_server() -> int: - """After a reset at least 50 setups are on the test server""" + """After a reset at least 20 setups are on the test server""" return 50 -@pytest.fixture() +@pytest.fixture def min_number_runs_on_test_server() -> int: """After a reset at least 21 runs are on the test server""" - return 21 + return 15 -@pytest.fixture() +@pytest.fixture def min_number_evaluations_on_test_server() -> int: - """After a reset at least 22 evaluations are on the test server""" - return 22 + """After a reset at least 8 evaluations are on the test server""" + return 8 -def _mocked_perform_api_call(call, request_method): - url = openml.config.server + "/" + call +def _mocked_perform_api_call(call, request_method): # noqa: ARG001 + url = openml.config.server + call return openml._api_calls._download_text_file(url) -@pytest.mark.server() +@pytest.mark.test_server def test_list_all(): openml.utils._list_all(listing_call=openml.tasks.functions._list_tasks) -@pytest.mark.server() +@pytest.mark.test_server def test_list_all_for_tasks(min_number_tasks_on_test_server): tasks = openml.tasks.list_tasks(size=min_number_tasks_on_test_server) assert min_number_tasks_on_test_server == len(tasks) -@pytest.mark.server() +@pytest.mark.test_server def test_list_all_with_multiple_batches(min_number_tasks_on_test_server): # By setting the batch size one lower than the minimum we guarantee at least two # batches and at the same time do as few batches (roundtrips) as possible. @@ -72,7 +74,7 @@ def test_list_all_with_multiple_batches(min_number_tasks_on_test_server): assert min_number_tasks_on_test_server <= sum(len(batch) for batch in batches) -@pytest.mark.server() +@pytest.mark.test_server def test_list_all_for_datasets(min_number_datasets_on_test_server): datasets = openml.datasets.list_datasets( size=min_number_datasets_on_test_server, @@ -83,29 +85,29 @@ def test_list_all_for_datasets(min_number_datasets_on_test_server): _check_dataset(dataset) -@pytest.mark.server() +@pytest.mark.test_server def test_list_all_for_flows(min_number_flows_on_test_server): flows = openml.flows.list_flows(size=min_number_flows_on_test_server) assert min_number_flows_on_test_server == len(flows) -@pytest.mark.server() -@pytest.mark.flaky() # Other tests might need to upload runs first +@pytest.mark.flaky # Other tests might need to upload runs first +@pytest.mark.test_server def test_list_all_for_setups(min_number_setups_on_test_server): # TODO apparently list_setups function does not support kwargs setups = openml.setups.list_setups(size=min_number_setups_on_test_server) assert min_number_setups_on_test_server == len(setups) -@pytest.mark.server() -@pytest.mark.flaky() # Other tests might need to upload runs first +@pytest.mark.flaky # Other tests might need to upload runs first +@pytest.mark.test_server def test_list_all_for_runs(min_number_runs_on_test_server): runs = openml.runs.list_runs(size=min_number_runs_on_test_server) assert min_number_runs_on_test_server == len(runs) -@pytest.mark.server() -@pytest.mark.flaky() # Other tests might need to upload runs first +@pytest.mark.flaky # Other tests might need to upload runs first +@pytest.mark.test_server def test_list_all_for_evaluations(min_number_evaluations_on_test_server): # TODO apparently list_evaluations function does not support kwargs evaluations = openml.evaluations.list_evaluations( @@ -115,9 +117,9 @@ def test_list_all_for_evaluations(min_number_evaluations_on_test_server): assert min_number_evaluations_on_test_server == len(evaluations) -@pytest.mark.server() @unittest.mock.patch("openml._api_calls._perform_api_call", side_effect=_mocked_perform_api_call) -def test_list_all_few_results_available(_perform_api_call): +@pytest.mark.test_server +def test_list_all_few_results_available(_perform_api_call): # noqa: PT019 datasets = openml.datasets.list_datasets(size=1000, data_name="iris", data_version=1) assert len(datasets) == 1, "only one iris dataset version 1 should be present" assert _perform_api_call.call_count == 1, "expect just one call to get one dataset" @@ -141,14 +143,40 @@ def test__create_cache_directory(config_mock, tmp_path): openml.utils._create_cache_directory("ghi") -@pytest.mark.server() +@pytest.mark.test_server def test_correct_test_server_download_state(): """This test verifies that the test server downloads the data from the correct source. If this tests fails, it is highly likely that the test server is not configured correctly. Usually, this means that the test server is serving data from the task with the same ID from the production server. That is, it serves parquet files wrongly associated with the test server's task. - """ + """ # noqa: E501, D404 task = openml.tasks.get_task(119) dataset = task.get_dataset() assert len(dataset.features) == dataset.get_data()[0].shape[1] + + +@unittest.mock.patch("openml.config.get_cache_directory") +def test_get_cache_size(config_mock, tmp_path): + """ + Test that the OpenML cache size utility correctly reports the cache directory + size before and after fetching a dataset. + + This test uses a temporary directory (tmp_path) as the cache location by + patching the configuration via config_mock. It verifies two conditions: + empty cache and after dataset fetch. + + Parameters + ---------- + config_mock : unittest.mock.Mock + A mock that overrides the configured cache directory to point to tmp_path. + tmp_path : pathlib.Path + A pytest-provided temporary directory used as an isolated cache location. + """ + config_mock.return_value = tmp_path + cache_size = openml.utils.get_cache_size() + assert cache_size == 0 + sub_dir = tmp_path / "subdir" + sub_dir.mkdir() + (sub_dir / "nested_file.txt").write_bytes(b"b" * 100) + assert openml.utils.get_cache_size() == 100