diff --git a/.github/workflows/citation.yml b/.github/workflows/citation.yml index ed95d956..bd1190af 100644 --- a/.github/workflows/citation.yml +++ b/.github/workflows/citation.yml @@ -2,9 +2,9 @@ name: Citation File Validation on: push: - branches: [ "main", "dev", "hotfix/update-ci" ] + branches: ["main", "dev", "hotfix/update-ci"] pull_request: - branches: [ "main", "dev", "hotfix/update-ci" ] + branches: ["main", "dev", "hotfix/update-ci"] permissions: contents: read @@ -18,10 +18,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 - - name: Set up Python - uses: actions/setup-python@v6 - - name: Install Checker - run: python3 -m pip install cffconvert - - name: Check Citation File - run: cffconvert --validate + - uses: actions/checkout@v7 + - name: Set up Python + uses: actions/setup-python@v7 + - name: Install Checker + run: python3 -m pip install cffconvert + - name: Check Citation File + run: cffconvert --validate diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 45c7349c..1159a6e8 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -50,7 +50,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.11" - name: Install Simvue @@ -66,7 +66,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.11" - name: Install Simvue @@ -86,7 +86,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.11" - name: Install Simvue @@ -106,7 +106,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.11" - name: Download SU2 @@ -128,7 +128,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.11" - name: Install Simvue @@ -150,7 +150,7 @@ jobs: with: submodules: recursive - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.11" - name: Install Simvue diff --git a/.github/workflows/prek.yml b/.github/workflows/prek.yml index 6b83b631..6a299fb2 100644 --- a/.github/workflows/prek.yml +++ b/.github/workflows/prek.yml @@ -21,8 +21,8 @@ jobs: timeout-minutes: 30 steps: - name: Checkout Code - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Run Prek - uses: j178/prek-action@v2 + uses: j178/prek-action@v3.0.0 with: - extra-args: '--all-files --skip no-commit-to-branch' + extra-args: "--all-files --skip no-commit-to-branch" diff --git a/.github/workflows/test_client_macos_nightlies.yml b/.github/workflows/test_client_macos_nightlies.yml index 2a9f5f24..366ad9be 100644 --- a/.github/workflows/test_client_macos_nightlies.yml +++ b/.github/workflows/test_client_macos_nightlies.yml @@ -17,9 +17,9 @@ jobs: runs-on: macos-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -42,9 +42,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -67,9 +67,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -91,9 +91,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -116,9 +116,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -141,9 +141,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -165,9 +165,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -189,9 +189,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -213,9 +213,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -237,9 +237,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies diff --git a/.github/workflows/test_client_ubuntu.yml b/.github/workflows/test_client_ubuntu.yml index 9b99fb88..352a3409 100644 --- a/.github/workflows/test_client_ubuntu.yml +++ b/.github/workflows/test_client_ubuntu.yml @@ -22,9 +22,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -46,9 +46,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -70,9 +70,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -93,9 +93,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -117,9 +117,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -141,9 +141,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -164,9 +164,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -187,9 +187,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -210,9 +210,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -233,9 +233,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies diff --git a/.github/workflows/test_client_ubuntu_nightlies.yml b/.github/workflows/test_client_ubuntu_nightlies.yml index 578fc7d5..dce507e5 100644 --- a/.github/workflows/test_client_ubuntu_nightlies.yml +++ b/.github/workflows/test_client_ubuntu_nightlies.yml @@ -20,9 +20,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -45,9 +45,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -70,9 +70,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -94,9 +94,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -119,9 +119,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -144,9 +144,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -168,9 +168,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -192,9 +192,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -216,9 +216,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -240,9 +240,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies diff --git a/.github/workflows/test_client_windows_nightlies.yml b/.github/workflows/test_client_windows_nightlies.yml index b8bbb710..b383f08c 100644 --- a/.github/workflows/test_client_windows_nightlies.yml +++ b/.github/workflows/test_client_windows_nightlies.yml @@ -17,9 +17,9 @@ jobs: runs-on: windows-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -43,9 +43,9 @@ jobs: runs-on: windows-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -69,9 +69,9 @@ jobs: runs-on: windows-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -94,9 +94,9 @@ jobs: runs-on: windows-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -120,9 +120,9 @@ jobs: runs-on: windows-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -146,9 +146,9 @@ jobs: runs-on: windows-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -171,9 +171,9 @@ jobs: runs-on: windows-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -195,9 +195,9 @@ jobs: runs-on: windows-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -220,9 +220,9 @@ jobs: runs-on: windows-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies @@ -245,9 +245,9 @@ jobs: runs-on: windows-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.13" - name: Install dependencies diff --git a/.github/workflows/test_multiple_python.yml b/.github/workflows/test_multiple_python.yml index dc1b5f1e..ed3da0c1 100644 --- a/.github/workflows/test_multiple_python.yml +++ b/.github/workflows/test_multiple_python.yml @@ -25,9 +25,9 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "${{ matrix.python-version }}" - name: Install dependencies @@ -55,9 +55,9 @@ jobs: matrix: python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "${{ matrix.python-version }}" - name: Install dependencies @@ -85,9 +85,9 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "${{ matrix.python-version }}" - name: Install dependencies @@ -114,9 +114,9 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "${{ matrix.python-version }}" - name: Install dependencies @@ -144,9 +144,9 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "${{ matrix.python-version }}" - name: Install dependencies @@ -174,9 +174,9 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "${{ matrix.python-version }}" - name: Install dependencies @@ -198,9 +198,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python 3.14 - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.14" - name: Install dependencies @@ -227,9 +227,9 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "${{ matrix.python-version }}" - name: Install dependencies @@ -256,9 +256,9 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "${{ matrix.python-version }}" - name: Install dependencies @@ -285,9 +285,9 @@ jobs: matrix: python-version: ["3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "${{ matrix.python-version }}" - name: Install dependencies diff --git a/README.md b/README.md index b75d6736..39181443 100644 --- a/README.md +++ b/README.md @@ -44,56 +44,61 @@ from simvue import Run ... if __name__ == "__main__": - ... # Using a context manager means that the status will be set to completed automatically, # and also means that if the code exits with an exception this will be reported to Simvue with Run() as run: - # Specify a run name, metadata (dict), tags (list), description, folder - run.init('example-run-name', - {'learning_rate': 0.001, 'training_steps': 2000, 'batch_size': 32}, # Metadaata - ['tensorflow'], # Tags - 'This is a test.', # Description - '/Project-A/part1') # Folder full path + run.init( + "example-run-name", + { + "learning_rate": 0.001, + "training_steps": 2000, + "batch_size": 32, + }, # Metadaata + ["tensorflow"], # Tags + "This is a test.", # Description + "/Project-A/part1", + ) # Folder full path # Set folder details if necessary - run.set_folder_details('/Project-A/part1', # Folder full path - metadata={}, # Metadata - tags=['tensorflow'], # Tags - description='This is part 1 of a test') # Description + run.set_folder_details( + "/Project-A/part1", # Folder full path + metadata={}, # Metadata + tags=["tensorflow"], # Tags + description="This is part 1 of a test", + ) # Description # Upload the code - run.save_file('training.py', 'code') + run.save_file("training.py", "code") # Upload an input file - run.save_file('params.in', 'input') + run.save_file("params.in", "input") # Add an alert (the alert definition will be created if necessary) run.create_metric_threshold_alert( - name='loss-too-high', # Name - rule='is above', # Rule - metric='loss', # Metric - frequency=1, # Frequency - window=1, # Window - threshold=10, # Threshold - notification='email' # Notification type + name="loss-too-high", # Name + rule="is above", # Rule + metric="loss", # Metric + frequency=1, # Frequency + window=1, # Window + threshold=10, # Threshold + notification="email", # Notification type ) ... while not converged: - ... # Send metrics inside main application loop - run.log_metrics({'loss': 0.5, 'density': 34.4}) + run.log_metrics({"loss": 0.5, "density": 34.4}) ... # Upload an output file - run.save_file('output.cdf', 'output') + run.save_file("output.cdf", "output") # If we weren't using a context manager we'd need to end the run # run.close() diff --git a/poetry.lock b/poetry.lock index b342a7fe..69be527b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -24,6 +24,21 @@ files = [ {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] +[[package]] +name = "basedpyright" +version = "1.40.1" +description = "static type checking for Python (but based)" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "basedpyright-1.40.1-py3-none-any.whl", hash = "sha256:222dc0382caf9816eb23a27cb7059fa96356ddc500b3b7b306072848fd650244"}, + {file = "basedpyright-1.40.1.tar.gz", hash = "sha256:da1c9913b6d169340a0dbb6df76ea97f476ccb697da8f92659ac46032f6d2ce8"}, +] + +[package.dependencies] +nodejs-wheel-binaries = ">=20.13.1" + [[package]] name = "certifi" version = "2026.7.22" @@ -1577,6 +1592,25 @@ pyspark-connect = ["pyspark[connect] (>=3.5.0)"] sql = ["narwhals[duckdb]", "sqlparse (>=0.5.5)"] sqlframe = ["sqlframe (>=3.22.0,!=3.39.3)"] +[[package]] +name = "nodejs-wheel-binaries" +version = "24.19.0" +description = "unoffical Node.js package" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c"}, + {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514"}, + {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227"}, + {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8"}, + {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7"}, + {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c"}, + {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd"}, + {file = "nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36"}, + {file = "nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b"}, +] + [[package]] name = "numpy" version = "2.2.6" @@ -2407,14 +2441,14 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyjwt" -version = "2.14.0" +version = "2.15.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc"}, - {file = "pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86"}, + {file = "pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8"}, + {file = "pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4"}, ] [package.dependencies] @@ -2587,6 +2621,18 @@ files = [ [package.dependencies] six = ">=1.5" +[[package]] +name = "python-version" +version = "0.0.2" +description = "Provides a simple utility for checking the python version." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "python_version-0.0.2-py2.py3-none-any.whl", hash = "sha256:2c7429f3375cee19873892e5db4d10d2b42172d8851a8bb5111729e028470580"}, + {file = "python_version-0.0.2.tar.gz", hash = "sha256:5c16de57c7f2d614621cf468e8f5a20bb6b3cec665c9c7f5e9f9f000bf04fe67"}, +] + [[package]] name = "pytz" version = "2026.3.post1" @@ -2736,30 +2782,30 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "ruff" -version = "0.16.8" +version = "0.16.9" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.16.8-py3-none-linux_armv6l.whl", hash = "sha256:6ffbd6d87383c1edf5f6fa890f10200950240d7c1a16052a19a09d3a2307dd38"}, - {file = "ruff-0.16.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:42ed6b878ed61e3acca92f2730a17acff39286944ea82398544696366a6f925e"}, - {file = "ruff-0.16.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7ea781c7f2afba8c6a505ea0fb3f994020249e0c450635f5381286fea6b46170"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8efeae3bbe414a5efefda11a792dfb51ef90ac48d50c4830de2f644caf3e8659"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a79b795469fef7fc6e908b218eed2eb17332afd85031db6480dc864560e69b2"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3fdc5563cdc50555e6fba39322850860e9267c1b3d12c26a74729d8604c3c812"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:34508983c70665578dab88f5223d8e6228307e1135398ca8bfc8b7e9501e282b"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:644bb578569e0ffc575741232bd385dacdd6fbe123f1a729e7a225f54aa3957f"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15e7d226246961db9235098333caa13063906d3851136b84c2900b82f5daa1df"}, - {file = "ruff-0.16.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a2bf6bc3e9ebdd4449abc6f06cf64b98051a2c61cf94d2fe9596518c881f1a1e"}, - {file = "ruff-0.16.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6ca111ba0849539165e9e59d2b442542f3c1e8060ebbdea82494f1ffbccb1e1f"}, - {file = "ruff-0.16.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:359a1e5b495448ee1e91018064382ebc86f90e8aac2fed222c7d0e4e8df85fd2"}, - {file = "ruff-0.16.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:59e8f5681349474110b24d62e93cfda6593f5fa3473446ca3705200cac1a08b9"}, - {file = "ruff-0.16.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:efa3e7a16d1baaa79957888dfdf8be9ef2e44db81cb032af06d76632ab59e773"}, - {file = "ruff-0.16.8-py3-none-win32.whl", hash = "sha256:55793ba85c69921e89be061426d91a78652d6e50317c962240922747a4eb713f"}, - {file = "ruff-0.16.8-py3-none-win_amd64.whl", hash = "sha256:a6b85621fd3c81e31fc5f5add09c9c078b430db3595ca632efafdec9e64ebfaa"}, - {file = "ruff-0.16.8-py3-none-win_arm64.whl", hash = "sha256:d075e820af612102ce217f07cc93e69f9490b10ec13ea85fa87bd03d996cef8a"}, - {file = "ruff-0.16.8.tar.gz", hash = "sha256:9247bf92b5f04d825c8639a4fe423ec2e4222acd9222e58412b0dab7e442798b"}, + {file = "ruff-0.16.9-py3-none-linux_armv6l.whl", hash = "sha256:95e6f022090368ab3b824c36276839c53b2adf1a3f4c09fefc33dfc400f6da96"}, + {file = "ruff-0.16.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a5f27be168556594a86d2f415db0cf43f5291917849318f873c7e2791f7a8c67"}, + {file = "ruff-0.16.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1632eb1d6197f33bd00b1acbc5b71009e89a8895c158e2d2b03a834fac964ab6"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b3f951b14d865d5952c89d40a5ca07e87abe24fa5453299878411e127748fb1c"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:447fc07e1573afff7cb02803462b12b6c8ece7cf10e2cd78565fa6d7a1c0bf8d"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a3e039a6a40ed976c491722b60e0ae4a4aa1a86057f540ee7a37a5d19ae9120"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4684dded7db60aa57cb118fa158630f5feade4af5782903b6053484bdf9bd129"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d29c934357e45642fda2f34c0b1f4025b4a6c01e15e4bf0016879d60078a142c"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a21713e629d3e5bdb2f5c2def1cc7f04f47fa8e1a7eb0571b4a28e1da64bc728"}, + {file = "ruff-0.16.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7baa24ef5fc8e77aa93879e1d3f43754a01ae488e869f1ae30cf431afd4d2452"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a41aac6230aadfaa133bdfa1614488531ffa3e0837567ae04c0da2058a9c0f9e"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c2529fb5896d49115b0e9aa8f887490b34bbe76baf879ec2264ac59406869ce7"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:41e3870277694177429b56406d65dfbdb2c2802c52b715edaf6a0b829c69d4ee"}, + {file = "ruff-0.16.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8adbe4e58af167f767d7b2ba5e83c42e878350796cf78c2f5e14ab9903a92588"}, + {file = "ruff-0.16.9-py3-none-win32.whl", hash = "sha256:0e1dbc2073624dee6618d41d0098690a7244654af746704b64759e12b6b6b385"}, + {file = "ruff-0.16.9-py3-none-win_amd64.whl", hash = "sha256:6bd40fec8cd4c8a3d4dd589bd8ad4e6320c13c29234159bfd959a40d529d597b"}, + {file = "ruff-0.16.9-py3-none-win_arm64.whl", hash = "sha256:ed1a252039200f57a59eebc063b54beabea67bfbaaca0eeaa7f54b5fbcda2284"}, + {file = "ruff-0.16.9.tar.gz", hash = "sha256:12b625c6cfba78d285d9f48eda5f053374f1e53cb10ef17342a383750db99161"}, ] [[package]] @@ -3032,4 +3078,4 @@ plot = ["matplotlib", "plotly"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<3.15" -content-hash = "6e313946685e97cb853ea060baa2bd52e61e7b45d67269d8b8ed80369c4fcf44" +content-hash = "249acf99e47d757f0fb08b45a8201272656a5b01e18f3683423fd4e9899670de" diff --git a/prek.toml b/prek.toml index 7d9dc3de..0db5fefe 100644 --- a/prek.toml +++ b/prek.toml @@ -34,10 +34,11 @@ id = "check-added-large-files" [[repos]] repo = "https://github.com/astral-sh/ruff-pre-commit" -rev = "v0.15.14" +rev = "v0.16.8" + [[repos.hooks]] id = "ruff-check" -args = ["--fix", "--exit-non-zero-on-fix", "--ignore=C901"] +args = ["--fix", "--exit-non-zero-on-fix", "--ignore=C901", "--preview"] [[repos.hooks]] id = "ruff-format" @@ -71,3 +72,31 @@ id = "poetry-lock" [[repos.hooks]] id = "poetry-install" args = ["--all-extras", "--all-groups"] + +[[repos]] +repo = "https://github.com/GabDug/sync-pre-commit-lock" +rev = "v0.9.0" + +[[repos.hooks]] +id = "sync-pre-commit-uv" + +[[repos]] +repo = "https://github.com/astral-sh/uv-pre-commit" +rev = "0.11.32" + +[[repos.hooks]] +id = "uv-lock" + +[[repos]] +repo = "https://github.com/citation-file-format/cffconvert" +rev = "b6045d78aac9e02b039703b030588d54d53262ac" + +[[repos.hooks]] +id = "validate-cff" + +[[repos]] +repo = "https://github.com/DetachHead/basedpyright-prek-mirror" +rev = "1.39.9" + +[[repos.hooks]] +id = "basedpyright" diff --git a/pyproject.toml b/pyproject.toml index a48696ea..731e96b1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,31 +33,32 @@ keywords = [ "metrics-gathering", ] dependencies = [ - "requests (>=2.32.3,<3.0.0)", - "pydantic (>=2.11,<3.0.0)", - "tabulate (>=0.9.0,<0.11.0)", - "pyjwt (>=2.13.0,<3.0.0)", - "pandas>=2.2.3,<3.0.6", - "toml (>=0.10.2,<0.11.0)", - "click (>=8.4.1,<9.0.0)", - "gitpython (>=3.1.59,<4.0.0)", - "humanfriendly (>=10.0,<11.0)", - "randomname (>=0.2.1,<0.3.0)", - "numpy (>=2.0.0,<3.0.0)", - "semver (>=3.0.4,<4.0.0)", - "email-validator (>=2.2.0,<3.0.0)", - "psutil (>=6.1.1,<8.0.0)", - "tenacity (>=9.0.0,<10.0.0)", - "typing-extensions (>=4.12.2,<5.0.0) ; python_version < \"3.11\"", - "deepmerge (>=2.0,<4.0)", - "geocoder (>=1.38.1,<2.0.0)", - "pydantic-extra-types (>=2.10.5,<3.0.0)", - "pyyaml (>=6.0.2,<7.0.0)", - "flatdict (==4.1.0)", - "pytest (>=9.0.3,<10.0.0)", - "unyt (>=3.1.0,<4.0.0)", - "pip>=26.1.2", - "msgpack>=1.2.1", + "requests>=2.34.2", + "pydantic>=2.13.5", + "tabulate>=0.10.0", + "pyjwt>=2.15.0", + "pandas>=2.3.3", + "toml>=0.10.2", + "click>=8.5.0", + "gitpython>=3.1.62", + "humanfriendly>=10.0", + "randomname>=0.2.1", + "numpy>=2.2.6", + "semver>=3.1.0", + "email-validator>=2.3.0", + "psutil>=7.2.2", + "tenacity>=9.1.4", + "typing-extensions>=4.16.0", + "python-version>=0.0.2", + "deepmerge>=3.0.1", + "geocoder>=1.38.1", + "pydantic-extra-types>=2.11.1", + "pyyaml>=6.0.3", + "flatdict>=4.1.0", + "pytest>=9.1.1", + "unyt>=3.1.0", + "pip>=26.2.1", + "msgpack>=1.2.2", ] [project.urls] @@ -77,6 +78,7 @@ build-backend = "hatchling.build" [dependency-groups] dev = [ + "basedpyright>=1.40.1", "interrogate>=1.7.0", "jinja2>=3.1.6", "pytest-cov>=7.1.0", @@ -84,20 +86,31 @@ dev = [ "pytest-sugar>=1.1.1", "pytest-timeout>=2.4.0", "pytest-xdist>=3.8.0", - "ruff>=0.15.14", - "types-requests>=2.33.0.20260518", + "ruff>=0.16.9", + "types-requests>=2.33.0.20260906", ] [tool.ruff] extend-exclude = ["tests", "examples", "notebooks", "simvue/pynvml.py"] -preview = true [tool.ruff.lint] -ignore = ["COM812", "PLR0904", "PLR6301", "D203", "D212", "PLW0717"] +preview = true +ignore = [ + "missing-trailing-comma", + "too-many-public-methods", + "no-self-use", + "incorrect-blank-line-before-class", + "multi-line-summary-first-line", + "undocumented-param", + "suspicious-subprocess-import", + "subprocess-without-shell-equals-true", + "too-many-statements-in-try-clause", + "suspicious-pickle-import", + "typing-only-standard-library-import", +] dummy-variable-rgx = "^_+$" extend-select = [ "C90", - "D417", "E", "F", "W", @@ -129,7 +142,6 @@ extend-select = [ "TC", "ARG", "I", - "D417", "ERA", "PL", "UP", @@ -176,5 +188,37 @@ fail-under = 95 verbose = 1 exclude = ["docs", "tests"] +[tool.basedpyright] +include = ["src", "examples"] +pythonVersion = "3.14" +typeCheckingMode = "off" +deprecateTypingAliases = true +reportAttributeAccessIssue = "error" +reportDuplicateImport = "error" +reportGeneralTypeIssues = "warning" +reportImplicitOverride = "error" +reportImportCycles = "none" +reportInconsistentOverload = "error" +reportIndexIssue = "error" +reportInvalidTypeForm = "error" +reportMissingTypeArgument = "error" +reportOperatorIssue = "error" +reportOptionalIterable = "error" +reportOptionalSubscript = "error" +reportPropertyTypeMismatch = "error" +reportRedeclaration = "warning" +reportUnusedClass = "error" +reportUnusedFunction = "error" +reportUnusedImport = "error" +reportUnusedVariable = "error" +reportSelfClsParameterName = "error" +reportUndefinedVariable = "error" +reportUnboundVariable = "error" +reportUnhashable = "error" +reportUnusedExcept = "error" +reportUnusedExpression = "error" +reportUnreachable = "error" +reportUnusedParameter = "error" + [tool.mypy] ignore_missing_imports = true diff --git a/simvue/api/objects/administrator/__init__.py b/simvue/api/objects/administrator/__init__.py index 52fcdb6f..431c7c63 100644 --- a/simvue/api/objects/administrator/__init__.py +++ b/simvue/api/objects/administrator/__init__.py @@ -5,8 +5,8 @@ """ -from .tenant import Tenant as Tenant -from .user import User as User +from .tenant import Tenant +from .user import User __all__ = [ "Tenant", diff --git a/simvue/api/objects/administrator/tenant.py b/simvue/api/objects/administrator/tenant.py index 2e61c643..adfb1622 100644 --- a/simvue/api/objects/administrator/tenant.py +++ b/simvue/api/objects/administrator/tenant.py @@ -66,6 +66,7 @@ def __init__( @override @classmethod @pydantic.validate_call + @override def new( cls, *, @@ -239,7 +240,7 @@ def created(self) -> datetime.datetime | None: datetime.datetime """ - _created: str | None = self._get_attribute("created") + _created: str | None = typing.cast("str | None", self._get_attribute("created")) return ( datetime.datetime.strptime(_created, DATETIME_FORMAT).astimezone( datetime.timezone.utc, diff --git a/simvue/api/objects/administrator/user.py b/simvue/api/objects/administrator/user.py index dd8def79..0d32ec39 100644 --- a/simvue/api/objects/administrator/user.py +++ b/simvue/api/objects/administrator/user.py @@ -66,6 +66,7 @@ def __init__( @override @classmethod @pydantic.validate_call + @override def new( cls, *, @@ -144,6 +145,7 @@ def new( @override @classmethod + @override def get( cls, *, @@ -189,8 +191,9 @@ def get( def username(self) -> str: """Retrieve the username for the user.""" if self.id and self.id.startswith("offline_"): - return self._get_attribute("user")["username"] - return self._get_attribute("username") + _user_dict = typing.cast("dict[str, str]", self._get_attribute("user")) + return _user_dict["username"] + return typing.cast("str", self._get_attribute("username")) @username.setter @write_only @@ -204,8 +207,9 @@ def username(self, username: str) -> None: def fullname(self) -> str: """Retrieve the full name for the user.""" if self.id and self.id.startswith("offline_"): - return self._get_attribute("user")["fullname"] - return self._get_attribute("fullname") + _user_dict = typing.cast("dict[str, str]", self._get_attribute("user")) + return _user_dict["fullname"] + return typing.cast("str", self._get_attribute("fullname")) @fullname.setter @write_only @@ -219,8 +223,9 @@ def fullname(self, fullname: str) -> None: def is_manager(self) -> bool: """Retrieve if the user has manager privileges.""" if self.id and self.id.startswith("offline_"): - return self._get_attribute("user")["is_manager"] - return self._get_attribute("is_manager") + _user_dict = typing.cast("dict[str, bool]", self._get_attribute("user")) + return _user_dict["is_manager"] + return typing.cast("bool", self._get_attribute("is_manager")) @is_manager.setter @write_only @@ -234,8 +239,9 @@ def is_manager(self, is_manager: bool) -> None: def is_admin(self) -> bool: """Retrieve if the user has admin privileges.""" if self.id and self.id.startswith("offline_"): - return self._get_attribute("user")["is_admin"] - return self._get_attribute("is_admin") + _user_dict = typing.cast("dict[str, bool]", self._get_attribute("user")) + return _user_dict["is_admin"] + return typing.cast("bool", self._get_attribute("is_admin")) @is_admin.setter @write_only @@ -248,16 +254,18 @@ def is_admin(self, is_admin: bool) -> None: def deleted(self) -> bool: """Retrieve if the user is pending deletion.""" if self.id and self.id.startswith("offline_"): - return self._get_attribute("user")["is_deleted"] - return self._get_attribute("is_deleted") + _user_dict = typing.cast("dict[str, bool]", self._get_attribute("user")) + return _user_dict["is_deleted"] + return typing.cast("bool", self._get_attribute("is_deleted")) @property @staging_check def is_readonly(self) -> bool: """Retrieve if the user has read-only access.""" if self.id and self.id.startswith("offline_"): - return self._get_attribute("user")["is_readonly"] - return self._get_attribute("is_readonly") + _user_dict = typing.cast("dict[str, bool]", self._get_attribute("user")) + return _user_dict["is_readonly"] + return typing.cast("bool", self._get_attribute("is_readonly")) @is_readonly.setter @write_only @@ -271,8 +279,9 @@ def is_readonly(self, is_readonly: bool) -> None: def enabled(self) -> bool: """Retrieve if the user is enabled.""" if self.id and self.id.startswith("offline_"): - return self._get_attribute("user")["is_enabled"] - return self._get_attribute("is_enabled") + _user_dict = typing.cast("dict[str, bool]", self._get_attribute("user")) + return _user_dict["is_enabled"] + return typing.cast("bool", self._get_attribute("is_enabled")) @enabled.setter @write_only @@ -286,8 +295,9 @@ def enabled(self, is_enabled: bool) -> None: def email(self) -> str: """Retrieve the user email.""" if self.id and self.id.startswith("offline_"): - return self._get_attribute("user")["email"] - return self._get_attribute("email") + _user_dict = typing.cast("dict[str, str]", self._get_attribute("user")) + return _user_dict["email"] + return typing.cast("str", self._get_attribute("email")) @email.setter @write_only @@ -305,7 +315,7 @@ def created(self) -> datetime.datetime | None: datetime.datetime """ - _created: str | None = self._get_attribute("created") + _created: str | None = typing.cast("str | None", self._get_attribute("created")) return ( datetime.datetime.strptime(_created, DATETIME_FORMAT).astimezone( datetime.timezone.utc, diff --git a/simvue/api/objects/alert/base.py b/simvue/api/objects/alert/base.py index 083bf167..b3d5659d 100644 --- a/simvue/api/objects/alert/base.py +++ b/simvue/api/objects/alert/base.py @@ -119,7 +119,7 @@ def compare(self, other: "AlertBase") -> bool: def get_alert(self) -> dict[str, typing.Any]: """Retrieve alert definition.""" try: - return self._get_attribute("alert") + return typing.cast("dict[str, object]", self._get_attribute("alert")) except AttributeError: return {} @@ -232,11 +232,16 @@ def abort(self, abort: bool) -> None: self._staging["abort"] = abort @pydantic.validate_call - def set_status(self, _: str, __: typing.Literal["ok", "critical"]) -> None: + def set_status( + self, message: str, err_type: typing.Literal["ok", "critical"] + ) -> None: """Set the status of this alert for a given run.""" - raise AttributeError( - f"Cannot update state for alert of type '{self.__class__.__name__}'", + _ = message + _ = err_type + _out_msg: str = ( + f"Cannot update state for alert of type '{self.__class__.__name__}'" ) + raise AttributeError(_out_msg) def get_status(self, run_id: str) -> typing.Literal["ok", "critical"]: """Retrieve the status of this alert for a given run.""" @@ -258,4 +263,8 @@ def get_status(self, run_id: str) -> typing.Literal["ok", "critical"]: scenario=f"Retrieving status for alert '{self.id}' in run '{run_id}'", ) - return _json_response.get("status") + _json_response = typing.cast("dict[str, object]", _json_response) + + return typing.cast( + "typing.Literal['ok', 'critical']", _json_response.get("status") + ) diff --git a/simvue/api/objects/alert/events.py b/simvue/api/objects/alert/events.py index 38619dae..75e43d1e 100644 --- a/simvue/api/objects/alert/events.py +++ b/simvue/api/objects/alert/events.py @@ -67,6 +67,7 @@ def __init__( @override @classmethod + @override def get( cls, *, @@ -80,6 +81,7 @@ def get( @classmethod @pydantic.validate_call + @override def new( cls, *, @@ -139,7 +141,7 @@ def new( _offline=offline, ) _alert._staging |= _alert_definition - _alert._params = {"deduplicate": True} + _alert._params = {"deduplicate": True} # pyright: ignore[reportAttributeAccessIssue] return _alert @override @@ -157,6 +159,7 @@ def __init__(self, alert: EventsAlert) -> None: """Initialise an alert definition with its parent alert.""" self._sv_obj = alert + @override def __eq__(self, other: "EventAlertDefinition") -> bool: """Compare this definition with that of another EventAlert.""" return all( @@ -166,14 +169,16 @@ def __eq__(self, other: "EventAlertDefinition") -> bool: ], ) + @override def __hash__(self) -> int: return hash(f"{self.frequency}+{self.pattern}") @property def pattern(self) -> str: """Retrieve the event log pattern monitored by this alert.""" + _alert: dict[str, str | float] = self._sv_obj.get_alert() try: - return self._sv_obj.get_alert()["pattern"] + return typing.cast("str", _alert["pattern"]) except KeyError as e: raise RuntimeError( "Expected key 'pattern' in alert definition retrieval", @@ -183,8 +188,9 @@ def pattern(self) -> str: @staging_check def frequency(self) -> int: """Retrieve the update frequency for this alert.""" + _alert: dict[str, str | float] = self._sv_obj.get_alert() try: - return self._sv_obj.get_alert()["frequency"] + return typing.cast("float", _alert["frequency"]) except KeyError as e: raise RuntimeError( "Expected key 'frequency' in alert definition retrieval", diff --git a/simvue/api/objects/alert/fetch.py b/simvue/api/objects/alert/fetch.py index 6f5a65b2..223123ee 100644 --- a/simvue/api/objects/alert/fetch.py +++ b/simvue/api/objects/alert/fetch.py @@ -33,6 +33,8 @@ class AlertSort(Sort): + """Sorting object for Alert retrieval.""" + @pydantic.field_validator("column") @classmethod def check_column(cls, column: str) -> str: @@ -124,7 +126,8 @@ def __new__( **kwargs, ) - raise RuntimeError(f"Unknown source type '{_alert_pre.source}'") + _out_msg: str = f"Unknown source type '{_alert_pre.source}'" + raise RuntimeError(_out_msg) @classmethod @pydantic.validate_call @@ -190,16 +193,27 @@ def get( expected_status=[http.HTTPStatus.OK], scenario=f"Retrieval of {_label}s", ) + _json_response = typing.cast( + "dict[str, list[dict[str, object]]]", _json_response + ) if (_data := _json_response.get("data")) is None: - raise RuntimeError(f"Expected key 'data' for retrieval of {_label}s") + _out_msg: str = f"Expected key 'data' for retrieval of {_label}s" + raise RuntimeError(_out_msg) for _entry in _data: _id = _entry.pop("id") if _entry["source"] == "events": yield ( _id, - EventsAlert(_read_only=True, identifier=_id, _local=True, **_entry), + EventsAlert( + _read_only=True, + identifier=_id, + _local=True, + _offline=False, + _user_agent=None, + **_entry, + ), ) elif _entry["source"] == "user": yield ( @@ -208,7 +222,23 @@ def get( ) elif ( _entry["source"] == "metrics" - and _entry.get("alert", {}).get("threshold") is not None + and typing.cast("dict[str, float]", _entry.get("alert", {})).get( + "threshold" + ) + is not None + ): + yield ( + _id, + MetricsThresholdAlert( + _local=True, + _read_only=True, + identifier=_id, + **_entry, + ), + ) + elif ( + _entry["source"] == "metrics" + and _entry.get("alert", {}).get("threshold") is not None # pyright: ignore[reportAttributeAccessIssue] ): yield ( _id, @@ -216,12 +246,14 @@ def get( _local=True, _read_only=True, identifier=_id, + _offline=False, + _user_agent=None, **_entry, ), ) elif ( _entry["source"] == "metrics" - and _entry.get("alert", {}).get("range_low") is not None + and _entry.get("alert", {}).get("range_low") is not None # pyright: ignore[reportAttributeAccessIssue] ): yield ( _id, @@ -233,7 +265,8 @@ def get( ), ) else: - raise RuntimeError( + _out_msg = ( f"Unrecognised alert source '{_entry['source']}' " - f"with data '{_entry}'", + + f"with data '{_entry}'", ) + raise RuntimeError(_out_msg) diff --git a/simvue/api/objects/alert/metrics.py b/simvue/api/objects/alert/metrics.py index 4611e947..f4f3c48e 100644 --- a/simvue/api/objects/alert/metrics.py +++ b/simvue/api/objects/alert/metrics.py @@ -10,9 +10,9 @@ import pydantic try: - from typing import Self + from typing import Self, override except ImportError: - from typing_extensions import Self + from typing_extensions import Self, override from simvue.api.objects.base import write_only from simvue.models import NAME_REGEX @@ -80,6 +80,7 @@ def __init__( @override @classmethod + @override def get( cls, *, @@ -94,6 +95,7 @@ def get( @override @classmethod + @override @pydantic.validate_call def new( cls, @@ -244,6 +246,7 @@ def _compare_objects(self, other: "AlertBase") -> bool: @classmethod @pydantic.validate_call + @override def new( cls, *, @@ -300,7 +303,8 @@ def new( """ if range_low >= range_high: - raise ValueError(f"Invalid arguments for range [{range_low}, {range_high}]") + _out_msg: str = f"Invalid arguments for range [{range_low}, {range_high}]" + raise ValueError(_out_msg) _alert_definition = { "rule": rule, @@ -335,6 +339,7 @@ def __init__(self, alert: MetricsRangeAlert) -> None: """Initialise definition with target alert.""" self._sv_obj = alert + @override def __eq__(self, other: "MetricsAlertDefinition") -> bool: """Compare a MetricsAlertDefinition with another.""" return all( @@ -346,6 +351,7 @@ def __eq__(self, other: "MetricsAlertDefinition") -> bool: ], ) + @override def __hash__(self) -> int: """Return definition hash.""" return hash(f"{self.aggregation}-{self.frequency}-{self.rule}-{self.window}") @@ -396,6 +402,7 @@ def frequency(self, frequency: int) -> None: class MetricThresholdAlertDefinition(MetricsAlertDefinition): """Alert definition for metric threshold alerts.""" + @override def __eq__(self, other: "MetricThresholdAlertDefinition") -> bool: """Compare this MetricThresholdAlertDefinition with another.""" if not super().__eq__(other): @@ -403,6 +410,7 @@ def __eq__(self, other: "MetricThresholdAlertDefinition") -> bool: return self.threshold == other.threshold + @override def __hash__(self) -> int: return hash(f"{super().__hash__()}+{self.threshold}") @@ -417,6 +425,7 @@ def threshold(self) -> float: class MetricRangeAlertDefinition(MetricsAlertDefinition): """Alert definition for metric range alerts.""" + @override def __eq__(self, other: "MetricRangeAlertDefinition") -> bool: """Compare a MetricRangeAlertDefinition with another.""" if not super().__eq__(other): @@ -429,6 +438,7 @@ def __eq__(self, other: "MetricRangeAlertDefinition") -> bool: ], ) + @override def __hash__(self) -> int: return hash(f"{super().__hash__()}+{self.range_high}+{self.range_low}") diff --git a/simvue/api/objects/alert/user.py b/simvue/api/objects/alert/user.py index 5c43febb..224518b9 100644 --- a/simvue/api/objects/alert/user.py +++ b/simvue/api/objects/alert/user.py @@ -67,6 +67,7 @@ def __init__( @override @classmethod @pydantic.validate_call + @override def new( cls, *, @@ -122,6 +123,7 @@ def _compare_objects(self, other: "AlertBase") -> bool: return super()._compare_objects(other) @classmethod + @override def get( cls, count: int | None = None, @@ -130,6 +132,7 @@ def get( """Return only UserAlerts.""" raise NotImplementedError("Retrieve of only user alerts is not yet supported") + @override def get_status(self, run_id: str) -> typing.Literal["ok", "critical"] | None: """Retrieve current alert status for the given run.""" if self._offline: @@ -137,12 +140,14 @@ def get_status(self, run_id: str) -> typing.Literal["ok", "critical"] | None: return super().get_status(run_id) + @override def on_reconnect(self, id_mapping: dict[str, str]) -> None: """Set status update on reconnect.""" for offline_id, status in self._staging.get("status", {}).items(): self.set_status(id_mapping.get(offline_id), status) @pydantic.validate_call + @override def set_status(self, run_id: str, status: typing.Literal["ok", "critical"]) -> None: """Set the status of this alert for a given run.""" if self._offline: diff --git a/simvue/api/objects/artifact/base.py b/simvue/api/objects/artifact/base.py index 53eafe3c..5b78a136 100644 --- a/simvue/api/objects/artifact/base.py +++ b/simvue/api/objects/artifact/base.py @@ -73,17 +73,21 @@ def __init__( # If the artifact is an online instance, need a place to store the response # from the initial creation - self._init_data: dict[str, dict] = {} + self._init_data: dict[str, dict[str, object]] = {} @classmethod - def new(cls, *_, **__) -> Self: + @override + def new(cls, **kwargs: typing.Any) -> Self: raise NotImplementedError + @override def commit(self) -> None: """Not applicable, cannot commit single write artifact.""" self._logger.info("Cannot call method 'commit' on write-once type 'Artifact'") - def attach_to_run(self, run_id: str, category: Category) -> None: + def attach_to_run( + self, run_id: str, category: Category + ) -> dict[str, object] | None: """Attach this artifact to a given run. Parameters @@ -98,8 +102,8 @@ def attach_to_run(self, run_id: str, category: Category) -> None: if self._offline: self._staging["runs"] = self._init_data["runs"] - super().commit() - return + _ = super().commit() + return None _run_artifacts_url = ( URL(self._user_config.server.url) @@ -113,12 +117,15 @@ def attach_to_run(self, run_id: str, category: Category) -> None: verify=self._user_config.server_verify, ) - get_json_from_response( + _json_response = get_json_from_response( expected_status=[http.HTTPStatus.OK], scenario=f"adding artifact '{self.name}' to run '{run_id}'", response=_response, ) + return typing.cast("dict[str, object]", _json_response) + + @override def on_reconnect(self, id_mapping: dict[str, str]) -> None: """Operations performed when artifact mode switched from offline to online. @@ -132,16 +139,18 @@ def on_reconnect(self, id_mapping: dict[str, str]) -> None: for _id, category in _offline_staging.items(): self.attach_to_run(run_id=id_mapping[_id], category=category) - def _upload(self, file: io.BytesIO, timeout: int, file_size: int) -> None: + def _upload( + self, file: io.BytesIO | io.BufferedReader, timeout: int | None, file_size: int + ) -> None: if self._offline: - super().commit() + _ = super().commit() return if not (_url := self._staging.get("url")): return if not timeout: - timeout = BASE_TIMEOUT + UPLOAD_TIMEOUT_PER_MB * file_size / 1024 / 1024 + timeout = BASE_TIMEOUT + UPLOAD_TIMEOUT_PER_MB * file_size // 1024 // 1024 self._logger.debug( "Will wait for a period of %s for upload of file for %s file to complete.", @@ -180,7 +189,7 @@ def _upload(self, file: io.BytesIO, timeout: int, file_size: int) -> None: _response.status_code, ) - get_json_from_response( + _ = get_json_from_response( expected_status=[http.HTTPStatus.OK, http.HTTPStatus.NO_CONTENT], allow_parse_failure=True, # JSON response from S3 not parsible scenario=f"uploading artifact '{_name}' to object storage", @@ -195,6 +204,7 @@ def _upload(self, file: io.BytesIO, timeout: int, file_size: int) -> None: super().commit() self.read_only(is_read_only=True) + @override def _get( self, storage: str | None = None, @@ -204,7 +214,7 @@ def _get( return super()._get( storage=storage or self._staging.get("server", {}).get("storage_id"), url=url, - **kwargs, + **kwargs, # pyright: ignore[reportArgumentType] ) @property @@ -225,7 +235,7 @@ def checksum(self) -> str: str """ - return self._get_attribute("checksum") + return typing.cast("str", self._get_attribute("checksum")) @property def storage_url(self) -> URL | None: @@ -236,7 +246,8 @@ def storage_url(self) -> URL | None: simvue.api.url.URL | None """ - return URL(_url) if (_url := self._init_data.get("url")) else None + _url = typing.cast("str | None", self._init_data.get("url")) + return URL(_url) if _url else None @property def original_path(self) -> str: @@ -247,7 +258,7 @@ def original_path(self) -> str: str """ - return self._get_attribute("original_path") + return typing.cast("str", self._get_attribute("original_path")) @property def storage_id(self) -> str | None: @@ -258,7 +269,7 @@ def storage_id(self) -> str | None: str | None """ - return self._get_attribute("storage_id") + return typing.cast("str | None", self._get_attribute("storage_id")) @property def mime_type(self) -> str: @@ -269,7 +280,7 @@ def mime_type(self) -> str: str """ - return self._get_attribute("mime_type") + return typing.cast("str", self._get_attribute("mime_type")) @property def size(self) -> int: @@ -280,7 +291,7 @@ def size(self) -> int: int """ - return self._get_attribute("size") + return typing.cast("int", self._get_attribute("size")) @property def name(self) -> str | None: @@ -291,7 +302,7 @@ def name(self) -> str | None: str | None """ - return self._get_attribute("name") + return typing.cast("str | None", self._get_attribute("name")) @property def created(self) -> datetime.datetime | None: @@ -302,7 +313,7 @@ def created(self) -> datetime.datetime | None: datetime.datetime | None """ - _created: str | None = self._get_attribute("created") + _created: str | None = typing.cast("str | None", self._get_attribute("created")) return ( datetime.datetime.strptime(_created, DATETIME_FORMAT).astimezone( datetime.timezone.utc, @@ -321,7 +332,7 @@ def uploaded(self) -> bool: bool """ - return self._get_attribute("uploaded") + return typing.cast("bool", self._get_attribute("uploaded")) @uploaded.setter @write_only @@ -339,7 +350,7 @@ def download_url(self) -> URL | None: simvue.api.url.URL | None """ - return self._get_attribute("url") + return typing.cast("URL", self._get_attribute("url")) @property def runs(self) -> Generator[str]: @@ -381,6 +392,7 @@ def get_category(self, run_id: str) -> Category: f"with respect to run '{run_id}'" ), ) + _json_response = typing.cast("dict[str, object]", _json_response) if _response.status_code == http.HTTPStatus.NOT_FOUND: raise ObjectNotFoundError( self.label(), @@ -388,7 +400,7 @@ def get_category(self, run_id: str) -> Category: extra=f"for run '{run_id}'", ) - return _json_response["category"] + return typing.cast("Category", _json_response["category"]) @pydantic.validate_call def download_content(self) -> Generator[bytes]: diff --git a/simvue/api/objects/artifact/fetch.py b/simvue/api/objects/artifact/fetch.py index 02d71f5d..8dbb3626 100644 --- a/simvue/api/objects/artifact/fetch.py +++ b/simvue/api/objects/artifact/fetch.py @@ -32,7 +32,8 @@ def check_column(cls, column: str) -> str: if column and ( column not in {"name", "created"} and not column.startswith("metadata.") ): - raise ValueError(f"Invalid sort column for artifacts '{column}'") + _out_msg: str = f"Invalid sort column for artifacts '{column}'" + raise ValueError(_out_msg) return column @@ -104,7 +105,7 @@ def from_run( Parameters ---------- run_id : str - The ID of the run to retriece artifacts from + The ID of the run to retrieve artifacts from category : Literal['input', 'output', 'code'] | None category of artifacts to return, if None, do not filter * input - this file is an input file. @@ -151,6 +152,8 @@ def from_run( scenario=f"Retrieval of artifacts for run '{run_id}'", ) + _json_response = typing.cast("list[dict[str, object]]", _json_response) + if _response.status_code == http.HTTPStatus.NOT_FOUND or not _json_response: raise ObjectNotFoundError( ArtifactBase.label, @@ -159,7 +162,7 @@ def from_run( ) for _entry in _json_response: - _id = _entry.pop("id") + _id = typing.cast("str | None", _entry.pop("id")) yield ( _id, Artifact(_local=True, _read_only=True, identifier=_id, **_entry), @@ -224,6 +227,7 @@ def from_name( expected_status=[http.HTTPStatus.OK], scenario=f"Retrieval of artifact '{name}' for run '{run_id}'", ) + _json_response = typing.cast("list[dict[str, object]]", _json_response) if _response.status_code == http.HTTPStatus.NOT_FOUND or not _json_response: raise ObjectNotFoundError( @@ -233,10 +237,11 @@ def from_name( ) if (_n_res := len(_json_response)) > 1 and not force_overwrite: - raise RuntimeError( + _out_msg: str = ( f"Expected single result for artifact '{name}' for run '{run_id}'" - f" but got {_n_res}", + + f" but got {_n_res}", ) + raise RuntimeError(_out_msg) _first_result: dict[str, typing.Any] = _json_response[0] _artifact_id: str = _first_result.pop("id") @@ -310,12 +315,17 @@ def get( expected_status=[http.HTTPStatus.OK], scenario=f"Retrieval of {_label}s", ) + _json_response = typing.cast("dict[str, object]", _json_response) + _data = typing.cast( + "list[dict[str, object]] | None", _json_response.get("data") + ) - if (_data := _json_response.get("data")) is None: - raise RuntimeError(f"Expected key 'data' for retrieval of {_label}s") + if _data is None: + _out_msg: str = f"Expected key 'data' for retrieval of {_label}s" + raise RuntimeError(_out_msg) for _entry in _data: - _id = _entry.pop("id") + _id = typing.cast("str", _entry.pop("id")) yield ( _id, Artifact(_local=True, _read_only=True, identifier=_id, **_entry), diff --git a/simvue/api/objects/artifact/file.py b/simvue/api/objects/artifact/file.py index 6550db56..e558a2b0 100644 --- a/simvue/api/objects/artifact/file.py +++ b/simvue/api/objects/artifact/file.py @@ -24,6 +24,17 @@ except ImportError: from typing_extensions import Self +try: + from typing import override +except ImportError: + from typing_extensions import override + + +try: + tz_utc = datetime.UTC +except AttributeError: + tz_utc = datetime.timezone.utc + class FileArtifact(ArtifactBase): """Simvue File Artifact. @@ -67,6 +78,7 @@ def __init__( ) @classmethod + @override def new( cls, *, @@ -121,11 +133,13 @@ def new( _mime_type = mime_type or get_mimetype_for_file(file_path) if _mime_type not in get_mimetypes(): - raise ValueError(f"Invalid MIME type '{mime_type}' specified") + _out_msg: str = f"Invalid MIME type '{mime_type}' specified" + raise ValueError(_out_msg) - if _file_orig_path := kwargs.pop("original_path", None): - _file_size = kwargs.pop("size") - _file_checksum = kwargs.pop("checksum") + _file_orig_path = typing.cast("str | None", kwargs.pop("original_path", None)) + if _file_orig_path: + _file_size = typing.cast("int", kwargs.pop("size")) + _file_checksum = typing.cast("str", kwargs.pop("checksum")) else: file_path = pathlib.Path(file_path) if snapshot: @@ -139,11 +153,14 @@ def new( "artifacts", ) _local_staging_dir.mkdir(parents=True, exist_ok=True) + _time_stamp: str = datetime.datetime.now( + tz=datetime.timezone.utc + ).strftime("%Y-%m-%d_%H-%M-%S_%f") _local_staging_file = _local_staging_dir.joinpath( - f"{file_path.stem}_" - f"{datetime.datetime.now(tz=datetime.timezone.utc).strftime('%Y-%m-%d_%H-%M-%S_%f')[:-3]}.file", + f"{file_path.stem}_{_time_stamp[:-3]}.file" ) - shutil.copy(file_path, _local_staging_file) + _local_staging_file = typing.cast("pathlib.Path", _local_staging_file) + _ = shutil.copy(file_path, _local_staging_file) file_path = _local_staging_file _file_size = file_path.stat().st_size @@ -162,17 +179,22 @@ def new( _offline=offline, _read_only=False, metadata=metadata, - **kwargs, + **kwargs, # pyright: ignore[reportArgumentType] ) _artifact._staging["file_path"] = str(file_path) if offline: _artifact._init_data = {} else: - _artifact._init_data = _artifact._post_single(**_artifact._staging) + _artifact._init_data = typing.cast( + "dict[str, dict[str, object]]", + _artifact._post_single(**_artifact._staging), + ) _artifact._staging["url"] = _artifact._init_data["url"] - _artifact._init_data["runs"] = kwargs.get("runs") or {} + _artifact._init_data["runs"] = typing.cast( + "dict[str, object]", kwargs.get("runs", {}) + ) if offline: return _artifact diff --git a/simvue/api/objects/artifact/object.py b/simvue/api/objects/artifact/object.py index 0a234df3..68658ee2 100644 --- a/simvue/api/objects/artifact/object.py +++ b/simvue/api/objects/artifact/object.py @@ -67,13 +67,14 @@ def __init__( @override @classmethod @pydantic.validate_call + @override def new( cls, *, name: typing.Annotated[str, pydantic.Field(pattern=NAME_REGEX)], storage: str | None, - obj: typing.Any, - metadata: dict[str, typing.Any] | None, + obj: object, + metadata: dict[str, object] | None, upload_timeout: int | None = None, allow_pickling: bool = True, offline: bool = False, @@ -132,13 +133,15 @@ def new( _serialization = serialize_object(obj, allow_pickle=allow_pickling) if not _serialization or not (_serialized := _serialization[0]): - raise ValueError(f"Could not serialize object of type '{type(obj)}'") + _out_msg: str = f"Could not serialize object of type '{type(obj)}'" + raise ValueError(_out_msg) if not (_data_type := _serialization[1]) and not allow_pickling: - raise ValueError( + _out_msg = ( f"Could not serialize object of type '{type(obj)}' " - "without pickling", + + "without pickling", ) + raise ValueError(_out_msg) _checksum = calculate_object_sha256(_serialized) @@ -165,10 +168,16 @@ def new( ).write_bytes(_serialized) else: - _artifact._init_data = _artifact._post_single(**_artifact._staging) + _single_post_data = typing.cast( + "dict[str, dict[str, object]]", + _artifact._post_single(**_artifact._staging), + ) + _artifact._init_data = _single_post_data _artifact._staging["url"] = _artifact._init_data["url"] - _artifact._init_data["runs"] = kwargs.get("runs") or {} + _artifact._init_data["runs"] = typing.cast( + "dict[str, object]", kwargs.get("runs") or {} + ) if offline: return _artifact diff --git a/simvue/api/objects/base.py b/simvue/api/objects/base.py index 457ba1ce..96ace600 100644 --- a/simvue/api/objects/base.py +++ b/simvue/api/objects/base.py @@ -3,6 +3,8 @@ Contains base class for interacting with objects on the Simvue server """ +from __future__ import annotations + import abc import http import inspect @@ -11,7 +13,11 @@ import types import typing import uuid -from collections.abc import Callable, Generator +from collections.abc import Generator + +if typing.TYPE_CHECKING: + import pathlib + from collections.abc import Callable import msgpack import pydantic @@ -37,9 +43,6 @@ from simvue.exception import ObjectNotFoundError from simvue.utilities import staging_merger -if typing.TYPE_CHECKING: - import pathlib - try: from typing import Self, override except ImportError: @@ -48,9 +51,13 @@ # Need to use this inside of Generator typing to # fix bug present in Python 3.10 - see issue #745 T = typing.TypeVar("T", bound="SimvueObject") +C = typing.TypeVar("C") +U = typing.TypeVar("U") -def staging_check(member_func: Callable) -> Callable: +def staging_check( + member_func: Callable[[typing.Any], typing.Any], +) -> Callable[[typing.Any], typing.Any]: """Decorator for checking if requested attribute has uncommitted changes.""" def _wrapper(self) -> typing.Any: @@ -80,14 +87,17 @@ def _wrapper(self) -> typing.Any: return _wrapper -def write_only(attribute_func: Callable) -> Callable: - def _wrapper(self: "SimvueObject", *args, **kwargs) -> typing.Any: +def write_only( + attribute_func: Callable[[typing.Any], typing.Any], +) -> Callable[[typing.Any], typing.Any]: + def _wrapper(self: SimvueObject, *args, **kwargs) -> typing.Any: _sv_obj = getattr(self, "_sv_obj", self) if _sv_obj.is_read_only: - raise AssertionError( + _out_msg: str = ( f"Cannot set property '{attribute_func.__name__}' " - f"on read-only object of type '{self.label()}'", + + f"on read-only object of type '{self.label()}'", ) + raise AssertionError(_out_msg) return attribute_func(self, *args, **kwargs) _wrapper.__name__ = attribute_func.__name__ @@ -102,7 +112,7 @@ def _wrapper(self: "SimvueObject", *args, **kwargs) -> typing.Any: class Visibility: """Interface for object visibility definition.""" - def __init__(self, sv_obj: "SimvueObject") -> None: + def __init__(self, sv_obj: SimvueObject) -> None: """Initialise visibility with target object.""" self._sv_obj = sv_obj @@ -176,24 +186,31 @@ def tenant(self, tenant: bool) -> None: class Sort(pydantic.BaseModel): + """Base class for performing sort during retrieval.""" + column: str descending: bool = True - def to_params(self) -> dict[str, str]: + def to_params(self) -> dict[str, str | bool]: + """Convert to RestAPI parameters.""" return {"id": self.column, "desc": self.descending} class VisibilityBatchArgs(pydantic.BaseModel): + """Class defining visibility arguments for batch upload.""" + tenant: bool | None = None user: list[str] | None = None public: bool | None = None class ObjectBatchArgs(pydantic.BaseModel): - pass + """Base class for object batch upload arguments.""" class SimvueObject(abc.ABC): + """Base class for all RestAPI objects.""" + def __init__( self, identifier: str | None, @@ -204,10 +221,11 @@ def __init__( _read_only: bool = True, _local: bool = False, _offline: bool = False, - **kwargs, + **kwargs: object, ) -> None: self._logger = logging.getLogger(f"simvue.{self.__class__.__name__}") self._local: bool = _local + self._read_only: bool = _read_only self._is_set: bool = False @@ -219,10 +237,10 @@ def __init__( self._identifier: str | None = ( identifier if identifier is not None else f"offline_{uuid.uuid1()}" ) - self._properties = [ + self._properties: list[str] = [ name for name, member in inspect.getmembers(self.__class__) - if isinstance(member, property) + if isinstance(member, property) and name not in ("label", "base_url") ] self._offline: bool = _offline or ( identifier is not None and identifier.startswith("offline_") @@ -277,7 +295,7 @@ def _get_local_staged(self) -> dict[str, typing.Any]: def _stage_to_other(self, obj_label: str, key: str, value: typing.Any) -> None: """Stage a change to another object type.""" with self._local_staging_file.open() as in_f: - _staged_data = json.load(in_f) + _staged_data = typing.cast("dict[str, typing.Any]", json.load(in_f)) if key not in _staged_data[obj_label]: _staged_data[key] = value @@ -339,10 +357,11 @@ def _get_attribute( ): self._staging[attribute] = _attribute return _attribute - raise AttributeError( + _out_msg: str = ( f"Could not retrieve attribute '{attribute}' " - f"for {self.label()} '{self._identifier}' from cached data", - ) from e + + f"for {self.label()} '{self._identifier}' from cached data", + ) + raise AttributeError(_out_msg) from e try: self._logger.debug( @@ -354,14 +373,16 @@ def _get_attribute( return self._get(url=url)[attribute] except KeyError as e: if self._offline: - raise AttributeError( + _out_msg = ( f"A value for attribute '{attribute}' has " - f"not yet been committed for offline {self.label()}" - f" '{self._identifier}'", - ) from e - raise RuntimeError( + + f"not yet been committed for offline {self.label()}" + + f" '{self._identifier}'", + ) + raise AttributeError(_out_msg) from e + _out_msg = ( f"Expected key '{attribute}' for {self.label()} '{self._identifier}'", - ) from e + ) + raise RuntimeError(_out_msg) from e def _clear_staging(self) -> None: self._staging = {} @@ -380,14 +401,16 @@ def _clear_staging(self) -> None: def get_visibility(self) -> dict[str, bool | list[str]]: try: - return self._get_attribute("visibility") + return typing.cast( + "dict[str, bool | list[str]]", self._get_attribute("visibility") + ) except AttributeError: return {} @classmethod @abc.abstractmethod - def new(cls, **_) -> Self: - pass + def new(cls, **_: typing.Any) -> Self: + """Define new instance of this object.""" @classmethod def batch_create( @@ -395,6 +418,7 @@ def batch_create( obj_args: ObjectBatchArgs, visibility: VisibilityBatchArgs, ) -> Generator[str]: + """Upload a set of objects.""" _, __ = obj_args, visibility raise NotImplementedError @@ -438,11 +462,12 @@ def ids( **kwargs, ): if (_data := response.get("data")) is None: - raise RuntimeError( + _out_msg: str = ( f"Expected key 'data' for retrieval of {cls.__name__.lower()}s", ) - for entry in _data: - yield entry["id"] + raise RuntimeError(_out_msg) + for entry in typing.cast("dict[str, object]", _data): + yield typing.cast("str", entry["id"]) _count += 1 if count and _count > count: return @@ -495,15 +520,16 @@ def get( if count and _count > count: return if (_data := _response.get("data")) is None: - raise RuntimeError( + _out_msg: str = ( f"Expected key 'data' for retrieval of {cls.__name__.lower()}s", ) + raise RuntimeError(_out_msg) # If data is an empty list if not _data: return - for entry in _data: + for entry in typing.cast("list[dict[str, object]]", _data): _id = entry["id"] yield ( _id, @@ -552,10 +578,11 @@ def count( **kwargs, ): if not (_count := _data.get("count")): - raise RuntimeError( + _out_msg: str = ( f"Expected key 'count' for retrieval of {cls.__name__.lower()}s", ) - _count_total += _count + raise RuntimeError(_out_msg) + _count_total += typing.cast("int", _count) return _count_total @classmethod @@ -568,7 +595,7 @@ def _get_all_objects( endpoint: str | None = None, expected_type: type = dict, **kwargs, - ) -> Generator[dict, None, None]: + ) -> Generator[dict[str, dict[str, object] | object], None, None]: _config: SimvueConfiguration = SimvueConfiguration.fetch( mode="online", server_url=server_url, @@ -597,16 +624,21 @@ def _get_all_objects( ) if expected_type is dict: - yield _generator + yield typing.cast("dict[str, object]", _generator) else: - yield from _generator + yield from typing.cast("list[dict[str, object]]", _generator) @property def is_read_only(self) -> bool: """Returns if this instance is in read-only mode.""" return self._read_only - def read_only(self, is_read_only: bool, *, clear_staged: bool = True) -> None: # noqa: FBT001 + def read_only( + self, + is_read_only: bool, # # ruff: ignore[boolean-type-hint-positional-argument] + *, + clear_staged: bool = True, + ) -> None: """Set whether this object is in read only state. Parameters @@ -626,7 +658,7 @@ def read_only(self, is_read_only: bool, *, clear_staged: bool = True) -> None: if not self.is_read_only and clear_staged: self._staging = self._get_local_staged() - def commit(self) -> dict | list[dict] | None: + def commit(self) -> list[dict[str, str]] | dict[str, str] | None: """Send updates to the server, or if offline, store locally.""" if self.is_read_only: raise AttributeError("Cannot commit object in 'read-only' mode") @@ -647,7 +679,10 @@ def commit(self) -> dict | list[dict] | None: # if staging is empty then we do not need to use PUT if not self._identifier or self._identifier.startswith("offline_"): # If batch upload send as list, else send as dictionary of params - if _batch_commit := self._staging.get("batch"): + _batch_commit = typing.cast( + "list[ObjectBatchArgs] | None", self._staging.get("batch") + ) + if _batch_commit is not None: self._logger.debug( "Posting batched data to server: %s %s", len(_batch_commit), @@ -669,7 +704,7 @@ def commit(self) -> dict | list[dict] | None: self.id, self._staging, ) - _response = self._put(**self._staging) + _response = typing.cast("dict[str, str]", self._put(**self._staging)) # Clear staged changes self._clear_staging() @@ -725,10 +760,11 @@ def _post_batch( ) if _response.status_code == http.HTTPStatus.FORBIDDEN: - raise RuntimeError( + _out_msg: str = ( "Forbidden: You do not have permission to " - f"create object of type '{self.label()}'", + + f"create object of type '{self.label()}'", ) + raise RuntimeError(_out_msg) _json_response = get_json_from_response( response=_response, @@ -737,24 +773,25 @@ def _post_batch( expected_type=list, ) - if not len(batch_data) == (_n_created := len(_json_response)): - raise RuntimeError( - "Expected %s to be created, but only %s found.", - len(batch_data), - _n_created, + if len(batch_data) != (_n_created := len(_json_response)): + _out_msg = ( + f"Expected {len(batch_data)} to be created, but only " + + f"{_n_created} found.", ) + raise RuntimeError(_out_msg) self._logger.debug("successfully created %s %s", _n_created, f"{self.label()}s") - return _json_response + return typing.cast("list[dict[str, str]]", _json_response) def _post_single( self, *, is_json: bool = True, - data: list | dict | None = None, + data: list[dict[str, object]] | dict[str, object] | None = None, **kwargs, ) -> dict[str, typing.Any] | list[dict[str, typing.Any]]: + # Remove any extra keys for key in self._local_only_args: _ = (data or kwargs).pop(key, None) @@ -772,10 +809,11 @@ def _post_single( ) if _response.status_code == http.HTTPStatus.FORBIDDEN: - raise RuntimeError( + _out_msg: str = ( "Forbidden: You do not have permission to create " - f"object of type '{self.label()}'", + + f"object of type '{self.label()}'", ) + raise RuntimeError(_out_msg) _json_response = get_json_from_response( response=_response, @@ -783,7 +821,11 @@ def _post_single( scenario=f"Creation of {self.label()}", ) - if _id := _json_response.get("id"): + if isinstance(_json_response, list): + raise TypeError("Expected dictionary from JSON response but got type list") + + _id = typing.cast("str | None", _json_response.get("id")) + if _id: self._logger.debug("'%s' created successfully", _id) self._identifier = _id elif not self._is_set: @@ -798,7 +840,7 @@ def _post_single( return _json_response - def _put(self, **kwargs) -> dict[str, typing.Any]: + def _put(self, **kwargs: object) -> dict[str, object]: if not self.url: raise RuntimeError(f"Identifier for instance of {self.label()} Unknown") @@ -815,10 +857,11 @@ def _put(self, **kwargs) -> dict[str, typing.Any]: ) if _response.status_code == http.HTTPStatus.FORBIDDEN: - raise RuntimeError( + _out_msg: str = ( "Forbidden: You do not have permission to " - f"create object of type '{self.label()}'", + + f"create object of type '{self.label()}'", ) + raise RuntimeError(_out_msg) return get_json_from_response( response=_response, @@ -826,7 +869,7 @@ def _put(self, **kwargs) -> dict[str, typing.Any]: scenario=f"Creation of {self.label()} '{self._identifier}", ) - def delete(self, **kwargs) -> dict[str, typing.Any]: + def delete(self, **kwargs: object) -> dict[str, object]: """Delete the server entry for this object. Returns @@ -859,7 +902,7 @@ def delete(self, **kwargs) -> dict[str, typing.Any]: ) self._logger.debug("'%s' deleted successfully", self._identifier) - return _json_response + return typing.cast("dict[str, object]", _json_response) def _get( self, @@ -868,7 +911,7 @@ def _get( allow_parse_failure: bool = False, **kwargs, ) -> dict[str, typing.Any]: - if self._identifier.startswith("offline_"): + if (self._identifier or "").startswith("offline_"): return self._get_local_staged() if not self.url: @@ -896,11 +939,12 @@ def _get( self._logger.debug("'%s' retrieved successfully", self._identifier) if not isinstance(_json_response, dict): - raise TypeError( + _out_msg: str = ( "Expected dictionary from JSON response " - f"during {self.label()} retrieval " - f"but got '{type(_json_response)}'", + + f"during {self.label()} retrieval " + + f"but got '{type(_json_response)}'", ) + raise TypeError(_out_msg) return _json_response def refresh(self) -> None: @@ -918,7 +962,7 @@ def _cache(self) -> None: with self._local_staging_file.open() as in_f: _local_data = json.load(in_f) - staging_merger.merge(_local_data, self._staging) + _ = staging_merger.merge(_local_data, self._staging) with self._local_staging_file.open("w", encoding="utf-8") as out_f: json.dump(_local_data, out_f, indent=2) @@ -970,11 +1014,12 @@ def endpoint(cls) -> str: @override def __str__(self) -> str: - """String representation of Simvue object.""" + """Represent Simvue object as string.""" return f"{self.__class__.__name__}({self.id=})" @override def __repr__(self) -> str: + """Represent Simvue object as Python repr format.""" _out_str = f"{self.__class__.__module__}.{self.__class__.__qualname__}(" _property_values: list[str] = [] _property_warn_list: list[str] = [] diff --git a/simvue/api/objects/events.py b/simvue/api/objects/events.py index affe6ebd..1a156baa 100644 --- a/simvue/api/objects/events.py +++ b/simvue/api/objects/events.py @@ -19,9 +19,13 @@ from .base import SimvueObject try: - from typing import Self + from typing import Self, override except ImportError: - from typing_extensions import Self + from typing_extensions import Self, override + + +if typing.TYPE_CHECKING: + from simvue.api.url import URL if typing.TYPE_CHECKING: from simvue.api.url import URL @@ -72,6 +76,7 @@ def __init__( @classmethod @pydantic.validate_call + @override def get( cls, run_id: str, @@ -126,19 +131,21 @@ def get( **kwargs, ): if (_data := response.get("data")) is None: - raise RuntimeError( + _out_msg: str = ( "Expected key 'data' for retrieval of " - f"{_class_instance.__class__.__name__.lower()}s", + + f"{_class_instance.__class__.__name__.lower()}s", ) + raise RuntimeError(_out_msg) - for _entry in _data: - yield EventSet(**_entry) + for _entry in typing.cast("list[dict[str, object]]", _data): + yield EventSet(**_entry) # pyright: ignore[reportArgumentType] _count += 1 - if _count > count: + if count and _count > count: return @classmethod @pydantic.validate_call + @override def new( cls, *, @@ -179,13 +186,19 @@ def new( server_token=server_token, _read_only=False, _offline=offline, - **kwargs, + **kwargs, # pyright: ignore[reportArgumentType] ) - def _post_single(self, **kwargs) -> dict[str, typing.Any]: - return super()._post_single(is_json=False, **kwargs) + @override + def _post_single( + self, + data: list[dict[str, object]] | dict[str, object] | None = None, + **kwargs: object, + ) -> dict[str, typing.Any] | list[dict[str, typing.Any]]: + return super()._post_single(is_json=False, data=data, **kwargs) - def _put(self, **kwargs) -> dict[str, typing.Any]: + @override + def _put(self, **_: object) -> dict[str, typing.Any]: raise NotImplementedError("Method 'put' is not available for type Events") @pydantic.validate_call @@ -223,7 +236,7 @@ def histogram( _time_begin: str = simvue_timestamp(timestamp_begin) _time_end: str = simvue_timestamp(timestamp_end) _response = sv_get( - url=_url, + url=f"{_url}", headers=self._headers, verify=self._user_config.server_verify, params={ @@ -239,9 +252,19 @@ def histogram( scenario="Retrieval of events histogram", response=_response, ) - return _json_response.get("data") + _json_response = typing.cast("dict[str, object]", _json_response) + + _data = typing.cast( + "list[dict[str, str | int]] | None", _json_response.get("data") + ) + + if not _data: + raise RuntimeError("Expected key 'data' in response for histogram request.") + + return _data - def delete(self, **kwargs) -> dict[str, typing.Any]: + @override + def delete(self, **kwargs: object) -> dict[str, typing.Any]: """Event set deletion not implemented. Raises @@ -252,6 +275,7 @@ def delete(self, **kwargs) -> dict[str, typing.Any]: """ raise NotImplementedError("Cannot delete event set") - def on_reconnect(self, id_mapping: dict[str, str]): + @override + def on_reconnect(self, id_mapping: dict[str, str]) -> None: if online_run_id := id_mapping.get(self._staging["run"]): self._staging["run"] = online_run_id diff --git a/simvue/api/objects/folder.py b/simvue/api/objects/folder.py index 8147696b..c0aa9a80 100644 --- a/simvue/api/objects/folder.py +++ b/simvue/api/objects/folder.py @@ -44,7 +44,8 @@ def check_column(cls, column: str) -> str: and column not in {"created", "modified", "path"} and not column.startswith("metadata.") ): - raise ValueError(f"Invalid sort column for folders '{column}") + _out_msg: str = f"Invalid sort column for folders '{column}" + raise ValueError(_out_msg) return column @@ -91,6 +92,7 @@ def __init__( @classmethod @pydantic.validate_call + @override def new( cls, *, @@ -112,6 +114,7 @@ def new( @classmethod @pydantic.validate_call + @override def get( cls, *, @@ -166,7 +169,7 @@ def filter(cls) -> FoldersFilter: return _filter_instance @override - def commit(self) -> dict | list[dict] | None: + def commit(self) -> dict[str, object] | list[dict[str, object]] | None: if "starred" in self._staging: _star_run: bool = self._staging.pop("starred") self._set_favourite(starred=_star_run) @@ -183,13 +186,13 @@ def tree(self) -> dict[str, object]: """ _level: int = len(self.path.split("/")) - _folders = self.__class__.get( + _folders: Generator[tuple[str, Folder]] = self.__class__.get( filters=json.dumps([f"path contains {self.path}"]), ) _paths = [folder.path.split("/") for _, folder in _folders] _paths = sorted(_paths, key=len) _out_dict: dict[str, object] = {} - _modifier = None + _modifier: dict[str, object] = {} for path in _paths: if len(path) <= _level: continue @@ -245,14 +248,18 @@ def name(self, name: str) -> None: @property @staging_check - def metadata(self) -> dict[str, int | str | float | dict | None] | None: + def metadata( + self, + ) -> dict[str, int | str | float | dict[str, object] | None] | None: """Return the folder metadata.""" return self._get().get("metadata") @metadata.setter @write_only @pydantic.validate_call - def metadata(self, metadata: dict[str, int | float | str | dict | None]) -> None: + def metadata( + self, metadata: dict[str, int | float | str | dict[str, object] | None] + ) -> None: """Update the folder metadata.""" self._staging["metadata"] = metadata @@ -282,13 +289,30 @@ def ttl(self, time_seconds: int) -> None: """Update the retention period for this folder.""" self._staging["ttl"] = time_seconds - def delete( # should params to this be optional and default to False? + @override + def delete( self, *, recursive: bool | None = False, delete_runs: bool | None = False, runs_only: bool | None = False, ) -> dict[str, typing.Any]: + """Delete a folder and its contents. + + Parameters + ---------- + recursive : bool, optional + whether to delete any sub-folders, default is False. + delete_runs : bool, optional + whether to delete any runs in this folder, default is False. + runs_only : bool, optional + whether to delete only runs, default is False. + + Returns + ------- + dict[str, Any] + information on the deleted content. + """ return super().delete( recursive=recursive, runs=delete_runs, @@ -307,7 +331,7 @@ def created(self) -> datetime.datetime | None: else None ) - def _set_favourite(self, *, starred: bool) -> dict: + def _set_favourite(self, *, starred: bool) -> dict[str, object]: """Set starred status.""" _url = self.url / "starred" _response = sv_put( diff --git a/simvue/api/objects/grids.py b/simvue/api/objects/grids.py index 814bb76c..86475b29 100644 --- a/simvue/api/objects/grids.py +++ b/simvue/api/objects/grids.py @@ -58,7 +58,7 @@ def check_ordered_array( ValueError if the values are unordered """ - if isinstance(array, np.ndarray): + if not isinstance(array, list): array = array.tolist() for i, element in enumerate(array): _array = np.array(element) @@ -130,12 +130,15 @@ def attach_metric_for_run(self, run_id: str, metric_name: str) -> None: return get_json_from_response( expected_status=[http.HTTPStatus.OK], scenario=( - f"Adding '{metric_name}' to grid " - f"'{self._identifier}' to run '{run_id}'", + ( + f"Adding '{metric_name}' to grid " + f"'{self._identifier}' to run '{run_id}'" + ), ), response=_response, ) + @override def on_reconnect(self, id_mapping: dict[str, str]) -> None: """Operations performed when this grid is switched from offline to online mode. @@ -165,14 +168,17 @@ def on_reconnect(self, id_mapping: dict[str, str]) -> None: @property def grid(self) -> list[list[float]]: + """Return the grid as list.""" return self._get_attribute("grid") @property def name(self) -> str: + """Retrieve the name.""" return self._get_attribute("name") @classmethod @pydantic.validate_call(config={"arbitrary_types_allowed": True}) + @override def new( cls, *, @@ -219,10 +225,13 @@ def new( """ if len(labels) != len(grid): - raise AssertionError( - "Length of argument 'labels' must match first " - f"grid dimension {len(grid)}.", + _out_msg = ( + ( + "Length of argument 'labels' must match first " + f"grid dimension {len(grid)}." + ), ) + raise AssertionError(_out_msg) return cls( grid=grid, @@ -241,13 +250,13 @@ def dimensions(self) -> tuple[int, int]: return len(self.grid) def run_data_url(self, run_id: str) -> URL: - """Returns the URL for grid data for a specific run.""" + """Return the URL for grid data for a specific run.""" return URL( f"{self._user_config.server.url}/runs/{run_id}/grids/{self._identifier}", ) def run_metric_url(self, run_id: str, metric_name: str) -> URL: - """Returns the URL for the values for a given run metric.""" + """Return the URL for the values for a given run metric.""" return URL( f"{self._user_config.server.url}/runs/{run_id}/metrics/{metric_name}/", ) @@ -259,7 +268,7 @@ def get_run_metric_values( run_id: str, metric_name: str, step: int, - ) -> dict: + ) -> dict[str, float | bool]: """Retrieve values for grid given run at a given step. Parameters @@ -289,13 +298,17 @@ def get_run_metric_values( expected_status=[http.HTTPStatus.OK], expected_type=dict, scenario=( - f"Retrieving '{metric_name}' grid values " - f"for run '{run_id}' at step {step}", + ( + f"Retrieving '{metric_name}' grid values " + f"for run '{run_id}' at step {step}" + ), ), ) @pydantic.validate_call - def get_run_metric_span(self, *, run_id: str, metric_name: str) -> dict: + def get_run_metric_span( + self, *, run_id: str, metric_name: str + ) -> dict[str, int | float | str]: """Retrieve span for this grid from the server for a given run. Parameters @@ -333,6 +346,7 @@ def get_run_metric_span(self, *, run_id: str, metric_name: str) -> dict: return _response[0] @classmethod + @override def get( cls, *_, @@ -381,10 +395,11 @@ def __init__( @staticmethod def run_grids_endpoint(run: str | None = None) -> URL: - """Returns the URL for grids for a specific run.""" + """Return the URL for grids for a specific run.""" return URL(f"runs/{run}/metrics/") - def _get_attribute(self, attribute: str, *default) -> typing.Any: + @override + def _get_attribute(self, attribute: str, *default: object) -> typing.Any: return super()._get_attribute( attribute, *default, @@ -394,6 +409,7 @@ def _get_attribute(self, attribute: str, *default) -> typing.Any: @override @classmethod @pydantic.validate_call + @override def new( cls, *, @@ -439,6 +455,7 @@ def new( @override @classmethod @pydantic.validate_call + @override def get( cls, *, @@ -492,11 +509,13 @@ def get( server_token=server_token, ) - def commit(self) -> dict | None: + @override + def commit(self) -> dict[str, object] | None: if not (_run_staging := self._staging.pop("data", None)): return None return self._log_values(_run_staging) + @override def on_reconnect(self, id_mapping: dict[str, str]) -> None: """Operations performed when grid metrics object switched mode switched. diff --git a/simvue/api/objects/metrics.py b/simvue/api/objects/metrics.py index a1fb3ce2..d4cd4e86 100644 --- a/simvue/api/objects/metrics.py +++ b/simvue/api/objects/metrics.py @@ -68,6 +68,7 @@ def __init__( @override @classmethod @pydantic.validate_call + @override def new( cls, *, @@ -113,6 +114,7 @@ def new( @override @classmethod @pydantic.validate_call + @override def get( cls, metrics: list[str], @@ -169,7 +171,7 @@ def get( @pydantic.validate_call def span(self, run_ids: list[str]) -> dict[str, int | float]: """Returns the metrics span for the given runs""" - _url = self._base_url / "span" + _url = self.base_url / "span" _response = sv_get( url=f"{_url}", headers=self._headers, @@ -199,15 +201,18 @@ def names(self, run_ids: list[str]) -> list[str]: expected_type=list, ) - def _post_single(self, **kwargs) -> dict[str, typing.Any]: + @override + def _post_single(self, **kwargs: object) -> dict[str, typing.Any]: return super()._post_single(is_json=False, **kwargs) + @override def delete(self, **kwargs) -> dict[str, typing.Any]: """Metrics cannot be deleted.""" raise NotImplementedError("Cannot delete metric set") - def on_reconnect(self, id_mapping: dict[str, str]): - """Action performed when mode switched from offline to online. + @override + def on_reconnect(self, id_mapping: dict[str, str]) -> None: + """Execute when mode switched from offline to online. Parameters ---------- @@ -218,6 +223,7 @@ def on_reconnect(self, id_mapping: dict[str, str]): if online_run_id := id_mapping.get(self._staging["run"]): self._staging["run"] = online_run_id + @override def to_dict(self) -> dict[str, typing.Any]: """Convert metrics object to dictionary. diff --git a/simvue/api/objects/run.py b/simvue/api/objects/run.py index 6468a28e..eed17b4f 100644 --- a/simvue/api/objects/run.py +++ b/simvue/api/objects/run.py @@ -69,7 +69,8 @@ def check_column(cls, column: str) -> str: and not column.startswith("metadata.") and column not in {"created", "started", "endtime", "modified"} ): - raise ValueError(f"Invalid sort column for runs '{column}'") + _out_msg: str = f"Invalid sort column for runs '{column}'" + raise ValueError(_out_msg) return column @@ -140,7 +141,7 @@ def filter(cls) -> RunsFilter: return _run_filter @override - def commit(self) -> dict | list[dict] | None: + def commit(self) -> dict[str, object] | list[dict[str, object]] | None: if "starred" in self._staging: _star_run: bool = self._staging.pop("starred") self._set_favourite(starred=_star_run) @@ -148,6 +149,7 @@ def commit(self) -> dict | list[dict] | None: @classmethod @pydantic.validate_call + @override def new( cls, *, @@ -216,6 +218,7 @@ def new( @classmethod @pydantic.validate_call + @override def batch_create( cls, entries: Iterable[RunBatchArgs], @@ -337,7 +340,7 @@ def status(self, status: Status) -> None: @property @staging_check - def ttl(self) -> int: + def ttl(self) -> int | None: """Set/retrieve the retention period for this run. Returns @@ -399,7 +402,7 @@ def user(self) -> str: @property @staging_check - def description(self) -> str: + def description(self) -> str | None: """Set/retrieve the description for this run. Returns @@ -460,7 +463,9 @@ def notifications(self) -> typing.Literal["none", "all", "error", "lost"]: "none" | "all" | "error" | "lost" """ - return self._get_attribute("notifications")["state"] + return typing.cast("dict[str, object]", self._get_attribute("notifications"))[ + "state" + ] @notifications.setter @write_only @@ -489,6 +494,7 @@ def alerts(self) -> list[str]: @override @classmethod @pydantic.validate_call + @override def get( cls, *, @@ -560,7 +566,7 @@ def get_alert_details(self) -> Generator[dict[str, typing.Any]]: "Cannot get alert details from an offline run - " "use .alerts to access a list of IDs instead", ) - for alert in self._get_attribute("alerts"): + for alert in typing.cast("dict[str, object]", self._get_attribute("alerts")): yield alert["alert"] @property @@ -633,7 +639,7 @@ def star(self, is_true: bool = True) -> None: """Star this folder as a favourite.""" self._staging["starred"] = is_true - def _set_favourite(self, *, starred: bool) -> dict: + def _set_favourite(self, *, starred: bool) -> dict[str, object]: """Set starred status.""" _url = self.url / "starred" _response = sv_put( @@ -689,7 +695,9 @@ def metrics( Generator[tuple[str, dict[str, int | float | bool]] """ - yield from self._get_attribute("metrics").items() + yield from typing.cast( + "dict[str, dict[str, object]]", self._get_attribute("metrics") + ).items() @property def events( @@ -707,7 +715,9 @@ def events( Generator[tuple[str, dict[str, Any]] """ - yield from self._get_attribute("events").items() + yield from typing.cast( + "dict[str, dict[str, object]]", self._get_attribute("events") + ).items() @write_only def send_heartbeat(self) -> dict[str, typing.Any] | None: @@ -781,7 +791,7 @@ def abort_trigger(self) -> bool: headers=self._headers, verify=self._user_config.server_verify, ) - _json_response = get_json_from_response( + _json_response: dict[str, bool] = get_json_from_response( response=_response, expected_status=[http.HTTPStatus.OK], scenario=f"Retrieving abort status for run '{self.id}'", @@ -841,7 +851,7 @@ def grids(self) -> list[dict[str, str]]: ) @pydantic.validate_call - def abort(self, reason: str) -> dict[str, typing.Any]: + def abort(self, reason: str) -> dict[str, object]: """Trigger an abort for this run by notifying the server. Parameters @@ -851,7 +861,7 @@ def abort(self, reason: str) -> dict[str, typing.Any]: Returns ------- - dict[str, Any] + dict[str, object] server response after updating abort status. Raises @@ -876,8 +886,9 @@ def abort(self, reason: str) -> dict[str, typing.Any]: response=_response, ) + @override def on_reconnect(self, id_mapping: dict[str, str]) -> None: - """Executed when a run switches from offline to online mode. + """Execute when a run switches from offline to online mode. Parameters ---------- @@ -892,6 +903,6 @@ def on_reconnect(self, id_mapping: dict[str, str]) -> None: if _id.startswith("offline") }, ) - if not all(online_alert_ids): + if not all(set(online_alert_ids)): raise KeyError("Could not find alert ID in offline to online ID mapping.") self._staging["alerts"] = online_alert_ids diff --git a/simvue/api/objects/stats.py b/simvue/api/objects/stats.py index 9cf96dd5..4f1e13ab 100644 --- a/simvue/api/objects/stats.py +++ b/simvue/api/objects/stats.py @@ -16,6 +16,11 @@ from .base import SimvueObject +try: + from typing import override +except ImportError: + from typing_extensions import override + __all__ = ["Stats"] @@ -69,6 +74,7 @@ def __init__( # set it to empty string so not None self._identifier = "" + @override @classmethod def new(cls, **_) -> None: """Creation of multiple stats objects is not logical here. @@ -80,6 +86,7 @@ def new(cls, **_) -> None: """ raise AttributeError("Creation of statistics objects is not supported") + @override @classmethod def delete(cls, **_) -> None: """Deletion of stats object is not logical here. @@ -91,6 +98,7 @@ def delete(cls, **_) -> None: """ raise AttributeError("Deletion of statistics is not supported") + @override def read_only(self) -> None: """Statistics can only be read-only. @@ -101,6 +109,7 @@ def read_only(self) -> None: """ raise NotImplementedError("Statistics are not modifiable.") + @override def id(self) -> None: """No unique indentifier for statistics retrieval. @@ -111,9 +120,12 @@ def id(self) -> None: """ return - def on_reconnect(self, **_) -> None: + @override + def on_reconnect(self, id_mapping: dict[str, str]) -> None: """No offline to online reconnect functionality for statistics.""" + _ = id_mapping + @override @classmethod def get(cls, **_) -> None: """Retrieval of multiple stats object is not logical here. @@ -127,6 +139,7 @@ def get(cls, **_) -> None: "Retrieval of multiple of statistics objects is not supported", ) + @override @classmethod def ids(cls, **_) -> None: """Retrieval of identifiers is not logical here. @@ -161,6 +174,7 @@ def get_run_stats(self) -> dict[str, int]: """Retrieve the run statistics.""" return self._get_attribute("runs") + @override def _get_local_staged(self) -> dict[str, typing.Any]: """No staging for stats so returns empty dict.""" return {} @@ -169,8 +183,9 @@ def _get_visibility(self) -> dict[str, bool | list[str]]: """Visibility does not apply here.""" return {} + @override def to_dict(self) -> dict[str, typing.Any]: - """Dictionary form of current user statistics. + """Return dictionary form of current user statistics. Returns ------- @@ -181,6 +196,18 @@ def to_dict(self) -> dict[str, typing.Any]: return {"runs": self.get_run_stats()} def admin_stats(self, *, tenant: str | None = None) -> dict[str, dict[str, int]]: + """Return adminstrator level stats. + + Parameters + ---------- + tenant : str, optional + specify tenant for stats retrieval. + + Returns + ------- + dict[str, dict[str, int]] + statistics of server objects for each user. + """ return { name: UserStatistics(**entry) for name, entry in self._get( @@ -189,6 +216,7 @@ def admin_stats(self, *, tenant: str | None = None) -> dict[str, dict[str, int]] ).items() } + @override def commit(self) -> None: """Does nothing, no data sendable to server.""" diff --git a/simvue/api/objects/storage/base.py b/simvue/api/objects/storage/base.py index c5656708..ae26666a 100644 --- a/simvue/api/objects/storage/base.py +++ b/simvue/api/objects/storage/base.py @@ -1,5 +1,4 @@ """Simvue Storage Base. -=================== Contains general definitions for Simvue Storage objects. """ @@ -29,6 +28,7 @@ class StorageBase(SimvueObject): _label: str = "storage" _endpoint: str = "storage" + _type: str @override def __init__( @@ -47,6 +47,7 @@ def __init__( **kwargs, ) + @override @classmethod def new( cls, @@ -57,6 +58,11 @@ def new( ) -> Self: """Create a new instance of a storage type.""" + @property + def type(self) -> str: + """Return storage type.""" + return self._type + @property @staging_check def name(self) -> str: diff --git a/simvue/api/objects/storage/fetch.py b/simvue/api/objects/storage/fetch.py index 5a4b221f..4971ae1c 100644 --- a/simvue/api/objects/storage/fetch.py +++ b/simvue/api/objects/storage/fetch.py @@ -73,7 +73,8 @@ def __new__( **kwargs, ) - raise RuntimeError(f"Unknown backend '{_storage_pre.backend}'") + _out_msg: str = f"Unknown backend '{_storage_pre.backend}'" + raise RuntimeError(_out_msg) @classmethod @pydantic.validate_call @@ -126,7 +127,7 @@ def get( ) _label: str = _class_instance.__class__.__name__.lower() _label = _label.replace("base", "") - _json_response = get_json_from_response( + _json_response: list[dict[str, object]] = get_json_from_response( response=_response, expected_status=[http.HTTPStatus.OK], scenario=f"Retrieval of {_label}s", @@ -134,7 +135,7 @@ def get( ) for _entry in _json_response: - _id = _entry.pop("id") + _id = typing.cast("str", _entry.pop("id")) if _entry["backend"] == "S3": yield ( _id, diff --git a/simvue/api/objects/storage/file.py b/simvue/api/objects/storage/file.py index dcdb0646..efcdd657 100644 --- a/simvue/api/objects/storage/file.py +++ b/simvue/api/objects/storage/file.py @@ -25,6 +25,8 @@ class FileStorage(StorageBase): """ + _type: str = "file" + @override def __init__( self, @@ -63,6 +65,7 @@ def __init__( @override @classmethod @pydantic.validate_call + @override def new( cls, *, diff --git a/simvue/api/objects/storage/s3.py b/simvue/api/objects/storage/s3.py index 86b09fea..7f4a1e5b 100644 --- a/simvue/api/objects/storage/s3.py +++ b/simvue/api/objects/storage/s3.py @@ -4,6 +4,8 @@ """ +from __future__ import annotations + import typing try: @@ -26,6 +28,8 @@ class S3Storage(StorageBase): """ + _type: str = "s3" + @override def __init__( self, @@ -72,6 +76,7 @@ def __init__( @override @classmethod + @override @pydantic.validate_call def new( cls, @@ -170,9 +175,12 @@ def new( def get_config(self) -> dict[str, typing.Any]: """Retrieve configuration.""" try: - return self._get_attribute("config") + _config: dict[str, float | str | None] = typing.cast( + "dict[str, float | str | None]", self._get_attribute("config") + ) except AttributeError: return {} + return _config class Config: @@ -193,12 +201,18 @@ def endpoint_url(self) -> str | None: the endpoint for this storage object if applicable """ + if not self._sv_obj: + raise RuntimeError("Expected S3Storage instance but none found.") + try: - return self._sv_obj.get_config()["endpoint_url"] + _http_url: str = typing.cast( + "str", self._sv_obj.get_config()["endpoint_url"] + ) except KeyError as e: raise RuntimeError( "Expected key 'endpoint_url' in alert definition retrieval", ) from e + return _http_url @endpoint_url.setter @write_only @@ -228,7 +242,7 @@ def region_name(self, region_name: str) -> None: def bucket(self) -> str: """Retrieve the bucket label for this storage.""" try: - return self._sv_obj.get_config()["bucket"] + return typing.cast("str", self._sv_obj.get_config()["bucket"]) except KeyError as e: raise RuntimeError( "Expected key 'bucket' in alert definition retrieval", @@ -239,10 +253,11 @@ def bucket(self) -> str: @pydantic.validate_call def bucket(self, bucket: str) -> None: """Modify the bucket label for this storage.""" - if self._sv_obj.type == "file": - raise ValueError( + if self._sv_obj.type != "s3": + _out_msg: str = ( f"Cannot set attribute 'bucket' for storage type '{self._sv_obj.type}'", ) + raise ValueError(_out_msg) _config = self._sv_obj.get_config() | {"bucket": bucket} self._sv_obj.append_to_staging({"config": _config}) diff --git a/simvue/api/objects/tag.py b/simvue/api/objects/tag.py index 49e03f8c..27c221ff 100644 --- a/simvue/api/objects/tag.py +++ b/simvue/api/objects/tag.py @@ -172,6 +172,7 @@ def created(self) -> datetime.datetime | None: @override @classmethod @pydantic.validate_call + @override def get( cls, *, diff --git a/simvue/api/request.py b/simvue/api/request.py index a4514a09..807166a5 100644 --- a/simvue/api/request.py +++ b/simvue/api/request.py @@ -23,6 +23,14 @@ from simvue.utilities import parse_validation_response + +class Seekable(typing.Protocol): + def seek(self, offset: int, whence: int = 0) -> int: ... + + +FileValue = Seekable | tuple[str, Seekable] + + DEFAULT_API_TIMEOUT = 10 RETRY_MULTIPLIER = 1 RETRY_MIN = 4 @@ -49,8 +57,12 @@ class RetryableHTTPError(Exception): def _rewind_request_streams(retry_state: RetryCallState) -> None: """Rewind file-like request bodies before retrying.""" - files = retry_state.kwargs.get("files") or {} - streams = (*files.values(), retry_state.kwargs.get("data")) + _kwargs = typing.cast("dict[str, typing.Any]", retry_state.kwargs) + _files = typing.cast("dict[str, FileValue]", _kwargs.get("files") or {}) + streams: tuple[FileValue | None, ...] = ( + *_files.values(), + retry_state.kwargs.get("data"), + ) for value in streams: stream = value[1] if isinstance(value, tuple) else value @@ -76,7 +88,7 @@ def post( *, headers: dict[str, str], params: dict[str, str], - data: typing.Any, + data: object, is_json: bool = True, timeout: int | None = None, verify: str | bool = True, @@ -111,27 +123,30 @@ def post( """ if is_json: - data_sent: str | dict[str, typing.Any] = json_module.dumps(data) + _data_sent: str | dict[str, typing.Any] | object = json_module.dumps(data) headers = set_json_header(headers) else: - data_sent = data + _data_sent = data response = requests.post( url, headers=headers, params=params, - data=data_sent, + data=_data_sent, # pyright: ignore[reportArgumentType] timeout=timeout, files=files, verify=verify, ) if response.status_code == http.HTTPStatus.UNPROCESSABLE_ENTITY: - _parsed_response = parse_validation_response(response.json()) - raise ValueError( + _parsed_response = parse_validation_response( + typing.cast("dict[str, typing.Any]", response.json()) + ) + _out_msg = ( f"Validation error for '{url}' " - f"[{response.status_code}]:\n{_parsed_response}", + + f"[{response.status_code}]:\n{_parsed_response}" ) + raise ValueError(_out_msg) if response.status_code in RETRY_STATUSES: raise RetryableHTTPError( @@ -191,7 +206,7 @@ def put( """ if is_json and data: - data_sent: str | dict[str, typing.Any] = json_module.dumps(data) + data_sent: str | dict[str, typing.Any] | object = json_module.dumps(data) headers = set_json_header(headers) else: data_sent = data @@ -231,7 +246,7 @@ def get( url: str, *, headers: dict[str, str] | None = None, - params: dict[str, str | int | float | None] | None = None, + params: dict[str, str | int | float | list[str] | None] | None = None, timeout: int = DEFAULT_API_TIMEOUT, json: dict[str, typing.Any] | None = None, verify: str | bool = True, @@ -334,21 +349,23 @@ def get_json_from_response( scenario: str, response: requests.Response, allow_parse_failure: bool = False, - expected_type: type[dict | list] = dict, -) -> dict | list: + expected_type: type[dict[str, object] | list[object]] = dict, +) -> dict[str, object] | list[dict[str, object]]: try: - json_response = response.json() + json_response: ( + list[dict[str, object]] | dict[str, str | object] | typing.Any | None + ) = response.json() json_response = json_response or ({} if expected_type is dict else []) decode_error = "" except requests.exceptions.JSONDecodeError as e: json_response = {} if allow_parse_failure else None - decode_error = f"{e}" + decode_error = f": {e}" error_str = f"{scenario} failed for url '{response.url}'" details: str | None = None if (_status_code := response.status_code) in expected_status: - if not isinstance(json_response, expected_type): + if json_response and not isinstance(json_response, expected_type): details = ( f"expected type '{expected_type.__name__}' " f"but got '{type(json_response).__name__}'" @@ -356,10 +373,10 @@ def get_json_from_response( elif json_response is not None: return json_response else: - details = f"could not request JSON response: {decode_error}" + details = f"could not request JSON response{decode_error}" elif isinstance(json_response, dict): error_str += f" with status {_status_code}" - details = (json_response or {}).get("detail") + details = typing.cast("str", (json_response or {}).get("detail")) try: txt_response = response.text @@ -382,7 +399,7 @@ def get_paginated( json: dict[str, typing.Any] | None = None, count: int | None = None, offset: int | None = None, - **params, + **params: str | float | list[str] | None, ) -> Generator[requests.Response]: """Paginate results of a server query. @@ -415,6 +432,7 @@ def get_paginated( # if the count requested is below page limit use this value # else if undefined or greater than the page limit use the limit _request_count: int = min(count or MAX_ENTRIES_PER_PAGE, MAX_ENTRIES_PER_PAGE) + _response: requests.Response | None = None try: while ( @@ -430,11 +448,13 @@ def get_paginated( _offset += MAX_ENTRIES_PER_PAGE if (count and _offset > count) or ( - _response.json().get("count", 0) < _offset + typing.cast("dict[str, int]", _response.json()).get("count", 0) + < _offset ): break except requests.exceptions.JSONDecodeError: + _code: str = f"[{_response.status_code}] " if _response else "" raise RuntimeError( - f"[{_response.status_code}] Failed to retrieve content from server: " - + _response.text, + f"{_code}Failed to retrieve content from server" + + (f": {_response.text}" if _response else ""), ) from None diff --git a/simvue/api/url.py b/simvue/api/url.py index aa73a701..744593db 100644 --- a/simvue/api/url.py +++ b/simvue/api/url.py @@ -5,9 +5,9 @@ """ try: - from typing import Self + from typing import Self, override except ImportError: - from typing_extensions import Self + from typing_extensions import Self, override import copy import urllib.parse @@ -20,14 +20,14 @@ class URL: @pydantic.validate_call def __init__(self, url: str) -> None: """Initialise a url from string form.""" - url = url.removesuffix("/") + _url = url.removesuffix("/") - _url = urllib.parse.urlparse(url) - self._scheme: str = _url.scheme - self._path: str = _url.path - self._host: str | None = _url.hostname - self._port: int | None = _url.port - self._fragment: str = _url.fragment + _parsed_url = urllib.parse.urlparse(_url) + self._scheme: str = _parsed_url.scheme + self._path: str = _parsed_url.path + self._host: str | None = _parsed_url.hostname + self._port: int | None = _parsed_url.port + self._fragment: str = _parsed_url.fragment def __truediv__(self, other: str) -> Self: """Define URL extension through use of '/'.""" @@ -35,6 +35,7 @@ def __truediv__(self, other: str) -> Self: _new /= other return _new + @override def __repr__(self) -> str: """Representation of URL.""" _out_str = f"{self.__class__.__module__}.{self.__class__.__qualname__}" @@ -51,24 +52,30 @@ def __itruediv__(self, other: str) -> Self: @property def scheme(self) -> str: + """Return URL scheme.""" return self._scheme @property def path(self) -> str: + """Return URL path.""" return self._path @property def hostname(self) -> str | None: + """Return URL hostname.""" return self._host @property def fragment(self) -> str: + """Return URL fragment.""" return self._fragment @property def port(self) -> int | None: + """Return URL port.""" return self._port + @override def __str__(self) -> str: """Construct string form of the URL.""" _out_str: str = "" diff --git a/simvue/bin/sender.py b/simvue/bin/sender.py index 17cce125..d09f4bc0 100644 --- a/simvue/bin/sender.py +++ b/simvue/bin/sender.py @@ -58,6 +58,7 @@ def sender_cli( threading_threshold: int, max_workers: int, ) -> None: + """Execute sender for upload of local items to server.""" try: _logger.info("Starting Simvue Sender") _sender = Sender( @@ -67,6 +68,6 @@ def sender_cli( throw_exceptions=True, ) _sender.upload(objects_to_upload) - except Exception as err: # noqa: BLE001 + except Exception as err: # ruff: ignore[blind-except] _logger.critical("Exception running sender: %s", err) raise click.Abort from None diff --git a/simvue/client.py b/simvue/client.py index 187c3c9e..2cfa5acb 100644 --- a/simvue/client.py +++ b/simvue/client.py @@ -6,6 +6,7 @@ import contextlib import http +import itertools import json import logging import pathlib @@ -18,8 +19,12 @@ from pandas import DataFrame from simvue.api.objects.alert.base import AlertBase +from simvue.api.objects.alert.fetch import AlertSort from simvue.exception import ObjectNotFoundError +if typing.TYPE_CHECKING: + from simvue.api.objects.artifact.base import ArtifactBase + from .api.objects import ( Alert, Artifact, @@ -55,13 +60,14 @@ def _download_artifact_to_file( output_dir: pathlib.Path | None, ) -> None: if not artifact.name: - raise RuntimeError(f"Expected artifact '{artifact.id}' to have a name") + _out_msg: str = f"Expected artifact '{artifact.id}' to have a name" + raise RuntimeError(_out_msg) _output_file = (output_dir or pathlib.Path.cwd()).joinpath(artifact.name) # If this is a hierarchical structure being downloaded, need to create directories _output_file.parent.mkdir(parents=True, exist_ok=True) with _output_file.open("wb") as out_f: for content in artifact.download_content(): - out_f.write(content) + _ = out_f.write(content) class Client: @@ -300,7 +306,7 @@ def get_runs( if output_format == "objects": return _runs - run_objs: list[Run] = [run for _, run in _runs] + run_objs: list[Run] = [run for _, run in _runs or []] response_data = [run.to_dict() for run in run_objs] if output_format == "dict": @@ -310,7 +316,7 @@ def get_runs( @prettify_pydantic @pydantic.validate_call - def delete_run(self, run_id: str) -> dict | None: + def delete_run(self, run_id: str) -> dict[str, object] | None: """Delete run by identifier. Parameters @@ -386,9 +392,10 @@ def _get_folder_id_from_path(self, path: str) -> str | None: return None if next(_ids, None): - raise RuntimeError( - f"Expected single folder match for '{path}', but found duplicate.", + _out_msg: str = ( + f"Expected single folder match for '{path}', but found duplicate." ) + raise RuntimeError(_out_msg) return _id @@ -397,7 +404,7 @@ def _get_folder_id_from_path(self, path: str) -> str | None: def delete_runs( self, folder_path: typing.Annotated[str, pydantic.Field(pattern=FOLDER_REGEX)], - ) -> list | None: + ) -> list[dict[str, object]] | None: """Delete runs in a named folder. Parameters @@ -418,9 +425,10 @@ def delete_runs( """ if not (_folder := self._get_folder_from_path(folder_path)): - raise ValueError(f"Could not find a folder matching '{folder_path}'") + _out_msg: str = f"Could not find a folder matching '{folder_path}'" + raise ValueError(_out_msg) _delete = _folder.delete(runs_only=True, delete_runs=True, recursive=False) - return _delete.get("runs", []) + return typing.cast("list[dict[str, object]]", _delete.get("runs", [])) @prettify_pydantic @pydantic.validate_call @@ -431,7 +439,7 @@ def delete_folder( recursive: bool = False, remove_runs: bool = False, allow_missing: bool = False, - ) -> list | None: + ) -> list[dict[str, object]] | None: """Delete a folder by name. Parameters @@ -480,7 +488,7 @@ def delete_folder( if folder_id not in _response.get("folders", []): raise RuntimeError("Deletion of folder failed, server returned mismatch.") - return _response.get("runs", []) + return typing.cast("list[dict[str, object]]", _response.get("runs", [])) @prettify_pydantic @pydantic.validate_call @@ -519,7 +527,7 @@ def list_artifacts( Yields ------ - str, Artifact + str, FileArtifact | ObjectArtifact ID and artifact entry for relevant artifacts Raises @@ -554,7 +562,9 @@ def _retrieve_artifacts_from_server( @prettify_pydantic @pydantic.validate_call - def abort_run(self, run_id: str, reason: str) -> dict | list: + def abort_run( + self, run_id: str, reason: str + ) -> dict[str, object] | list[dict[str, object]]: """Abort a currently active run on the server. Parameters @@ -566,7 +576,7 @@ def abort_run(self, run_id: str, reason: str) -> dict | list: Returns ------- - dict | list + dict response from server """ @@ -698,7 +708,7 @@ def get_artifacts_as_files( if there was a failure retrieving artifacts from the server """ - _artifacts: Generator[tuple[str, Artifact]] = Artifact.from_run( + _artifacts: Generator[tuple[str, ArtifactBase]] = Artifact.from_run( server_url=self._user_config.server.url, server_token=self._user_config.server.token, run_id=run_id, @@ -848,7 +858,7 @@ def _get_run_metrics_from_server( xaxis: str, aggregate: bool, max_points: int | None = None, - ) -> dict[str, typing.Any]: + ) -> dict[str, object]: params: dict[str, str | int | None] = { "runs": json.dumps(run_ids), "aggregate": aggregate, @@ -864,12 +874,14 @@ def _get_run_metrics_from_server( timeout=DEFAULT_API_TIMEOUT, ) - return get_json_from_response( + _json_response = get_json_from_response( expected_status=[http.HTTPStatus.OK], scenario=f"Retrieval of metrics '{metric_names}' in runs '{run_ids}'", response=metrics_response, ) + return typing.cast("dict[str, object]", _json_response) + @prettify_pydantic @pydantic.validate_call def get_metric_values( @@ -883,7 +895,7 @@ def get_metric_values( use_run_names: bool = False, aggregate: bool = False, max_points: pydantic.PositiveInt | None = None, - ) -> dict | DataFrame | None: + ) -> "dict[str, dict[tuple[float, str], float]] | DataFrame | None": """Retrieve the values for a given metric across multiple runs. Uses filters to specify which runs should be retrieved. @@ -939,20 +951,24 @@ def get_metric_values( "'xaxis=timestamp'", ) - _args = {"filters": json.dumps(run_filters)} if run_filters else {} + _args: dict[str, str | list[object]] = ( + {"filters": json.dumps(run_filters)} if run_filters else {} + ) + + _run_data: dict[str, object] = {} if not run_ids: - _run_data = dict(Run.get(**_args)) + _run_data = dict(Run.get(**_args)) # pyright: ignore[reportArgumentType] - if not ( - _run_metrics := self._get_run_metrics_from_server( - metric_names=metric_names, - run_ids=run_ids or list(_run_data.keys()), - xaxis=xaxis, - aggregate=aggregate, - max_points=max_points, - ) - ): + _run_metrics: dict[str, object] = self._get_run_metrics_from_server( + metric_names=metric_names, + run_ids=run_ids or list(_run_data.keys()), + xaxis=xaxis, + aggregate=aggregate, + max_points=max_points, + ) + + if not _run_metrics: return None if aggregate: return aggregated_metrics_to_dataframe( @@ -977,8 +993,10 @@ def get_metric_values( def plot_metrics( self, run_ids: list[str], - metric_names: list[str], - xaxis: typing.Literal["step", "time"], + metric_names: list[ + typing.Annotated[str, pydantic.StringConstraints(pattern=NAME_REGEX)] + ], + xaxis: typing.Literal["step", "time", "timestamp"], *, max_points: int | None = None, ) -> typing.Any: @@ -1006,7 +1024,7 @@ def plot_metrics( if invalid arguments are provided """ - data: DataFrame = self.get_metric_values( + _data: DataFrame = self.get_metric_values( run_ids=run_ids, metric_names=metric_names, xaxis=xaxis, @@ -1015,40 +1033,40 @@ def plot_metrics( aggregate=False, ) - if data is None: - raise RuntimeError( + if _data is None: + _out_msg: str = ( f"Cannot plot metrics {metric_names}, " - f"no data found for runs {run_ids}.", + + f"no data found for runs {run_ids}.", ) + raise RuntimeError(_out_msg) # Undo multi-indexing - flattened_df = data.reset_index() + flattened_df = _data.reset_index() - import matplotlib.pyplot as plt # noqa: PLC0415 + import matplotlib.pyplot as plt # ruff: ignore[import-outside-top-level] - for run in run_ids: - for name in metric_names: - label = None - if len(run_ids) > 1 and len(metric_names) > 1: - label = f"{run}: {name}" - elif len(run_ids) > 1 and len(metric_names) == 1: - label = run - elif len(run_ids) == 1 and len(metric_names) > 1: - label = name + for run, name in itertools.product(run_ids, metric_names): + label = None + if len(run_ids) > 1 and len(metric_names) > 1: + label = f"{run}: {name}" + elif len(run_ids) > 1 and len(metric_names) == 1: + label = run + elif len(run_ids) == 1 and len(metric_names) > 1: + label = name - flattened_df.plot(y=name, x=xaxis, label=label) + _ = flattened_df.plot(y=name, x=xaxis, label=label) if xaxis == "step": - plt.xlabel("Steps") + _ = plt.xlabel("Steps") elif xaxis == "time": - plt.xlabel("Relative Time") + _ = plt.xlabel("Relative Time") if xaxis == "step": - plt.xlabel("steps") + _ = plt.xlabel("steps") elif xaxis == "timestamp": - plt.xlabel("Time") + _ = plt.xlabel("Time") if len(metric_names) == 1: - plt.ylabel(metric_names[0]) + _ = plt.ylabel(metric_names[0]) return plt.figure() @@ -1086,33 +1104,35 @@ def get_events( if there was a failure retrieving information from the server """ - msg_filter: str = ( + _msg_filter: str = ( json.dumps([f"event.message contains {message_contains}"]) if message_contains else "" ) - params: dict[str, str | int] = { + _params: dict[str, str | int] = { "run": run_id, - "filters": msg_filter, + "filters": _msg_filter, "start": start_index or 0, "count": count_limit or 0, } - response = requests.get( + _response = requests.get( f"{self._user_config.server.url}/events", headers=self._headers, - params=params, + params=_params, timeout=DEFAULT_API_TIMEOUT, ) - json_response = get_json_from_response( + _json_response = get_json_from_response( expected_status=[http.HTTPStatus.OK], scenario=f"Retrieval of events for run '{run_id}'", - response=response, + response=_response, ) - return json_response.get("data", []) + _json_response = typing.cast("dict[str, object]", _json_response) + + return typing.cast("list[dict[str, str]]", _json_response.get("data", [])) @prettify_pydantic @pydantic.validate_call @@ -1160,12 +1180,28 @@ def get_alerts( """ if not run_id: if critical_only: - raise RuntimeError( + _out_msg: str = ( "critical_only is ambiguous when returning alerts " - "with no run ID specified.", + + "with no run ID specified.", ) + raise RuntimeError(_out_msg) + _sorting = ( + [ + AlertSort(column=column, descending=descending) + for column, descending in sort_by_columns + ] + if sort_by_columns + else None + ) + if names_only: + return [ + alert.name + for _, alert in Alert.get( + sorting=_sorting, count=count_limit, offset=start_index + ) + ] return [ - alert.name if names_only else alert + alert for _, alert in Alert.get( sorting=[ dict(zip(("column", "descending"), a, strict=True)) @@ -1194,8 +1230,14 @@ def get_alerts( for alert in Run(identifier=run_id).get_alert_details() ] + if names_only: + return [ + alert.name + for alert in _alerts + if not critical_only or alert.get_status(run_id) == "critical" + ] return [ - alert.name if names_only else alert + alert for alert in _alerts if not critical_only or alert.get_status(run_id) == "critical" ] diff --git a/simvue/config/__init__.py b/simvue/config/__init__.py index bbe90e1c..49d7ff94 100644 --- a/simvue/config/__init__.py +++ b/simvue/config/__init__.py @@ -3,3 +3,7 @@ This module contains definitions for the Simvue configuration options """ + +from .user import SimvueConfiguration + +__all__ = ["SimvueConfiguration"] diff --git a/simvue/config/parameters.py b/simvue/config/parameters.py index d9eb66f5..c553c319 100644 --- a/simvue/config/parameters.py +++ b/simvue/config/parameters.py @@ -56,6 +56,8 @@ def check_for_cert_env( class ServerSpecifications(pydantic.BaseModel): + """Specify server configurations.""" + model_config: typing.ClassVar[pydantic.ConfigDict] = BASE_MODEL_CONFIG | { "extra": "forbid" } @@ -89,28 +91,38 @@ def check_token(cls, v: pydantic.SecretStr | None) -> pydantic.SecretStr | None: class OfflineSpecifications(pydantic.BaseModel): + """Specify offline mode configurations.""" + cache: pathlib.Path | None = None model_config: typing.ClassVar[pydantic.ConfigDict] = BASE_MODEL_CONFIG @pydantic.field_validator("cache") @classmethod def check_valid_cache_path(cls, cache: pathlib.Path) -> pathlib.Path: + """Check cache path is valid.""" if not cache.parent.exists(): - raise FileNotFoundError(f"No such directory '{cache.parent}'.") + _out_msg: str = f"No such directory '{cache.parent}'." + raise FileNotFoundError(_out_msg) if not cache.parent.is_dir(): - raise FileNotFoundError(f"'{cache.parent}' is not a directory.") + _out_msg = f"'{cache.parent}' is not a directory." + raise FileNotFoundError(_out_msg) if not os.access(cache.parent, os.W_OK): - raise AssertionError(f"'{cache.parent}' is not a writable location.") + _out_msg = f"'{cache.parent}' is not a writable location." + raise AssertionError(_out_msg) return cache class MetricsSpecifications(pydantic.BaseModel): + """Specify metric configurations.""" + system_metrics_interval: pydantic.PositiveInt | None = -1 enable_emission_metrics: bool = False model_config: typing.ClassVar[pydantic.ConfigDict] = BASE_MODEL_CONFIG class DefaultRunSpecifications(pydantic.BaseModel): + """Specify run default configurations.""" + name: str | None = None description: str | None = None tags: list[str] | None = None @@ -122,5 +134,7 @@ class DefaultRunSpecifications(pydantic.BaseModel): class ClientGeneralOptions(pydantic.BaseModel): + """Specify client options.""" + debug: bool = False model_config: typing.ClassVar[pydantic.ConfigDict] = BASE_MODEL_CONFIG diff --git a/simvue/config/user.py b/simvue/config/user.py index ceffff2a..bed41753 100644 --- a/simvue/config/user.py +++ b/simvue/config/user.py @@ -45,12 +45,16 @@ SIMVUE_SERVER_UPPER_CONSTRAINT: semver.Version | None = semver.Version.parse("2.0.0") SIMVUE_SERVER_LOWER_CONSTRAINT: semver.Version | None = semver.Version.parse("1.1.0") +__all__ = ["SimvueConfiguration"] + if typing.TYPE_CHECKING: from collections.abc import Generator class SimvueConfiguration(pydantic.BaseModel): + """Configure session.""" + # Hide values as they contain token and URL model_config = pydantic.ConfigDict( hide_input_in_errors=True, @@ -93,7 +97,7 @@ def nosim_version(self) -> semver.Version | None: return self._nosim_version @classmethod - def _load_pyproject_configs(cls) -> dict | None: + def _load_pyproject_configs(cls) -> dict[str, object] | None: """Recover any Simvue non-authentication configurations from pyproject.toml.""" _pyproject_toml = sv_util.find_first_instance_of_file( file_names=["pyproject.toml"], @@ -180,19 +184,19 @@ def _check_server( _url = URL(url) / "version" _response = sv_get(f"{_url}", headers=headers, verify=verify) - if _response.status_code == http.HTTPStatus.UNAUTHORIZED: - raise AssertionError("Unauthorised token") - - if _response.status_code != http.HTTPStatus.OK or not ( - _version_str := _response.json().get("version") - ): - raise AssertionError("Failed to retrieve version from server response.") - except Exception as err: raise AssertionError( f"Exception retrieving server version:\n {err!s}", ) from err + if _response.status_code == http.HTTPStatus.UNAUTHORIZED: + raise AssertionError("Unauthorised token") + + if _response.status_code != http.HTTPStatus.OK or not ( + _version_str := _response.json().get("version") + ): + raise AssertionError("Failed to retrieve version from server response.") + _nosim_version_str: str = "" _nosim_version: semver.Version | None = None @@ -215,22 +219,32 @@ def _check_server( SIMVUE_SERVER_UPPER_CONSTRAINT and _version >= SIMVUE_SERVER_UPPER_CONSTRAINT ): - raise AssertionError( + _out_msg: str = ( f"Python API v{__version__} is not compatible " - "with the current Simvue server version: " - f"{_version_str} >= {SIMVUE_SERVER_UPPER_CONSTRAINT}", + + "with the current Simvue server version: " + + f"{_version_str} >= {SIMVUE_SERVER_UPPER_CONSTRAINT}", ) + raise AssertionError(_out_msg) + if SIMVUE_SERVER_LOWER_CONSTRAINT and _version < SIMVUE_SERVER_LOWER_CONSTRAINT: - raise AssertionError( + _out_msg = ( f"Python API v{__version__} is not compatible " - "with the current Simvue server version: " - f"{_version_str} < {SIMVUE_SERVER_LOWER_CONSTRAINT}", + + "with the current Simvue server version: " + + f"{_version_str} < {SIMVUE_SERVER_LOWER_CONSTRAINT}", ) + raise AssertionError(_out_msg) return _version, _nosim_version @pydantic.validate_call def write(self, out_directory: pydantic.DirectoryPath) -> None: + """Write configuration to file. + + Parameters + ---------- + output_directory : pathlib.path + location to write configuration file. + """ with out_directory.joinpath(CONFIG_FILE_NAMES[0]).open("w") as out_f: toml.dump(self.model_dump(), out_f) @@ -283,7 +297,9 @@ def fetch( object containing configurations """ - _config_dict: dict[str, dict[str, str]] = cls._load_pyproject_configs() or {} + _config_dict: dict[str, dict[str, str | dict[str, str]]] = ( + cls._load_pyproject_configs() or {} + ) profile = os.environ.get("SIMVUE_SERVER_PROFILE", profile) @@ -299,10 +315,11 @@ def fetch( if not profile: _config_dict["server"] = _config_dict.get("server", {}) elif not _config_dict.get("profiles", {}).get(profile): - raise RuntimeError( + _out_msg: str = ( f"Cannot load server configuration for '{profile}', " - "profile not found in configurations.", + + "profile not found in configurations.", ) + raise RuntimeError(_out_msg) else: _config_dict["server"] = _config_dict["profiles"][profile] @@ -323,9 +340,10 @@ def fetch( # Ranking of configurations for token and URl is: # Environment Variables > Run Definition > Configuration File + _server_opts: dict[str, str] = _config_dict["server"] _server_url = os.environ.get( "SIMVUE_URL", - server_url or _config_dict["server"].get("url"), + server_url or _server_opts.get("url"), ) if isinstance(_server_url, URL): @@ -333,7 +351,7 @@ def fetch( _server_token = os.environ.get( "SIMVUE_TOKEN", - server_token or _config_dict["server"].get("token"), + server_token or _server_opts.get("token"), ) _run_mode = mode or _config_dict["run"].get("mode") or "online" @@ -344,8 +362,8 @@ def fetch( if not _server_token and _run_mode != "offline": raise RuntimeError("No server token was specified") - _config_dict["server"]["token"] = _server_token - _config_dict["server"]["url"] = _server_url + _config_dict["server"]["token"] = _server_token # pyright: ignore[reportIndexIssue] + _config_dict["server"]["url"] = _server_url # pyright: ignore[reportIndexIssue] _config_dict["run"]["mode"] = _run_mode _user_config = SimvueConfiguration(current_profile=profile, **_config_dict) @@ -387,6 +405,7 @@ def config_file(cls) -> pathlib.Path: @property def headers(self) -> dict[str, str]: + """Return online API call headers.""" if not self.server.token: raise ValueError("Cannot generate headers, no token provided.") return { diff --git a/simvue/converters.py b/simvue/converters.py index e839089e..a1e77720 100644 --- a/simvue/converters.py +++ b/simvue/converters.py @@ -10,11 +10,13 @@ import pandas as pd if typing.TYPE_CHECKING: + from collections.abc import Iterable, Mapping + from pd import DataFrame def aggregated_metrics_to_dataframe( - request_response_data: dict[str, list[dict[str, float]]], + request_response_data: dict[str, object], xaxis: str, parse_to: typing.Literal["dict", "dataframe"] = "dict", ) -> typing.Union["DataFrame", dict[str, dict[tuple[float, str], float]] | None]: @@ -43,7 +45,9 @@ def aggregated_metrics_to_dataframe( _all_steps: list[float] = sorted( { d[xaxis] - for sublist in request_response_data.values() + for sublist in typing.cast( + "Iterable[list[str]]", request_response_data.values() + ) for d in sublist if xaxis in d }, @@ -55,10 +59,12 @@ def aggregated_metrics_to_dataframe( _value_types = list(_value_types) _value_types.remove(xaxis) - result_dict: dict[str, dict[tuple[float, str], float]] | None = { + result_dict: dict[str, dict[tuple[float, str], float | None]] = { metric_name: {} for metric_name in request_response_data } + metrics: list[dict[str, float]] + for metric_name, metrics in request_response_data.items(): metrics_iterator = iter(metrics) _metric_steps = (d[xaxis] for d in metrics) @@ -77,13 +83,12 @@ def aggregated_metrics_to_dataframe( _data_frame = pd.DataFrame(result_dict) _data_frame.index.name = xaxis return _data_frame - if parse_to == "dict": - return result_dict - raise ValueError(f"Unrecognised parse format '{parse_to}'") + + return result_dict def parse_run_set_metrics( - request_response_data: dict[str, dict[str, list[dict[str, float]]]], + request_response_data: dict[str, object], xaxis: str, run_labels: list[str], parse_to: typing.Literal["dict", "dataframe"] = "dict", @@ -120,10 +125,14 @@ def parse_run_set_metrics( if not request_response_data: return pd.DataFrame({}) if parse_to == "dataframe" else {} + _request_values: Iterable[dict[str, list[dict[str, float]]]] = ( + request_response_data.values() + ) + _all_steps: list[float] = sorted( { d[xaxis] - for run_data in request_response_data.values() + for run_data in _request_values for sublist in run_data.values() for d in sublist if xaxis in d @@ -131,58 +140,56 @@ def parse_run_set_metrics( ) _all_metrics: list[str] = sorted( - {key for run_data in request_response_data.values() for key in run_data}, + {key for run_data in _request_values for key in run_data}, ) # Get the keys from the aggregate which are not the xaxis label - _first_run = next(iter(request_response_data.values())) + _first_run = next(iter(_request_values)) _first_metric_set = next(iter(_first_run.values())) _value_types = next(iter(_first_metric_set)).keys() _value_types = list(_value_types) _value_types.remove(xaxis) - result_dict: dict[str, dict[tuple[float, str], float]] | None = { + _result_dict: dict[str, dict[tuple[float, str], float | None]] = { metric_name: {} for metric_name in _all_metrics } for run_label, run_data in zip( run_labels, - request_response_data.values(), + _request_values, strict=True, ): for metric_name in _all_metrics: if metric_name not in run_data: for step in _all_steps: - result_dict[metric_name][step, run_label] = None + _result_dict[metric_name][step, run_label] = None continue metrics = run_data[metric_name] metrics_iterator = iter(metrics) _metric_steps = (d[xaxis] for d in metrics) for step in _all_steps: if step not in _metric_steps: - result_dict[metric_name][step, run_label] = None + _result_dict[metric_name][step, run_label] = None else: next_item = next(metrics_iterator) - result_dict[metric_name][step, run_label] = next_item.get("value") + _result_dict[metric_name][step, run_label] = next_item.get("value") if parse_to == "dataframe": return pd.DataFrame( - result_dict, + _result_dict, index=pd.MultiIndex.from_product( [_all_steps, run_labels], names=(xaxis, "run"), ), ) - if parse_to == "dict": - return result_dict - raise ValueError(f"Unrecognised parse format '{parse_to}'") + return _result_dict def to_dataframe(data) -> pd.DataFrame: """Convert runs to dataframe.""" - metadata = [] - system_columns = [] - columns = { + _metadata: list[dict[str, object]] = [] + _system_columns: list[str] = [] + _columns = { "name": [], "status": [], "folder": [], @@ -192,31 +199,36 @@ def to_dataframe(data) -> pd.DataFrame: } for run in data: - for item in run.get("metadata", []): - if item not in metadata: - metadata.append(item) - for item, value in (run.get("system", {}) or {}).items(): + _meta: list[str] = typing.cast("list[str]", run.get("metadata", [])) + _system: dict[str, dict[str, str] | str] = typing.cast( + "dict[str, dict[str, str] | str]", run.get("system", {}) or {} + ) + for item in _meta: + if item not in _metadata: + _metadata.append(item) + for item, value in _system.items(): if isinstance(value, dict): - system_columns += [ + _system_columns += [ col_name for sub_item in value - if (col_name := f"system.{item}.{sub_item}") not in system_columns + if (col_name := f"system.{item}.{sub_item}") not in _system_columns ] - elif f"system.{item}" not in system_columns: - system_columns.append(f"system.{item}") + elif f"system.{item}" not in _system_columns: + _system_columns.append(f"system.{item}") - columns |= {f"metadata.{column}": [] for column in metadata} | { - column: [] for column in system_columns + _columns |= {f"metadata.{column}": [] for column in _metadata} | { + column: [] for column in _system_columns } for run in data: - run_info = flatdict.FlatDict(run, delimiter=".") - for column, value_ in columns.items(): + _run_info: Mapping[str, str] = flatdict.FlatDict(run, delimiter=".") + for column, value_ in _columns.items(): try: - value_.append(run_info.get(column)) + _column_val = typing.cast("str", _run_info.get(column)) + value_.append(_column_val) except TypeError: value_.append(None) - return pd.DataFrame(data=columns) + return pd.DataFrame(data=_columns) def metric_time_series_to_dataframe( diff --git a/simvue/dispatch/base.py b/simvue/dispatch/base.py index dd5c57ba..c2d0c0fd 100644 --- a/simvue/dispatch/base.py +++ b/simvue/dispatch/base.py @@ -50,7 +50,7 @@ def add_item( *, object_type: str, metadata: dict[str, int | float] | None = None, - **__, + **_, ) -> None: """Add an item to the dispatcher. diff --git a/simvue/dispatch/direct.py b/simvue/dispatch/direct.py index 750c76f0..6164f132 100644 --- a/simvue/dispatch/direct.py +++ b/simvue/dispatch/direct.py @@ -5,6 +5,11 @@ from .base import DispatcherBaseClass +try: + from typing import override +except ImportError: + from typing_extensions import override + class DirectDispatcher(DispatcherBaseClass): """The DirectDispatcher executes the provided callback immediately.""" @@ -40,6 +45,7 @@ def __init__( thresholds=thresholds, ) + @override def add_item( self, item: typing.Any, @@ -52,22 +58,28 @@ def add_item( super().add_item(item, object_type=object_type, metadata=metadata) self._callback([item], object_type) + @override def run(self) -> None: """Run does not execute anything in this context.""" + @override def start(self) -> None: """Start does not execute anything in this context.""" + @override def join(self) -> None: """Join does not execute anything in this context.""" + @override def purge(self) -> None: """Purge does not execute anything in this context.""" + @override def is_alive(self) -> bool: """As unthreaded, state as not alive always.""" return False + @override @property def empty(self) -> bool: """No queue so always empty.""" diff --git a/simvue/dispatch/dispatcher.py b/simvue/dispatch/dispatcher.py index e973cc58..95b03c8b 100644 --- a/simvue/dispatch/dispatcher.py +++ b/simvue/dispatch/dispatcher.py @@ -17,7 +17,7 @@ from .base import DispatcherBaseClass -def Dispatcher( # noqa: N802 +def Dispatcher( # ruff: ignore[invalid-function-name] mode: typing.Literal["direct", "queued"], callback: typing.Callable[[list[typing.Any], str], None], object_types: list[str], diff --git a/simvue/dispatch/queued.py b/simvue/dispatch/queued.py index c18cf0b1..0f392d22 100644 --- a/simvue/dispatch/queued.py +++ b/simvue/dispatch/queued.py @@ -14,6 +14,11 @@ from .base import DispatcherBaseClass +try: + from typing import override +except ImportError: + from typing_extensions import override + MAX_REQUESTS_PER_SECOND: float = 1.0 MAX_BUFFER_SIZE: int = 16000 QUEUE_SIZE = 10000 @@ -79,8 +84,9 @@ def __init__( } self._max_read_rate: float = max_read_rate self._max_buffer_size: int = max_buffer_size - self._send_timer: int = 0 + self._send_timer: float = 0 + @override def add_item( self, item: typing.Any, @@ -100,11 +106,13 @@ def add_item( raise KeyError(f"No queue '{object_type}' found") self._queues[object_type].put((item, metadata or {}), block=blocking) + @override @property def empty(self) -> bool: """Returns if all queues are empty.""" return all(queue.empty() for queue in self._queues.values()) + @override def purge(self) -> None: """Purge all queues.""" for q in self._queues.values(): @@ -145,6 +153,7 @@ def _create_buffer(self, queue_label: str) -> list[typing.Any]: return _buffer + @override def run(self) -> None: """Execute the dispatcher action. diff --git a/simvue/eco/api_client.py b/simvue/eco/api_client.py index 645e1c8a..dbd9f0e5 100644 --- a/simvue/eco/api_client.py +++ b/simvue/eco/api_client.py @@ -14,7 +14,6 @@ import typing import geocoder -import geocoder.location import pydantic import requests @@ -25,6 +24,9 @@ except ImportError: from typing_extensions import Self +if typing.TYPE_CHECKING: + from geocoder.ipinfo import IpinfoQuery + CO2_SIGNAL_API_ENDPOINT: str = ( "https://api.electricitymap.org/v3/carbon-intensity/latest" ) @@ -43,7 +45,7 @@ class CO2SignalResponse(pydantic.BaseModel): carbon_intensity_units: str @classmethod - def from_json_response(cls, json_response: dict) -> "CO2SignalResponse": + def from_json_response(cls, json_response: dict[str, str]) -> "CO2SignalResponse": _co2_signal_data = CO2SignalData( datetime=datetime.datetime.fromisoformat( json_response["datetime"].replace("Z", "+00:00"), @@ -58,7 +60,7 @@ def from_json_response(cls, json_response: dict) -> "CO2SignalResponse": @functools.lru_cache -def _call_geocoder_query() -> typing.Any: +def _call_geocoder_query() -> "IpinfoQuery": """Call GeoCoder API for IP location. Cached so this API is only called once per session as required. @@ -99,9 +101,11 @@ def post_init(self) -> Self: def _get_user_location_info(self) -> None: """Retrieve location information for the current user.""" logger.info("📍 Determining current user location.") - _current_user_loc_data: geocoder.location.BBox = _call_geocoder_query() + _current_user_loc_data = _call_geocoder_query() self._latitude: float self._longitude: float + if not _current_user_loc_data.latlng or not _current_user_loc_data.country: + raise RuntimeError("Failed to get user location.") self._latitude, self._longitude = _current_user_loc_data.latlng self._two_letter_country_code: str = _current_user_loc_data.country diff --git a/simvue/eco/emissions_monitor.py b/simvue/eco/emissions_monitor.py index 702abdfd..432a78b1 100644 --- a/simvue/eco/emissions_monitor.py +++ b/simvue/eco/emissions_monitor.py @@ -3,9 +3,6 @@ Provides an interface for estimating CO2 usage for processes on the CPU. """ -__author__ = "Kristian Zarebski" -__date__ = "2025-02-27" - import dataclasses import datetime import json @@ -180,6 +177,8 @@ def now(self) -> str: @property def outdated(self) -> bool: """Checks if the current data is out of date.""" + if not self._local_data: + raise RuntimeError("Expected local data to be initialised for CO2 monitor") if not self.intensity_refresh_interval: return False @@ -188,7 +187,9 @@ def outdated(self) -> bool: self._local_data["last_updated"], TIME_FORMAT, ).replace(tzinfo=datetime.timezone.utc) - return (_now - _latest_time).seconds > self.intensity_refresh_interval + return (_now - _latest_time).seconds > typing.cast( + "int", self.intensity_refresh_interval + ) def _load_local_data(self) -> dict[str, str | dict[str, str | float]] | None: """Loads locally stored CO2 intensity data.""" @@ -240,6 +241,10 @@ def check_refresh(self) -> bool: ): logger.info("🌍 CO2 emission outdated, calling API.") _data: CO2SignalResponse = self._client.get() + if self._local_data is None: + raise RuntimeError( + "Expected local data to be initialised for CO2 monitor" + ) self._local_data[self._client.country_code] = _data.model_dump(mode="json") self._local_data["last_updated"] = self.now() with self._data_file_path.open("w") as out_f: @@ -268,6 +273,11 @@ def estimate_co2_emissions( if not self._data_file_path: raise RuntimeError("Expected local data file to be defined.") + if self.thermal_design_power_per_cpu is None or self.n_cores_per_cpu is None: + raise ValueError( + "Expected values for thermal design power and number of cores for CPU" + ) + if not (_process := self._processes.get(process_id)): self._processes[process_id] = (_process := ProcessData()) diff --git a/simvue/exception.py b/simvue/exception.py index 215535ab..3002bc8c 100644 --- a/simvue/exception.py +++ b/simvue/exception.py @@ -1,6 +1,6 @@ """Simvue Exception Types. -Custom exceptions for handling of Simvue request scenarions. +Custom exceptions for handling of Simvue request scenarios. """ @@ -9,11 +9,14 @@ class ObjectNotFoundError(Exception): """For failure retrieving Simvue object from server.""" def __init__(self, obj_type: str, name: str, extra: str | None = None) -> None: - super().__init__( - f"Failed to retrieve '{name}' of type '{obj_type}' " - f"{f'{extra}, ' if extra else ''}" - "no such object", + _msg: str = ( + ( + f"Failed to retrieve '{name}' of type '{obj_type}' " + f"{f'{extra}, ' if extra else ''}" + "no such object" + ), ) + super().__init__(_msg) class SimvueRunError(RuntimeError): diff --git a/simvue/executor.py b/simvue/executor.py index 26d3f22a..d0dfad94 100644 --- a/simvue/executor.py +++ b/simvue/executor.py @@ -6,16 +6,13 @@ Stdout and Stderr are sent to Simvue as artifacts. """ -__author__ = "Kristian Zarebski" -__date__ = "2023-11-15" - import contextlib import logging import multiprocessing.synchronize import os import pathlib import shutil -import subprocess # noqa: S404 +import subprocess import threading import time import typing @@ -31,7 +28,10 @@ class CompletionCallback(typing.Protocol): - def __call__(self, *, status_code: int, std_out: str, std_err: str) -> None: ... + """Structure of callback executed on process completion.""" + + def __call__(self, *, status_code: int, std_out: str, std_err: str) -> None: + """Script executed on completion of a given process.""" def get_current_shell() -> str | None: @@ -51,7 +51,7 @@ def _execute_process( completion_trigger: threading.Event | None = None, environment: dict[str, str] | None = None, cwd: pathlib.Path | None = None, -) -> tuple[subprocess.Popen, threading.Thread | None]: +) -> tuple[subprocess.Popen[bytes], threading.Thread | None]: thread_out = None logger.debug("Launching process '%s'.", " ".join(command)) @@ -60,7 +60,7 @@ def _execute_process( pathlib.Path(f"{runner_name}_{proc_id}.err").open("w", encoding="utf-8") as err, pathlib.Path(f"{runner_name}_{proc_id}.out").open("w", encoding="utf-8") as out, ): - _result = subprocess.Popen( # noqa: S603 + _result = subprocess.Popen( command, stdout=out, stderr=err, @@ -74,7 +74,7 @@ def _execute_process( def trigger_check( completion_callback: CompletionCallback | None, trigger_to_set: multiprocessing.synchronize.Event | None, - process: subprocess.Popen, + process: subprocess.Popen[bytes], ) -> None: while process.poll() is None: time.sleep(1) @@ -129,15 +129,15 @@ def __init__(self, simvue_runner: "simvue.Run", *, keep_logs: bool = True) -> No """ self._runner = simvue_runner self._keep_logs = keep_logs - self._completion_callbacks: dict[str, CompletionCallback] | None = {} + self._completion_callbacks: dict[str, CompletionCallback] = {} self._completion_triggers: dict[ str, multiprocessing.synchronize.Event | None, ] = {} - self._completion_processes: dict[str, threading.Thread] | None = {} + self._completion_processes: dict[str, threading.Thread] = {} self._alert_ids: dict[str, str] = {} self.command_str: dict[str, str] = {} - self._processes: dict[str, subprocess.Popen] = {} + self._processes: dict[str, subprocess.Popen[bytes]] = {} self._all_processes: list[psutil.Process] = [] def std_out(self, process_id: str) -> str | None: @@ -532,7 +532,7 @@ def kill_process( if (process := self._processes.get(process_id)) is None: logger.error( "Failed to terminate process '%s' no such identifier.", - process.id, + process_id, ) return try: diff --git a/simvue/handler.py b/simvue/handler.py index af287897..46dffa68 100644 --- a/simvue/handler.py +++ b/simvue/handler.py @@ -36,7 +36,7 @@ def emit(self, record: logging.LogRecord) -> None: try: self._run_object.log_event(_msg) - except Exception: # noqa: BLE001 + except Exception: # ruff: ignore[blind-except] logging.Handler.handleError(self, record) @override diff --git a/simvue/metadata.py b/simvue/metadata.py index e5815ec5..519292c8 100644 --- a/simvue/metadata.py +++ b/simvue/metadata.py @@ -14,7 +14,7 @@ import toml import yaml -from pip._internal.operations.freeze import freeze # noqa: PLC2701 +from pip._internal.operations.freeze import freeze # ruff: ignore[import-private-name] from simvue.models import simvue_timestamp @@ -40,7 +40,7 @@ def git_info(repository: pathlib.Path) -> dict[str, typing.Any]: """ try: - import git # noqa: PLC0415 + import git # ruff: ignore[import-outside-top-level] except ImportError: return {} @@ -65,20 +65,21 @@ def git_info(repository: pathlib.Path) -> dict[str, typing.Any]: (tag.name for tag in git_repo.tags if tag.commit == current_commit), current_commit.hexsha, ) - return { - "git": { - "authors": list(author_list), - "ref": ref, - "msg": current_commit.message.strip(), - "time_stamp": simvue_timestamp(current_commit.committed_datetime), - "blame": blame, - "url": git_repo.remote().url, - "dirty": dirty, - }, - } except (git.InvalidGitRepositoryError, ValueError): return {} + return { + "git": { + "authors": list(author_list), + "ref": ref, + "msg": current_commit.message.strip(), + "time_stamp": simvue_timestamp(current_commit.committed_datetime), + "blame": blame, + "url": git_repo.remote().url, + "dirty": dirty, + }, + } + def _conda_dependency_parse(dependency: str) -> tuple[str, str] | None: """Parse a dependency definition into module-version.""" @@ -155,7 +156,7 @@ def _conda_env(environment_file: pathlib.Path) -> dict[str, str]: def _python_env(repository: pathlib.Path) -> dict[str, typing.Any]: """Retrieve a dictionary of Python dependencies if lock file is available.""" - python_meta: dict[str, dict] = {} + python_meta: dict[str, dict[str, str]] = {} if (pyproject_file := pathlib.Path(repository).joinpath("pyproject.toml")).exists(): content = toml.load(pyproject_file) @@ -208,7 +209,7 @@ def _python_env(repository: pathlib.Path) -> dict[str, typing.Any]: def _rust_env(repository: pathlib.Path) -> dict[str, typing.Any]: """Retrieve a dictionary of Rust dependencies if lock file available.""" - rust_meta: dict[str, dict] = {} + rust_meta: dict[str, dict[str, str]] = {} if (cargo_file := pathlib.Path(repository).joinpath("Cargo.toml")).exists(): content = toml.load(cargo_file).get("package", {}) @@ -232,7 +233,7 @@ def _rust_env(repository: pathlib.Path) -> dict[str, typing.Any]: def _julia_env(repository: pathlib.Path) -> dict[str, typing.Any]: """Retrieve a dictionary of Julia dependencies if a project file is available.""" - julia_meta: dict[str, dict] = {} + julia_meta: dict[str, dict[str, str]] = {} if (project_file := pathlib.Path(repository).joinpath("Project.toml")).exists(): content = toml.load(project_file) julia_meta["project"] = { @@ -243,7 +244,7 @@ def _julia_env(repository: pathlib.Path) -> dict[str, typing.Any]: def _node_js_env(repository: pathlib.Path) -> dict[str, typing.Any]: - js_meta: dict[str, dict] = {} + js_meta: dict[str, dict[str, str]] = {} if ( project_file := pathlib.Path(repository).joinpath("package-lock.json") ).exists(): diff --git a/simvue/metrics.py b/simvue/metrics.py index 45c3528d..2feda762 100644 --- a/simvue/metrics.py +++ b/simvue/metrics.py @@ -6,6 +6,7 @@ import contextlib import logging +import typing import psutil @@ -128,7 +129,8 @@ def get_gpu_metrics(processes: list[psutil.Process]) -> list[tuple[float, float] if is_gpu_used(handle, processes): utilisation_percent = nvmlDeviceGetUtilizationRates(handle).gpu memory = nvmlDeviceGetMemoryInfo(handle) - memory_percent = 100 * memory.free / memory.total + memory_percent = 100 * typing.cast("float", memory.free) + memory_percent /= typing.cast("float", memory.total) gpu_metrics.append((utilisation_percent, memory_percent)) nvmlShutdown() @@ -156,7 +158,7 @@ def __init__( """ self.cpu_percent: float | None = get_process_cpu(processes, interval=interval) self.cpu_memory: float | None = get_process_memory(processes) - self.gpus: list[dict[str, float]] = get_gpu_metrics(processes) + self.gpus: list[tuple[float, float]] = get_gpu_metrics(processes) def to_dict(self) -> dict[str, float]: """Create metrics dictionary for sending to a Simvue server.""" diff --git a/simvue/models.py b/simvue/models.py index ae9d60b2..36725b39 100644 --- a/simvue/models.py +++ b/simvue/models.py @@ -77,14 +77,13 @@ def simvue_timestamp( """ if not date_time: - date_time = datetime.datetime.now(datetime.timezone.utc) + date_time = datetime.datetime.now(datetime.UTC) elif isinstance(date_time, str): _local_time = datetime.datetime.now(datetime.timezone.utc).astimezone().tzinfo date_time = ( - datetime.datetime - .strptime(date_time, DATETIME_FORMAT) + datetime.datetime.strptime(date_time, DATETIME_FORMAT) .replace(tzinfo=_local_time) - .astimezone(datetime.timezone.utc) + .astimezone(datetime.UTC) ) return date_time.strftime(DATETIME_FORMAT) diff --git a/simvue/pynvml.py b/simvue/pynvml.py index ff4d218a..6b5ee013 100644 --- a/simvue/pynvml.py +++ b/simvue/pynvml.py @@ -1,3 +1,5 @@ +# pyright: reportSelfClsParameterName=false,reportImplicitOverride=false +# pyright: reportAttributeAccessIssue=false,reportUnreachable=false ##### # Based on nvidia-ml-py version 11.515.75 from https://pypi.org/project/nvidia-ml-py/ # including changes to fix nvmlDeviceGet*RunningProcesses from diff --git a/simvue/run.py b/simvue/run.py index 8378ad1b..12ae9b33 100644 --- a/simvue/run.py +++ b/simvue/run.py @@ -99,7 +99,7 @@ def check_run_initialised( """ @functools.wraps(function) - def _wrapper(self: Self, *args: typing.Any, **kwargs: typing.Any) -> typing.Any: + def _wrapper(self: "Run", *args: typing.Any, **kwargs: typing.Any) -> typing.Any: # Tidy pydantic errors _function = prettify_pydantic(function) @@ -329,7 +329,7 @@ def processes(self) -> list[psutil.Process]: return process_list process_list += [self._parent_process] - process_list += self._child_processes + process_list += self._child_processes or [] return list(set(process_list)) @@ -504,7 +504,13 @@ def _heartbeat( def _create_dispatch_callback( self, - ) -> typing.Callable: + ) -> typing.Callable[ + [ + list[typing.Any], + typing.Literal["events", "metrics_tensor", "metrics_regular"], + ], + None, + ]: """Generates the relevant callback for posting of metrics and events. The generated callback is assigned to the dispatcher instance and is @@ -777,7 +783,7 @@ def init( # Parse the time to live/retention time if specified try: if retention_period: - self._retention: int | None = int( + self._retention = int( humanfriendly.parse_timespan(retention_period), ) else: @@ -846,7 +852,7 @@ def init( ) click.secho( "[simvue] Monitor in the UI at " - f"{self._user_config.server.url.rsplit('/api', 1)[0]}" + f"{str(self._user_config.server.url).rsplit('/api', 1)[0]}" f"/dashboard/runs/run/{self.id}", bold=self._term_color, fg="green" if self._term_color else None, @@ -1315,10 +1321,6 @@ def update_metadata(self, metadata: dict[str, typing.Any]) -> bool: self._error("Cannot update metadata, run not initialised") return False - if not isinstance(metadata, dict): - self._error("metadata must be a dict") - return False - if self._sv_obj: self._sv_obj.metadata = metadata self._sv_obj.commit() @@ -1398,7 +1400,7 @@ def update_tags(self, tags: list[str]) -> bool: try: self.set_tags(list(set(current_tags + tags))) - except Exception as err: # noqa: BLE001 + except Exception as err: # ruff: ignore[blind-except] self._error(f"Failed to update tags: {err}") return False @@ -1468,7 +1470,7 @@ def log_event( return False # FIXME: Temporary, this will eventually be removed - import semver # noqa: PLC0415 + import semver # ruff: ignore[import-outside-top-level] _log_level_server_version = semver.Version.parse("1.2.16") if ( @@ -1663,7 +1665,9 @@ def assign_metric_to_grid( """ _axes_ticks = ( - axes_ticks.tolist() if isinstance(axes_ticks, np.ndarray) else axes_ticks + axes_ticks.tolist() + if axes_ticks and not isinstance(axes_ticks, list) + else axes_ticks ) grid_name = grid_name or metric_name @@ -1788,7 +1792,7 @@ def log_metrics( # Classify metrics into regular and tensor based for label, metric in metrics.items(): - if isinstance(metric, np.ndarray): + if not isinstance(metric, (int, float)): if metric.size > MAXIMUM_GRID_METRIC_SIZE: logger.warning( "Cannot log grid metric %s, size %d exceeds limit of %d", @@ -2752,7 +2756,7 @@ def set_metric_units( self._meta_cache.setdefault("metrics", {}) try: - _unit_obj = unyt_quantity.from_string(units) + _unit_obj: unyt_quantity = unyt_quantity.from_string(units) self._meta_cache["metrics"][metric_name] = { "units": units, "mks_conversion": mks_conversion or float(_unit_obj.in_mks().value), diff --git a/simvue/sender/actions.py b/simvue/sender/actions.py index 91af41a7..f24f5009 100644 --- a/simvue/sender/actions.py +++ b/simvue/sender/actions.py @@ -285,7 +285,7 @@ def _single_item_upload( if upload_status is not None: with thread_lock: upload_status.setdefault(cls.object_type, 0) - upload_status[cls.object_type] += 1 + upload_status[cls.object_type] += 1 # pyright: ignore[reportOperatorIssue] if simvue_monitor_run: simvue_monitor_run.log_metrics( {f"uploads.{cls.object_type}": upload_status[cls.object_type]}, @@ -1201,6 +1201,7 @@ def upload( indent=2, ) + @override @classmethod def json_file(cls, cache_directory: pathlib.Path, *_: object) -> pathlib.Path: """Returns the local cache JSON file for an upload. diff --git a/simvue/sender/base.py b/simvue/sender/base.py index f3ba20a7..ff032c14 100644 --- a/simvue/sender/base.py +++ b/simvue/sender/base.py @@ -85,7 +85,9 @@ def __init__( self._max_workers = max_workers self._lock_path = self._cache_directory.joinpath("sender.lock") self._thread_lock = threading.Lock() - self._run_notification: typing.Literal["none", "email"] = run_notification + self._run_notification: typing.Literal["none", "all", "email"] = ( + run_notification + ) self._run_retention_period: str | None = run_retention_period self._upload_status: dict[str, str | float] = {} self._monitor_uploads: bool = monitor_uploads @@ -146,7 +148,7 @@ def _initialise_monitor_run(self) -> Run: trigger_abort=False, ) - _run.upload_count = 0 + _run.upload_count = 0 # pyright: ignore[reportAttributeAccessIssue] return _run diff --git a/simvue/serialization.py b/simvue/serialization.py index 57a66a99..8e1f3446 100644 --- a/simvue/serialization.py +++ b/simvue/serialization.py @@ -5,7 +5,6 @@ import contextlib import json -import pickle # noqa: S403 import typing from io import BytesIO @@ -85,7 +84,7 @@ def serialize_object(data: typing.Any, *, allow_pickle: bool) -> tuple[str, str] return _serialize_torch_tensor(data) if module_name == "builtins" and class_name == "module" and not allow_pickle: with contextlib.suppress(ImportError): - import matplotlib.pyplot as plt # noqa: PLC0415 + import matplotlib.pyplot as plt # ruff: ignore[import-outside-top-level] if data == plt: return _serialize_matplotlib(data) @@ -98,7 +97,7 @@ def serialize_object(data: typing.Any, *, allow_pickle: bool) -> tuple[str, str] @check_extra("plot") def _serialize_plotly_figure(data: typing.Any) -> tuple[str, str]: try: - import plotly # noqa: PLC0415 + import plotly # ruff: ignore[import-outside-top-level] except ImportError: return None mimetype = "application/vnd.plotly.v1+json" @@ -113,7 +112,7 @@ def _serialize_plotly_figure(data: typing.Any) -> tuple[str, str]: @check_extra("plot") def _serialize_matplotlib(data: typing.Any) -> tuple[str, str] | None: try: - import plotly # noqa: PLC0415 + import plotly # ruff: ignore[import-outside-top-level] except ImportError: return None mimetype = "application/vnd.plotly.v1+json" @@ -128,7 +127,7 @@ def _serialize_matplotlib(data: typing.Any) -> tuple[str, str] | None: @check_extra("plot") def _serialize_matplotlib_figure(data: typing.Any) -> tuple[str, str] | None: try: - import plotly # noqa: PLC0415 + import plotly # ruff: ignore[import-outside-top-level] except ImportError: return None mimetype = "application/vnd.plotly.v1+json" @@ -161,7 +160,7 @@ def _serialize_dataframe(data: typing.Any) -> tuple[str, str] | None: @check_extra("torch") def _serialize_torch_tensor(data: typing.Any) -> tuple[str, str] | None: try: - import torch # noqa: PLC0415 + import torch # ruff: ignore[import-outside-top-level] except ImportError: torch = None return None @@ -187,6 +186,8 @@ def _serialize_json(data: typing.Any) -> tuple[str, str] | None: def _serialize_pickle(data: typing.Any) -> tuple[str, str] | None: + import pickle # ruff: ignore[import-outside-top-level] + mimetype = "application/octet-stream" data = pickle.dumps(data) return data, mimetype @@ -217,16 +218,7 @@ def deserialize_data( @check_extra("plot") def _deserialize_plotly_figure(data: "Buffer") -> "Figure | None": try: - import plotly # noqa: PLC0415 - except ImportError: - return None - return plotly.io.from_json(data) - - -@check_extra("plot") -def _deserialize_matplotlib_figure(data: "Buffer") -> "Figure | None": - try: - import plotly # noqa: PLC0415 + import plotly # ruff: ignore[import-outside-top-level] except ImportError: return None return plotly.io.from_json(data) @@ -247,7 +239,7 @@ def _deserialize_dataframe(data: "Buffer") -> "DataFrame | None": @check_extra("torch") def _deserialize_torch_tensor(data: "Buffer") -> "Tensor | None": try: - import torch # noqa: PLC0415 + import torch # ruff: ignore[import-outside-top-level] except ImportError: torch = None return None @@ -258,7 +250,9 @@ def _deserialize_torch_tensor(data: "Buffer") -> "Tensor | None": def _deserialize_pickle(data) -> typing.Any | None: - return pickle.loads(data) # noqa: S301 + import pickle # ruff: ignore[import-outside-top-level] + + return pickle.loads(data) # ruff: ignore[suspicious-pickle-usage] def _deserialize_json(data) -> typing.Any | None: diff --git a/simvue/simvue_types.py b/simvue/simvue_types.py index 5bd976fe..bd274355 100644 --- a/simvue/simvue_types.py +++ b/simvue/simvue_types.py @@ -5,15 +5,16 @@ try: from typing import TypeAlias except ImportError: - from typing import TypeAlias + from typing_extensions import TypeAlias # ruff: ignore[deprecated-import] if typing.TYPE_CHECKING: + from collections.abc import Buffer + from numpy import ndarray from pandas import DataFrame from plotly.graph_objects import Figure, FigureWidget from torch import Tensor - from typing_extensions import Buffer DeserializedContent: TypeAlias = typing.Union[ diff --git a/simvue/system.py b/simvue/system.py index d12bdff9..52528d84 100644 --- a/simvue/system.py +++ b/simvue/system.py @@ -5,7 +5,7 @@ import platform import shutil import socket -import subprocess # noqa: S404 +import subprocess import sys import typing @@ -24,7 +24,7 @@ def get_cpu_info() -> tuple[str, str]: if _lscpu := shutil.which("lscpu"): with contextlib.suppress(subprocess.CalledProcessError): - _info = subprocess.check_output(_lscpu).decode().strip() # noqa: S603 + _info = subprocess.check_output(_lscpu).decode().strip() for line in _info.split("\n"): if "Model name" in line: _model_name = line.split(":")[1].strip() @@ -36,8 +36,7 @@ def get_cpu_info() -> tuple[str, str]: if not _model_name and (_sysctl := shutil.which("sysctl")): with contextlib.suppress(subprocess.CalledProcessError): info = ( - subprocess # noqa: S603 - .check_output([_sysctl, "machdep.cpu.brand_string"]) + subprocess.check_output([_sysctl, "machdep.cpu.brand_string"]) .decode() .strip() ) @@ -63,7 +62,7 @@ def get_gpu_info() -> dict[str, str]: return _gpu_info with contextlib.suppress(subprocess.CalledProcessError, IndexError): - output = subprocess.check_output( # noqa: S603 + output = subprocess.check_output( [_nvidia_smi, "--query-gpu=name,driver_version", "--format=csv"], ) lines = output.split(b"\n") diff --git a/simvue/utilities.py b/simvue/utilities.py index c8428853..21481fc1 100644 --- a/simvue/utilities.py +++ b/simvue/utilities.py @@ -1,6 +1,7 @@ """Utility and global helper functions.""" import contextlib +import datetime import functools import hashlib import importlib.util @@ -12,10 +13,12 @@ import platform import typing +import deepmerge import jwt import pydantic import tabulate -from deepmerge import Merger + +from .models import DATETIME_FORMAT CHECKSUM_BLOCK_SIZE = 4096 EXTRAS: tuple[str, ...] = ("plot", "torch") @@ -158,7 +161,7 @@ def parse_validation_response( return str(_table) -def check_extra(extra_name: str) -> typing.Callable: +def check_extra(extra_name: str) -> typing.Callable[[typing.Any], typing.Any]: """Check for the presence of a given module extra. Some features are unlocked by specifying an 'extra' when installing. @@ -178,8 +181,8 @@ def check_extra(extra_name: str) -> typing.Callable: """ def decorator( - class_func: typing.Callable | None = None, - ) -> typing.Callable | None: + class_func: typing.Callable[[object], object | None] | None = None, + ) -> typing.Callable[[object], object | None] | None: @functools.wraps(class_func) def wrapper(self, *args, **kwargs) -> typing.Any: if extra_name == "plot" and not all( @@ -274,7 +277,7 @@ def skip_if_failed( failure_attr: str, ignore_exc_attr: str, on_failure_return: typing.Any | None = None, -) -> typing.Callable: +) -> typing.Callable[[typing.Any], typing.Any]: """Decorator for ensuring if Simvue throws an exception any other code continues. If Simvue throws an exception and the user has specified that such failure @@ -299,7 +302,9 @@ def skip_if_failed( """ - def decorator(class_func: typing.Callable) -> typing.Callable: + def decorator( + class_func: typing.Callable[[typing.Any], typing.Any], + ) -> typing.Callable[[typing.Any], typing.Any]: @functools.wraps(class_func) def wrapper(self: "Run", *args, **kwargs) -> typing.Any: if getattr(self, failure_attr, None) and getattr( @@ -330,7 +335,9 @@ def wrapper(self: "Run", *args, **kwargs) -> typing.Any: return decorator -def prettify_pydantic(func: typing.Callable) -> typing.Callable: +def prettify_pydantic( + func: typing.Callable[[typing.Any], typing.Any], +) -> typing.Callable[[typing.Any], typing.Any]: """Converts pydantic validation errors to a table. Parameters @@ -390,6 +397,38 @@ def calculate_file_sha256(file: pathlib.Path) -> str | None: return None +def validate_timestamp(timestamp): + """ + Validate a user-provided timestamp + """ + try: + _ = datetime.datetime.strptime(timestamp, DATETIME_FORMAT).astimezone( + datetime.timezone.utc + ) + except ValueError: + return False + + return True + + +def simvue_timestamp(date_time: datetime.datetime | None = None) -> str: + """Return the Simvue valid timestamp + + Parameters + ---------- + date_time: datetime.datetime, optional + if provided, the datetime object to convert, else use current date and time + + Returns + ------- + str + Datetime string valid for the Simvue server + """ + if not date_time: + date_time = datetime.datetime.now(datetime.timezone.utc) + return date_time.strftime(DATETIME_FORMAT) + + @functools.lru_cache def get_mimetypes() -> list[str]: """Returns a list of allowed MIME types.""" @@ -406,7 +445,7 @@ def get_mimetype_for_file(file_path: pathlib.Path) -> str: # Create a new Merge strategy for merging local file and staging attributes -staging_merger = Merger( +staging_merger = deepmerge.Merger( # pass in a list of tuple, with the # strategies you are looking to apply # to each type. diff --git a/simvue/version.py b/simvue/version.py index 1a50cfdc..9a33f5b6 100644 --- a/simvue/version.py +++ b/simvue/version.py @@ -9,8 +9,7 @@ __version__ = importlib.metadata.version("simvue") except importlib.metadata.PackageNotFoundError: _metadata = ( - pathlib - .Path(__file__) + pathlib.Path(__file__) .parents[2] .joinpath( "pyproject.toml", diff --git a/tests/unit/test_request.py b/tests/unit/test_request.py index 019513b5..a3bda893 100644 --- a/tests/unit/test_request.py +++ b/tests/unit/test_request.py @@ -20,7 +20,7 @@ def test_post_retry_rewinds_file_stream(mocker, tuple_form: bool) -> None: stream = io.BytesIO(_PAYLOAD) file_value = ("artifact.bin", stream) if tuple_form else stream - def fake_post(*args, **kwargs): + def fake_post(*_, **kwargs): file = kwargs["files"]["file"] file = file[1] if isinstance(file, tuple) else file payloads.append(file.read()) @@ -47,7 +47,7 @@ def test_put_retry_rewinds_data_stream(mocker) -> None: payloads = [] stream = io.BytesIO(_PAYLOAD) - def fake_put(*args, **kwargs): + def fake_put(*_, **kwargs): payloads.append(kwargs["data"].read()) return _Response(503 if len(payloads) == 1 else 200) diff --git a/tests/unit/test_sender.py b/tests/unit/test_sender.py index 4cccd8e7..0d1fd16a 100644 --- a/tests/unit/test_sender.py +++ b/tests/unit/test_sender.py @@ -180,32 +180,51 @@ def test_send_heartbeat(offline_cache_setup, parallel, mocker): _path = f"/simvue_unit_testing/objects/folder/{_uuid}" _folder = Folder.new(path=_path, offline=True) _folder.commit() - + _offline_runs = [] - + for i in range(5): _name = f"test_sender_server_ids-{_uuid}-{i}" _run = Run.new(name=_name, folder=_path, offline=True, heartbeat_timeout=1, status="running") _run.commit() - + _offline_runs.append(_run) - + _sender = Sender(threading_threshold=1 if parallel else 10) _sender.upload() _online_runs = [Run(identifier=_sender.id_mapping.get(_offline_run.id)) for _offline_run in _offline_runs] assert all([_online_run.status == "running" for _online_run in _online_runs]) - + + _all_running: bool = False + _counter: int = 0 + + while not _all_running and _counter < 100: + # Get online runs and check all running + [_online_run.refresh() for _online_run in _online_runs] + _all_running = all([_online_run.status == "running" for _online_run in _online_runs]) + time.sleep(0.1) + if not _all_running and _counter >= 100: + raise AssertionError("Failed to launch all runs from sender") + spy_put = mocker.spy(requests, "put") - + # Create heartbeat and send every 0.5s for 5s for i in range(10): time.sleep(0.5) [_offline_run.send_heartbeat() for _offline_run in _offline_runs] Sender(threading_threshold=1 if parallel else 10).upload() - + # Check requests.put() endpoint called 50 times - once for each of the 5 runs, on all 10 iterations assert spy_put.call_count == 50 - - # Get online runs and check all running - [_online_run.refresh() for _online_run in _online_runs] - assert all([_online_run.status == "running" for _online_run in _online_runs]) + + _all_running = False + _counter = 0 + + while not _all_running and _counter < 100: + # Get online runs and check all running + [_online_run.refresh() for _online_run in _online_runs] + _all_running = all([_online_run.status == "running" for _online_run in _online_runs]) + time.sleep(0.1) + + if not _all_running and _counter >= 100: + raise AssertionError("Failed to keep all runs running from sender") diff --git a/uv.lock b/uv.lock index 683c8c2e..71b1fbd6 100644 --- a/uv.lock +++ b/uv.lock @@ -32,6 +32,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] +[[package]] +name = "basedpyright" +version = "1.40.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/ee/8d0b6806338b13526303cf72754351221a32cdb80ab56dcf2c91d6b1ea57/basedpyright-1.40.1.tar.gz", hash = "sha256:da1c9913b6d169340a0dbb6df76ea97f476ccb697da8f92659ac46032f6d2ce8", size = 25131254, upload-time = "2026-09-10T23:17:24.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/84/c1e1e845d0453253a98d1ba188d68597f0ba7e552eeabea514d9395d418a/basedpyright-1.40.1-py3-none-any.whl", hash = "sha256:222dc0382caf9816eb23a27cb7059fa96356ddc500b3b7b306072848fd650244", size = 13689276, upload-time = "2026-09-10T23:17:20.322Z" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -1271,6 +1283,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/b5/1b84b2c784db76d69442334bc8b8748c840f13ca53be086f4f250ad4a0bc/narwhals-2.26.0-py3-none-any.whl", hash = "sha256:29326d74f107c347fd1009bd58e38d9f7c7c5b51e6de97bc93dbc325d9038b54", size = 474034, upload-time = "2026-09-08T13:32:07.159Z" }, ] +[[package]] +name = "nodejs-wheel-binaries" +version = "24.19.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/76/7e97195e14346598565a0de4ca8bdbd5e634b3fb5b1ba590b7b1b89f8a63/nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b", size = 8058, upload-time = "2026-08-19T21:47:19.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/52/0774b52c7be8151ad9d5aff44edc100c3f13d6d8eb3765f63ffa40e69fe8/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c", size = 56047203, upload-time = "2026-08-19T21:46:43.448Z" }, + { url = "https://files.pythonhosted.org/packages/67/3a/4fdbbfecf2c23d52c0e3f68de7f7c1b3c97a26d328c69c5f6c49c48e340e/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514", size = 56219459, upload-time = "2026-08-19T21:46:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/0147149415195c59b8a72a594916bfb80d6be4d586f9fbfda313889e0efc/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227", size = 60588256, upload-time = "2026-08-19T21:46:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/89/6631d0982353da1bb7bc00bb1988f702822c62b42634f57999ee53b5c337/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8", size = 61123607, upload-time = "2026-08-19T21:46:58.117Z" }, + { url = "https://files.pythonhosted.org/packages/32/a2/fa30f0841e4602995782e124359f9b910c7b481d98decf61ef0b2fc3ebfb/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7", size = 62632842, upload-time = "2026-08-19T21:47:03.42Z" }, + { url = "https://files.pythonhosted.org/packages/18/01/22d97ca72213f66cc386ee638db30c2e62757fdf761c6029074bced83d1c/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c", size = 63250653, upload-time = "2026-08-19T21:47:07.938Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/e3be8fa327a795bcaf7a19cd84299e338a7bce32ff0665fdce9cfa22573c/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd", size = 42448503, upload-time = "2026-08-19T21:47:12.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/37/34cf28ba1691a060174948a9927fe61091982d6048b2e403071a9acce443/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36", size = 40090155, upload-time = "2026-08-19T21:47:16.032Z" }, +] + [[package]] name = "numpy" version = "2.2.6" @@ -1554,7 +1582,7 @@ wheels = [ [[package]] name = "pandas" -version = "3.0.5" +version = "3.0.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and sys_platform == 'win32'", @@ -1573,49 +1601,49 @@ dependencies = [ { name = "python-dateutil" }, { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/48/ef/f1fd7431d635bf20015489bf0bd69c17fff1018de773540f651455a3916b/pandas-3.0.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2946e77e4a53cd248cbde631a12f0e51c8324ce354c3eba4d20147c1ad6f4282", size = 10397178, upload-time = "2026-07-22T22:17:48.274Z" }, - { url = "https://files.pythonhosted.org/packages/31/b4/0eafac990a431561187694126de01f9b12559549b4d86360c0c4bd870fde/pandas-3.0.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71ecc8fb7ed1a7aa4392316b5309a6347e8e7f832f38fd897846b3a1457a9298", size = 9990736, upload-time = "2026-07-22T22:17:52.388Z" }, - { url = "https://files.pythonhosted.org/packages/de/21/359880af3ea9b7cb23bea5b51e8e70ef3866c03be09da9a2787e18e330a8/pandas-3.0.5-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b173f5951ff6b8b0ec7675e20dff3c97b7e7a57dfcce387c2d7c5afe87cb7899", size = 10814438, upload-time = "2026-07-22T22:17:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/d1/50/d6cc4d7e508bbccf5d6027314a8312bc7ac73d0ec7f195f53838daafab40/pandas-3.0.5-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c0cf1dd9b55a22d105fc46c1b489af3bd42264fcba7c66297bf47a9a1d9c78a", size = 11323634, upload-time = "2026-07-22T22:17:56.858Z" }, - { url = "https://files.pythonhosted.org/packages/70/2b/d5f0a8c90dd0ae04e64ba53b871afb796ec026b615086d382ddc2ade729b/pandas-3.0.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0fac0010c75e4efb6b99e249c183a8993ce0dc95c240f9b120a5e67c727b7928", size = 11850860, upload-time = "2026-07-22T22:17:59.1Z" }, - { url = "https://files.pythonhosted.org/packages/5c/30/183aec2e19adf778a98d29b5729a0a68f4cc4ebf9b9c3b70d0297355bcb1/pandas-3.0.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:08d24fe11a17dc33bd6e937dc9c665f9cba08fbdc9f657f405713515febe300d", size = 12411100, upload-time = "2026-07-22T22:18:01.485Z" }, - { url = "https://files.pythonhosted.org/packages/fa/9a/31f4983f191af51ab2a8f2d0c7b33dff3a84da26533f982fff02c2f9e28b/pandas-3.0.5-cp311-cp311-win_amd64.whl", hash = "sha256:b1261758dfb6cf12c3cff8300e21cefad30e7ec709abb4c24ac7318e6a52462a", size = 9968804, upload-time = "2026-07-22T22:18:03.903Z" }, - { url = "https://files.pythonhosted.org/packages/49/97/7886c89a39045c69ad82cbceaf3343810480c8ef49a216319ce8183860a6/pandas-3.0.5-cp311-cp311-win_arm64.whl", hash = "sha256:679f4e85b30ddb1515458ab1e788d3e260eae369b1f78da7a3aa4cac8ebf4a2a", size = 9205447, upload-time = "2026-07-22T22:18:06.134Z" }, - { url = "https://files.pythonhosted.org/packages/1c/54/1dc810ea558d1320b597aa140a514f2fdf1d2ea09c38cf556f13ea712ec9/pandas-3.0.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fa290c16964d4963fbfbc358928239cf3bd755b20e988ce944877def2f44471d", size = 10411717, upload-time = "2026-07-22T22:18:08.307Z" }, - { url = "https://files.pythonhosted.org/packages/68/56/fbe81c09195924d8b7b8d4461a20458fe80a6a5ed6b24f0314da684277e1/pandas-3.0.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c2e26bb46934b8a2ca0c3de1d3d606fc5f6746584791b2db264d58cf370e08dc", size = 9957095, upload-time = "2026-07-22T22:18:10.6Z" }, - { url = "https://files.pythonhosted.org/packages/e0/51/fac252f4a913ed5eabf3c11b880a9e8d5a6c10f0b2129d0462212d238b4d/pandas-3.0.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73fa87b08a7ef706f8aafda39ddaccf2a99047bea62d8c88a0361bcafb2237bc", size = 10485458, upload-time = "2026-07-22T22:18:12.834Z" }, - { url = "https://files.pythonhosted.org/packages/12/98/e976540c1addf70442be7842a18cf70884a964abbf69442504f4d2939989/pandas-3.0.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d373ce03ffd84010ed9839fa73672a9c8256990532e158440c0085db7d914b34", size = 10998091, upload-time = "2026-07-22T22:18:15.209Z" }, - { url = "https://files.pythonhosted.org/packages/a4/8c/1f29b5be8d3fc47dd7567eb167fabba2085879b31e0287ce7cba6d3d2ff4/pandas-3.0.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a29c53d85ea98c5e792c59ef82ee9fbe6ca902c0d0adb6b23f45ef894cd7bf6", size = 11499501, upload-time = "2026-07-22T22:18:17.689Z" }, - { url = "https://files.pythonhosted.org/packages/9d/e2/bd9c98ad2df7b38bde002adde4cdf353519da51881634323b126c55997f9/pandas-3.0.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a5ad3b02ed6bc7d7ae9b70804b2c6aa31827489d150f8e623ce82491b82085d7", size = 12060559, upload-time = "2026-07-22T22:18:20.147Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9a/ffbd852d58bd74a617fe2f8ee6a58a96982271ce41cf981eab22190b4a4b/pandas-3.0.5-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:b2acb4650527eec6822c3dadb2b771277b65e7dae7a267d4bccf65fd1bb3fbce", size = 7197652, upload-time = "2026-07-22T22:18:22.502Z" }, - { url = "https://files.pythonhosted.org/packages/70/b5/d2d3e9ae73362ba4229651b0ee1455cf78073a1ce585f6ff693782ce263e/pandas-3.0.5-cp312-cp312-win_amd64.whl", hash = "sha256:80a611068e8a3ac23f7398c6c14eb46dc974e5cc9997f653e2dcfd1da74edd41", size = 9831691, upload-time = "2026-07-22T22:18:24.534Z" }, - { url = "https://files.pythonhosted.org/packages/52/51/dea1e89d6a6796b9c43f85a09b484ee03edb8a4c4842e73e200a8c11301c/pandas-3.0.5-cp312-cp312-win_arm64.whl", hash = "sha256:25ff585b972a18ef1fe9ffa3ac6544d9950508aa76832e5147640b6022821e49", size = 9105796, upload-time = "2026-07-22T22:18:27.064Z" }, - { url = "https://files.pythonhosted.org/packages/bf/09/7b95c4a0025227d6f118c4039b423412ac6a982db02864166185d812fbc7/pandas-3.0.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c05a767fe8e5b4fe9e1c29806829c582052eaedb9120a3da83ba3f69e24a5b", size = 10385742, upload-time = "2026-07-22T22:18:29.346Z" }, - { url = "https://files.pythonhosted.org/packages/8d/0c/dc78fd8c4da477b4b5e8ad37295af352190d21ef63a9ee1bc071753074cc/pandas-3.0.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b86765f268b56f7e665b93bce9d5df69dee7f99e595cf8fb839483ab315942a3", size = 9932067, upload-time = "2026-07-22T22:18:31.833Z" }, - { url = "https://files.pythonhosted.org/packages/3e/71/3592c055cf44df9808550f9368ceda80ff2b224d355ef73fe251dcda1802/pandas-3.0.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c597ecf5616b5c420372c1d4d4c00dbbfba7398bea857dcc984347e1ea48417b", size = 10466756, upload-time = "2026-07-22T22:18:34.195Z" }, - { url = "https://files.pythonhosted.org/packages/e3/70/4363150359f95b4cb4bcbb34ca23572bb5495749a621a8f3d5a1ddfd293c/pandas-3.0.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b11c36e218331d0387cbe3a0a5f75162357a1d92d57b2b08a336ff94b19b2be", size = 10938525, upload-time = "2026-07-22T22:18:36.81Z" }, - { url = "https://files.pythonhosted.org/packages/f7/d0/317e7a0c67c0e69fa905a0161409397a7dc2d46ff611f6ca4803352c042b/pandas-3.0.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cf52e1f61d229496da17dc7ab54acdee627357e7008fd4fecba3d0ba2937fa58", size = 11489303, upload-time = "2026-07-22T22:18:39.287Z" }, - { url = "https://files.pythonhosted.org/packages/f1/8d/36dade89b49e4f9d5cbdbe863772581f98c0c6d78fc39ad4c557f6f2e17e/pandas-3.0.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db172144bb56422bd157812f3b021eacc255451470b31e2c633c349490a1cfee", size = 11989004, upload-time = "2026-07-22T22:18:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/9c/ba/18c4ec8a746e177da05a9e7a7963781d8ea195780724f854601b6ebd6b78/pandas-3.0.5-cp313-cp313-win_amd64.whl", hash = "sha256:0d298e951f23016ce4699951d044ae6418dbc91bf68cefca0f77666fcbb4e5c6", size = 9826896, upload-time = "2026-07-22T22:18:44.539Z" }, - { url = "https://files.pythonhosted.org/packages/de/ec/28a57266b753799a87b8bc79e7887ac6fd981b8c6d2978a0b7e7b6bd708c/pandas-3.0.5-cp313-cp313-win_arm64.whl", hash = "sha256:66266d3442a5e8b3c90274c2b8b230bee42dd1c286bc822cc2f9f2c7e12b883e", size = 9094790, upload-time = "2026-07-22T22:18:47.468Z" }, - { url = "https://files.pythonhosted.org/packages/51/2f/cf6aae281264f4463f0875bcbb15fd2bb6d291cc535187dad1732475e4a9/pandas-3.0.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2f264fc46911cc8131a7322a16199bbf8e353d27c10bb211f5bd0c814324dc36", size = 10390034, upload-time = "2026-07-22T22:18:49.818Z" }, - { url = "https://files.pythonhosted.org/packages/06/ec/5189518c7a7659c4bdcc6b1eb32c46c6f3c86b0661ffd84143d1112c7732/pandas-3.0.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:53730687fcd161883b24e10411c06d6a4c0f2275d2faf3bb2bc25deb4ba8007c", size = 9980065, upload-time = "2026-07-22T22:18:52.249Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f1/598503ce8d7e3c35601e0747ba288c7864baae66380725bc12f13f884dfe/pandas-3.0.5-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:960d3ebcf249f75206899fcd2c6de53f736b7265759ced0d3e559df0b8b709b0", size = 10545532, upload-time = "2026-07-22T22:18:54.813Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/ceae2adf7034e07e9910299fe412e1819c4f0dd520700a888bcb03625448/pandas-3.0.5-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e94c2c5ca43bd3ca32bf64d32308887b65e5f9bfd8023ea52755107a999f93b", size = 10963120, upload-time = "2026-07-22T22:18:57.42Z" }, - { url = "https://files.pythonhosted.org/packages/66/25/86e0f4451874eb79e688deeebe3c451fec4557f8952005818d800ee8ac7e/pandas-3.0.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e819dd5f62966b481a8cb649d3299ebd886a1ea91ed5a99bf7ce77c98d18ab94", size = 11563178, upload-time = "2026-07-22T22:18:59.729Z" }, - { url = "https://files.pythonhosted.org/packages/f3/45/8643daa3b4147e433adfcccefdd0380d3aad79d86b15d8999730fe1944d5/pandas-3.0.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c5ed2e7c06e91d340dfd091d7934f9bc82e4a36b95f647f090b9d1c9ac649da", size = 12028708, upload-time = "2026-07-22T22:19:02.164Z" }, - { url = "https://files.pythonhosted.org/packages/96/58/ad979ae617615576e8aafd569c9d4b62f1191d896e38f51d66ba06f3b89a/pandas-3.0.5-cp314-cp314-win_amd64.whl", hash = "sha256:cd8f7c6dc98527058ee6264219343f5392240a6f1bfa654fc5d79023020d0c92", size = 9951806, upload-time = "2026-07-22T22:19:04.596Z" }, - { url = "https://files.pythonhosted.org/packages/69/32/7ac03886b304049a9d2625ee88f59af760d8a93bd30ed9239bce7b9869a8/pandas-3.0.5-cp314-cp314-win_arm64.whl", hash = "sha256:5183427f5a8156d480f30333777bc978be93650a49a7c01db26adffe95b31e85", size = 9238297, upload-time = "2026-07-22T22:19:06.836Z" }, - { url = "https://files.pythonhosted.org/packages/be/ed/1d1f2ee5547d5167face2376d11c8b2a4c7bfff5a416ee7a9046891fab1e/pandas-3.0.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:303da736987d481074ca720ada325f8bd80c64ebc2d45ed79b29df3aaa4a26ca", size = 10849690, upload-time = "2026-07-22T22:19:09.391Z" }, - { url = "https://files.pythonhosted.org/packages/57/55/17e17152e98fbb0c4b1e562bc65387a2f20a80db0f4a86bf8d3a0e4248d4/pandas-3.0.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3b2801bbb049d0136f6c213eae02b5fca969384fc2064dd728d8620552aa49da", size = 10509945, upload-time = "2026-07-22T22:19:11.773Z" }, - { url = "https://files.pythonhosted.org/packages/88/90/817d44dbf83facf9556f33576d9af0a241981e7bb5c00606c0bcb5df8dda/pandas-3.0.5-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cce3a9d11d2b1f82c69a27ec1f4948a170e2c403c4bbfa8cca62e3fdebe2ef3a", size = 10392197, upload-time = "2026-07-22T22:19:14.024Z" }, - { url = "https://files.pythonhosted.org/packages/f1/da/889f00c0a6f5aa1545add70abbf01502dff87ab577adb855bd631c54d2f2/pandas-3.0.5-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef01af4d8dc6cd2c8d6c7736f149574ef93fe043811eeb5e445f2647154b5040", size = 10862726, upload-time = "2026-07-22T22:19:16.351Z" }, - { url = "https://files.pythonhosted.org/packages/bc/98/f1e934fb3c98fce859c6147c6785816c7b5b9ab7821115c5d8c4de9842b9/pandas-3.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e2759e890db96dfcffdbd9b86c3c2cb6afaf58def482820317e06163ec1066cd", size = 11414864, upload-time = "2026-07-22T22:19:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/fe/be/d448af7d657d82e1888dd8551f79c6d6fb161080b5b9752d84d910ec2319/pandas-3.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b58b1b39d46a5862e3fb18f50d1a201398619d16a0f9f73f57eea5583cf0e63c", size = 11925105, upload-time = "2026-07-22T22:19:21.515Z" }, - { url = "https://files.pythonhosted.org/packages/29/c1/ccb4238212c8c4f496c584f3044d94e0c030ed8e1d68999db46c91c2242f/pandas-3.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:1c10461f6eeb35d8f05b6184c65c8b9991663b66c46b1d559b682cb34ae7c6ea", size = 10387612, upload-time = "2026-07-22T22:19:24.257Z" }, - { url = "https://files.pythonhosted.org/packages/d2/cf/6a51b2c38980e04c279fd2fa908a1b0982064e860444acfca4ec2e2c8359/pandas-3.0.5-cp314-cp314t-win_arm64.whl", hash = "sha256:3c5015fd1730fbf883647e88068176c839c102cea883ba1769a6f4593bfc1f8c", size = 9509776, upload-time = "2026-07-22T22:19:26.694Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/e2/17/d7b106e05bfa642e8694451e7d3d759c6a241c5386a5d962e4f66c047e06/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7315a31bfe1089cd3d71a7de781c9dca986762d0b4fe7c0ef17465d10", size = 4667686, upload-time = "2026-09-17T23:23:18.345Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/48/88e8d250d28efa8163294f6809a71683c7ee67f63ac7c33021c0503b3547/pandas-3.0.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:085e3786ae6b2e82b406266bce36690f72b9dc1421903ba9296b2981a9fcf586", size = 10391798, upload-time = "2026-09-17T23:20:20.96Z" }, + { url = "https://files.pythonhosted.org/packages/55/a6/39db5d41f3eb5d7846626312cb30e0cea48e988fc47a3725698bc931ad3c/pandas-3.0.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d7564d86a94c2eb8ab290b07f63ddaae5c032fa53897c29a2ff2197d43aee8af", size = 10023718, upload-time = "2026-09-17T23:20:25.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/b7/707e966129f77ee8a39d41a41bbd989b790b3fef581c6e26b821315cba6b/pandas-3.0.6-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7c0afdcaf6661d795fcefc2f647ddd1136f62cdc153fba177c685d97a87808", size = 10612800, upload-time = "2026-09-17T23:20:27.99Z" }, + { url = "https://files.pythonhosted.org/packages/63/be/dfb6cc9329d0bbe76dadda8a1113d026bb996ec76c509221368dc68349f4/pandas-3.0.6-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47121f9571503f724c9b93e297ab6254ac99c77adf5e9ed085ea419fd585c258", size = 11108900, upload-time = "2026-09-17T23:20:30.65Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6f/3d58f15bbe972f3d7bfa13ee06d731785a76fe8d232c92b2655a8de14127/pandas-3.0.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:994a79608263fe1c14cc48ffa7300e2b834b7d1cb406ffe96a08828cb0cdd79b", size = 11630226, upload-time = "2026-09-17T23:20:33.582Z" }, + { url = "https://files.pythonhosted.org/packages/58/54/9b494de4a3dd92fc6eb19187db1b21afb50fdc21962f32ea1aca9f270cb2/pandas-3.0.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a3a22e07fe75347eaacc75b0e85297947af4fba6b4aae23916bd8b6828d0bba3", size = 12146942, upload-time = "2026-09-17T23:20:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/d3/dc/d2df02854aec5d47659acfb2be352eecc691845b2f86e99c84f1010a8671/pandas-3.0.6-cp311-cp311-win_amd64.whl", hash = "sha256:2e5fa32ff162dfdbc280157d664f44d23049ae414725af9676df339c501d82cd", size = 9859246, upload-time = "2026-09-17T23:20:43.976Z" }, + { url = "https://files.pythonhosted.org/packages/79/1e/2a30df0d7dede5c195300a1820b0bc21cea3aa24e4c8b6c4431565ecc79a/pandas-3.0.6-cp311-cp311-win_arm64.whl", hash = "sha256:5e75072773c1b2f7cb63faa3a6f562aede11f3976f68ed34cb538bc091a28171", size = 9106923, upload-time = "2026-09-17T23:20:46.611Z" }, + { url = "https://files.pythonhosted.org/packages/77/4c/597d588c055d4373cff19cbbc32d4dd046c7be8fadee957585b5ba9e5b24/pandas-3.0.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7dac2d65e9087e8e7b5a45fe15c4920911a221df061ab629943ce016489145c7", size = 10397411, upload-time = "2026-09-17T23:20:49.465Z" }, + { url = "https://files.pythonhosted.org/packages/18/8f/48907c7c707b61a8e5018c32e1a3f70623209bfb59020a2f6196159d3aa7/pandas-3.0.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9dab635a549e58a053c7b0fa054dc0bd7be22f0ed9a720f4a85d5fb993276172", size = 10051279, upload-time = "2026-09-17T23:20:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/67/fa/613d867c3d9554a61bafdec6f79565c8a3e73235feb52cc4a72ad2e0fa6a/pandas-3.0.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3dccb584123b399c07562ac4d62543e90ede49ddf8ce3c13ffc64cbe828c281", size = 10303429, upload-time = "2026-09-17T23:20:55.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/67/0c0f18e38d7f2d2af8c24b3315bc4046e73bbdd4a5540405506671ad0c0d/pandas-3.0.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0704044b676496b8350e023b09f174a26772456c974a2b11c36bebb558c9490d", size = 10788193, upload-time = "2026-09-17T23:20:58.617Z" }, + { url = "https://files.pythonhosted.org/packages/39/53/1b57f3162501fe36687e4870e1b918a6458ca7386b173af75663ace95857/pandas-3.0.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e7c1905ef02c3d6d43d9dbd5b6ccb4da4870a0b0c821bbc103fbdb6f3ad2707b", size = 11323501, upload-time = "2026-09-17T23:21:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d2/b1182e8d39100369d7f13f4a125a3fb6b096fef112c46d0566c25781ff68/pandas-3.0.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:569e114072b24fc4970c12e2b4bab252671668a40b324318903380cab0254c0c", size = 11838173, upload-time = "2026-09-17T23:21:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5b717af24d2f27995e51e0875a269dddd373045216e34cf62e3aa764eaa1/pandas-3.0.6-cp312-cp312-pyemscripten_2024_0_wasm32.whl", hash = "sha256:2a8fc94be2ee5f1d86f97aacd8cc566f81680b6498e76f3007421bb5d98151bf", size = 7118499, upload-time = "2026-09-17T23:21:07.661Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/14b3b17cd75cef4a1ee1af4234eb41cc1afe4103b98c2d80e8916abfd42b/pandas-3.0.6-cp312-cp312-win_amd64.whl", hash = "sha256:3ef908d28590b3f42d7070e7ad8f9b34b442b260b7f3c1afb57e0040c58cdb1b", size = 9661439, upload-time = "2026-09-17T23:21:10.959Z" }, + { url = "https://files.pythonhosted.org/packages/3b/11/3d580a604a1e35d69f6676847bd12db7d14344bc677b717f4413e79c5d0d/pandas-3.0.6-cp312-cp312-win_arm64.whl", hash = "sha256:f4e7c52eb108d752e7592268108fd3e98efd76d83a3125cdd06c621c2e44359b", size = 8959806, upload-time = "2026-09-17T23:21:13.851Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/143605a1f6443ad50ebda78a31e5a3a10147fec2590e931584aaa5ff0a09/pandas-3.0.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ae8073aed8e21d1a7fe263dcdc6840743549722a6738198a0a46000fa9476f2", size = 10418900, upload-time = "2026-09-17T23:21:16.594Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ca/87f8548f73d452aab35e4a90f8b39ae303295e0f2ef0b4055c44d6b3f1be/pandas-3.0.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:60d81f9e1799b36f3739e7fff44d1fbb2e8fd5a271b3863e03de9715fccda0fa", size = 10064785, upload-time = "2026-09-17T23:21:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/43/1a/d951442e5607c6e3b2462eff8f420797d428aa74b87c6ecfe4f48553626e/pandas-3.0.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:097090508a1dd335013d39106fc10b20f4fd4a171638e47b77d55798ed9dab6c", size = 10245290, upload-time = "2026-09-17T23:21:22.797Z" }, + { url = "https://files.pythonhosted.org/packages/50/fa/96d50e1e6cd0b08b5e2b7c838f65ae644940f75a124063380b5ef73b6866/pandas-3.0.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1e92d9fa834c7d877130027cddc0cad8dcff97c1f6cca26bd6310f847228b658", size = 10757657, upload-time = "2026-09-17T23:21:25.673Z" }, + { url = "https://files.pythonhosted.org/packages/7b/12/f82d13a2cb703e1a8acee7e01fdc2b898d9cd0c00f07d1dfce63af43e350/pandas-3.0.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b27c8d890e4aa2171437ae2a39de1d215e674158e4865c4023a8b31c932513b2", size = 11249114, upload-time = "2026-09-17T23:21:28.898Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ce/8aef2e561a2f2c8b38c913c67373c65ba6748174e763d27c80271b24bd17/pandas-3.0.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8029ec0f1f89e4f985929ce1f6626dabf3140d61a4e9c1215afdab34eaf9a5d", size = 11820511, upload-time = "2026-09-17T23:21:32.11Z" }, + { url = "https://files.pythonhosted.org/packages/c0/bd/63cb67e6903ef6d9c2871916dbcbc09d254da0fe8b870cf62e16b21945f2/pandas-3.0.6-cp313-cp313-win_amd64.whl", hash = "sha256:f3ce8a6968045481e91a3990e797e348ce13db45ee164a7095bbc824e26c09dd", size = 9638092, upload-time = "2026-09-17T23:21:34.883Z" }, + { url = "https://files.pythonhosted.org/packages/75/2e/e7b35b712edb068d382ddc8b2bea8a04974100515ba2daa22b478b265842/pandas-3.0.6-cp313-cp313-win_arm64.whl", hash = "sha256:cc39303913e2ea129915670de5d1c9fbd647f543bb72e5543bac8baa94e9e42f", size = 8952032, upload-time = "2026-09-17T23:21:37.729Z" }, + { url = "https://files.pythonhosted.org/packages/75/55/1a8875395b05ccd572cbca0b9255dcd2db6e6508e632a558c1a6884b39ad/pandas-3.0.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ee913a91669056c1de1a6b733fbfeab711de9e54e3bee2dfa5fe79d9457247d1", size = 10492213, upload-time = "2026-09-17T23:21:40.746Z" }, + { url = "https://files.pythonhosted.org/packages/35/61/47ae13476995cc8a40cd609e93e7cf11f273d8692925c2903cb6d38aa0d1/pandas-3.0.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff51a4459ed036e93d1eb1bb5e6e7b28685d3cb6b7c12b91c05b31024e234729", size = 10156618, upload-time = "2026-09-17T23:21:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f2/cc5f2adb8d6e86a85d9fb5128f8cf205a61189336f70d1f7faf0d1b53ec9/pandas-3.0.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:654aae059295dbba6ecd2328ca12712a2cf1676214c8699f1c29213f7ccf9c34", size = 10375479, upload-time = "2026-09-17T23:21:47.159Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ba/ffdcb19be4ff6bfe7d969e7cef2c567c633df5a3a1cc1053394ad053bca8/pandas-3.0.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f51d7f651c8054c5e82a69265c98082e795d1442df7ca6edc3a545d61214b1", size = 10783244, upload-time = "2026-09-17T23:21:50.367Z" }, + { url = "https://files.pythonhosted.org/packages/77/5b/e150075b2c6eb69fae896f2d9239bc6ed07db97735971d53d66de6553460/pandas-3.0.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:22172a92e7ee678ec0140c7af4fc9366b55413834a1cd86af78b3caa0b0574de", size = 11382223, upload-time = "2026-09-17T23:21:53.355Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8a/b441c587dc7355bf6e1f68a91b4f76a6c29740f0c23be3acc5d4ebbeea6d/pandas-3.0.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583be68728a31d0d750d5b8d9e00f02b153df0d4655f858bde93cb84cfc4227c", size = 11854881, upload-time = "2026-09-17T23:21:56.342Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e9/f43410fada510b43fec09993c08f552086c3d247d3ee801a678f3cb10ea5/pandas-3.0.6-cp314-cp314-win_amd64.whl", hash = "sha256:77ccbe5057aece6fc172b9b77f19c04335af6882bc2e10c8f3ee4e6bfb3da553", size = 9791672, upload-time = "2026-09-17T23:21:59.332Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9e/db14c059c21f9baa1907d436f8bf30e0c76c6288225c5e8b79a08ba8b2c5/pandas-3.0.6-cp314-cp314-win_arm64.whl", hash = "sha256:fb625f426b375bcc96e3a04c5d5d266cd7be6ae5d6866e0e703382ab5164068c", size = 9121246, upload-time = "2026-09-17T23:22:02.123Z" }, + { url = "https://files.pythonhosted.org/packages/67/ba/bad0f8dac020ab38a8637fddab01a57a82da7a496a6e6f19590aad53ab62/pandas-3.0.6-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9e492cd4bdba6778de4fe0df7f4590c012161ebcf9902dce01b01dc683105514", size = 10920612, upload-time = "2026-09-17T23:22:05.404Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a9/b500982e9aac6d52a58da4ad3f11e14168a315b06906b3f397c427878065/pandas-3.0.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d7dcd21238cbb4828ff148481ba01cac8946dc5121457b5aeba28636f8f99a60", size = 10570213, upload-time = "2026-09-17T23:22:08.44Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/e6ecd0073c98be8f840ac3125b955272835d7d9fd69f5944383b164deb5e/pandas-3.0.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ff482fa91fa2bafd92e8fe66ce3645c851824310f295c1f0a2f96e928fc4541", size = 10252356, upload-time = "2026-09-17T23:22:11.302Z" }, + { url = "https://files.pythonhosted.org/packages/04/f5/001e230a7a7803590d9275a1a3f7e1bb605e3a495cfe5e8d3a532090621b/pandas-3.0.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:db7ec631f26223beee8e5c9e0b8f23c24d8197bbd1d982421d4e3188bea51965", size = 10655952, upload-time = "2026-09-17T23:22:14.283Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ab/bab587148a3852c96aae26c4b5f9e04ce2221801ad94e166b4fbf969ede0/pandas-3.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd75ed0c840f709fc2ae26ddd9534ac77ca1a48ac0cce521a74acaa85f3340a7", size = 11274177, upload-time = "2026-09-17T23:22:17.352Z" }, + { url = "https://files.pythonhosted.org/packages/f3/32/74b48d87df2b80892d713c149abfe36d5db4de41b4eccb042a2bc07dafc1/pandas-3.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ef738d71d1059245b6bb03e312be06d8b3821326a83486c1ad03b9aba3710e44", size = 11719041, upload-time = "2026-09-17T23:22:20.227Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c6/d64b72d64d7eb0fad9fe424d34138e45dee70ddbea1360dcd0adf30e28f6/pandas-3.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:429d9df32731ab01383ed98f2baa7a60368090d1a94fc06019a12062510e8630", size = 10191388, upload-time = "2026-09-17T23:22:23.524Z" }, + { url = "https://files.pythonhosted.org/packages/7a/30/5e5b2ccabeca73ae2b03fc82bca3eabb7466cf43737f05ac08d591665d47/pandas-3.0.6-cp314-cp314t-win_arm64.whl", hash = "sha256:a4dbd4dc65cbe645b92b8785d0f96dd7311010dc6606cf620e51b07b8788a12a", size = 9387796, upload-time = "2026-09-17T23:22:26.64Z" }, ] [[package]] @@ -2036,6 +2064,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-version" +version = "0.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/74/8ed1d7f895b33a89565be4b1c8651d503fcb2cdda951fef4ec6f6906432c/python_version-0.0.2.tar.gz", hash = "sha256:5c16de57c7f2d614621cf468e8f5a20bb6b3cec665c9c7f5e9f9f000bf04fe67", size = 1888, upload-time = "2017-12-01T17:28:30.993Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/e3/06841b7e2b5dab99f6e5ece60db926ce35b9cf351da87ecaf2d75e5d9009/python_version-0.0.2-py2.py3-none-any.whl", hash = "sha256:2c7429f3375cee19873892e5db4d10d2b42172d8851a8bb5111729e028470580", size = 3411, upload-time = "2017-12-01T17:28:32.686Z" }, +] + [[package]] name = "pytz" version = "2026.4" @@ -2196,13 +2233,14 @@ dependencies = [ { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pandas", version = "3.0.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "3.0.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pip" }, { name = "psutil" }, { name = "pydantic" }, { name = "pydantic-extra-types" }, { name = "pyjwt" }, { name = "pytest" }, + { name = "python-version" }, { name = "pyyaml" }, { name = "randomname" }, { name = "requests" }, @@ -2210,7 +2248,7 @@ dependencies = [ { name = "tabulate" }, { name = "tenacity" }, { name = "toml" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, { name = "unyt" }, ] @@ -2223,6 +2261,7 @@ plot = [ [package.dev-dependencies] dev = [ + { name = "basedpyright" }, { name = "interrogate" }, { name = "jinja2" }, { name = "pytest-cov" }, @@ -2236,38 +2275,40 @@ dev = [ [package.metadata] requires-dist = [ - { name = "click", specifier = ">=8.4.1,<9.0.0" }, - { name = "deepmerge", specifier = ">=2.0,<4.0" }, - { name = "email-validator", specifier = ">=2.2.0,<3.0.0" }, - { name = "flatdict", specifier = "==4.1.0" }, - { name = "geocoder", specifier = ">=1.38.1,<2.0.0" }, - { name = "gitpython", specifier = ">=3.1.59,<4.0.0" }, - { name = "humanfriendly", specifier = ">=10.0,<11.0" }, + { name = "click", specifier = ">=8.5.0" }, + { name = "deepmerge", specifier = ">=3.0.1" }, + { name = "email-validator", specifier = ">=2.3.0" }, + { name = "flatdict", specifier = ">=4.1.0" }, + { name = "geocoder", specifier = ">=1.38.1" }, + { name = "gitpython", specifier = ">=3.1.62" }, + { name = "humanfriendly", specifier = ">=10.0" }, { name = "matplotlib", marker = "extra == 'plot'", specifier = ">=3.10.0,<4.0.0" }, - { name = "msgpack", specifier = ">=1.2.1" }, - { name = "numpy", specifier = ">=2.0.0,<3.0.0" }, - { name = "pandas", specifier = ">=2.2.3,<3.0.6" }, - { name = "pip", specifier = ">=26.1.2" }, + { name = "msgpack", specifier = ">=1.2.2" }, + { name = "numpy", specifier = ">=2.2.6" }, + { name = "pandas", specifier = ">=2.3.3" }, + { name = "pip", specifier = ">=26.2.1" }, { name = "plotly", marker = "extra == 'plot'", specifier = ">=6.0.0,<8.0.0" }, - { name = "psutil", specifier = ">=6.1.1,<8.0.0" }, - { name = "pydantic", specifier = ">=2.11,<3.0.0" }, - { name = "pydantic-extra-types", specifier = ">=2.10.5,<3.0.0" }, - { name = "pyjwt", specifier = ">=2.13.0,<3.0.0" }, - { name = "pytest", specifier = ">=9.0.3,<10.0.0" }, - { name = "pyyaml", specifier = ">=6.0.2,<7.0.0" }, - { name = "randomname", specifier = ">=0.2.1,<0.3.0" }, - { name = "requests", specifier = ">=2.32.3,<3.0.0" }, - { name = "semver", specifier = ">=3.0.4,<4.0.0" }, - { name = "tabulate", specifier = ">=0.9.0,<0.11.0" }, - { name = "tenacity", specifier = ">=9.0.0,<10.0.0" }, - { name = "toml", specifier = ">=0.10.2,<0.11.0" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'", specifier = ">=4.12.2,<5.0.0" }, - { name = "unyt", specifier = ">=3.1.0,<4.0.0" }, + { name = "psutil", specifier = ">=7.2.2" }, + { name = "pydantic", specifier = ">=2.13.5" }, + { name = "pydantic-extra-types", specifier = ">=2.11.1" }, + { name = "pyjwt", specifier = ">=2.15.0" }, + { name = "pytest", specifier = ">=9.1.1" }, + { name = "python-version", specifier = ">=0.0.2" }, + { name = "pyyaml", specifier = ">=6.0.3" }, + { name = "randomname", specifier = ">=0.2.1" }, + { name = "requests", specifier = ">=2.34.2" }, + { name = "semver", specifier = ">=3.1.0" }, + { name = "tabulate", specifier = ">=0.10.0" }, + { name = "tenacity", specifier = ">=9.1.4" }, + { name = "toml", specifier = ">=0.10.2" }, + { name = "typing-extensions", specifier = ">=4.16.0" }, + { name = "unyt", specifier = ">=3.1.0" }, ] provides-extras = ["plot"] [package.metadata.requires-dev] dev = [ + { name = "basedpyright", specifier = ">=1.40.1" }, { name = "interrogate", specifier = ">=1.7.0" }, { name = "jinja2", specifier = ">=3.1.6" }, { name = "pytest-cov", specifier = ">=7.1.0" }, @@ -2275,8 +2316,8 @@ dev = [ { name = "pytest-sugar", specifier = ">=1.1.1" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, - { name = "ruff", specifier = ">=0.15.14" }, - { name = "types-requests", specifier = ">=2.33.0.20260518" }, + { name = "ruff", specifier = ">=0.16.9" }, + { name = "types-requests", specifier = ">=2.33.0.20260906" }, ] [[package]]