diff --git a/docs/oidc-device-flow-release-notes.md b/docs/oidc-device-flow-release-notes.md new file mode 100644 index 000000000..f420b70f0 --- /dev/null +++ b/docs/oidc-device-flow-release-notes.md @@ -0,0 +1,108 @@ +# ValidMind Python library: OIDC device flow (release notes) + +This note summarizes what your identity provider (IdP) must expose, how to call the library with the device authorization flow ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)), and how authentication works end to end. Share it with security and platform teams when enabling notebook or CLI login without long-lived API keys. + +--- + +## 1. IdP configuration the library depends on + +The SDK does **OIDC discovery** against your issuer. From `{issuer}/.well-known/openid-configuration` it **requires** at least: + +| Discovery field | Why | +|-----------------|-----| +| `device_authorization_endpoint` | Start the device flow (user gets a code and verification URL). | +| `token_endpoint` | Poll for tokens after authorization, and refresh access tokens. | + +**OAuth / IdP app registration** + +- Register an OAuth **public client** used only with the device flow (no embedded client secret in the library). +- Enable the **device code** / device authorization grant for that client (names vary by vendor). + - **Microsoft Entra ID:** Turn on **Allow public client flows** for the app registration and ensure the device code flow is permitted for your tenant/policy. +- Supply ValidMind with values that match what the **ValidMind platform** already trusts for your organization: + - **Issuer URL** — Normalized OpenID Provider issuer (example Entra v2: `https://login.microsoftonline.com//v2.0`). + - **Client ID** — The application (client) ID of the public OAuth client. + - **Scopes** — The library defaults to `openid profile email`. Your IdP may require **additional scopes** or a **resource identifier** so the access token is acceptable to the ValidMind API. + - **Audience / API identifier (often required)** — Access tokens must pass ValidMind’s JWT validation (issuer, audience, signing keys). Many setups need an explicit **audience** for API-style access tokens (e.g. Auth0 API Identifier, or Azure AD custom scope / resource). Pass this as the `audience` argument (or set env `VM_OIDC_AUDIENCE`) so the provider issues tokens ValidMind can verify. + +**Operational checks** + +- Misconfiguration often appears as a token issued by the IdP but **401/403 from ValidMind** (e.g. wrong audience). Align `audience` / scopes with what ValidMind has configured for your tenant (`api_audience` and JWKS). +- Revoking access: users can sign out or revoke the app in the IdP portal; the library also caches tokens locally (see below). + +--- + +## 2. `vm.init` and `api_client.init` (same API) + +`import validmind as vm` exposes **`vm.init`**, which is the same function as **`validmind.api_client.init`**. There is no separate “OIDC-only” entrypoint—authentication mode is selected by the arguments (and environment variables) you pass. + +**API key mode (unchanged)** + +```python +vm.init( + api_key="...", + api_secret="...", + api_host="https://.../api/v1/tracking/", # or api_url= (alias) + model="", +) +``` + +Environment variables still apply when arguments are omitted: `VM_API_KEY`, `VM_API_SECRET`, `VM_API_HOST`, `VM_API_MODEL`. + +**OIDC device flow mode (new)** + +```python +vm.init( + issuer="https://login.microsoftonline.com//v2.0", + client_id="", + model="", + api_host="https://.../api/v1/tracking/", # or api_url= (alias); defaults from VM_API_HOST if unset + scope="openid profile email", # optional; this is the default if omitted + audience="", # optional; often required for API tokens; or VM_OIDC_AUDIENCE +) +``` + +Rules: + +- Provide **either** API key + secret **or** OIDC (`issuer` + `client_id`). Mixing both raises an error. +- If `issuer` is set, **`client_id` is required** (and vice versa). +- Optional **`audience`** can also be set via **`VM_OIDC_AUDIENCE`** if you prefer not to put it in code. + +Equivalent import: + +```python +from validmind import api_client + +api_client.init(issuer="...", client_id="...", model="...", api_host="...") +``` + +--- + +## 3. How the flow works (overview) + +1. **`vm.init(..., issuer=, client_id=, ...)`** loads cached tokens from `~/.validmind/credentials.json` (if present) for the normalized `(issuer, client_id)` — and optional audience — key. +2. If there is a **valid access token**, it is reused. +3. If the access token is expired but a **refresh token** is available, the library **refreshes** silently; on failure it clears that cache entry and continues. +4. If no usable token exists, the library runs the **device authorization flow**: + - Fetches OIDC metadata from `{issuer}/.well-known/openid-configuration`. + - POSTs to **`device_authorization_endpoint`** with `client_id`, `scope`, and `audience` (when configured). + - Prints **verification URL** and **user code** (from the IdP response) so the user can complete login in a browser. + - **Polls** **`token_endpoint`** until the user authorizes, using `grant_type` for the device code per RFC 8628, then stores **access** (and refresh when provided) tokens in the credential file. +5. Subsequent calls to the ValidMind **tracking API** send **`Authorization: Bearer `** together with the usual headers such as **`X-MODEL-CUID`**. API key headers are not used in OIDC mode. + +Org and model access are enforced server-side: the user must be allowed to use the model identified by `model` (model CUID), consistent with ValidMind’s existing authorization rules for library clients. + +--- + +## Quick reference + +| Item | Purpose | +|------|---------| +| Issuer URL | Discovery and token validation context | +| Client ID | Public OAuth client for device flow | +| Model CUID | Identifies the inventory model (`model=` or `VM_API_MODEL`) | +| API host / URL | Tracking API base (`api_host`, `api_url`, or `VM_API_HOST`) | +| Scope | OAuth scopes (default `openid profile email`) | +| Audience | Often needed so access tokens target the ValidMind API (`audience` or `VM_OIDC_AUDIENCE`) | +| Credential file | `~/.validmind/credentials.json` (cached tokens; restrict like other secrets on shared machines) | + +For implementation details in this repository, see `validmind/oidc_device.py`, `validmind/credentials_store.py`, and `validmind/api_client.py`. diff --git a/notebooks/quickstart/quickstart_model_documentation_oidc_device_flow.ipynb b/notebooks/quickstart/quickstart_model_documentation_oidc_device_flow.ipynb new file mode 100644 index 000000000..9fff9205a --- /dev/null +++ b/notebooks/quickstart/quickstart_model_documentation_oidc_device_flow.ipynb @@ -0,0 +1,889 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "686bdc65", + "metadata": {}, + "source": [ + "# Quickstart for model documentation (OIDC device flow)\n", + "\n", + "Learn the basics of using ValidMind to document models as part of a model development workflow. **This variant authenticates with the OIDC device authorization flow** ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) instead of storing API keys in the notebook: you sign in through your organization's identity provider when `vm.init()` runs. After setup, generate a draft of documentation using ValidMind tests for a binary classification model.\n", + "\n", + "To document a model with the ValidMind Library, we'll:\n", + "\n", + "1. Import a sample dataset and preprocess it\n", + "2. Split the datasets and initialize them for use with ValidMind\n", + "3. Initialize a model object for use with testing\n", + "4. Run a full suite of tests as defined by our documentation template, which will send the results of those tests to the ValidMind Platform" + ] + }, + { + "cell_type": "markdown", + "id": "17e1b850", + "metadata": {}, + "source": [ + "::: {.content-hidden when-format=\"html\"}\n", + "## Contents \n", + "- [Introduction](#toc1__) \n", + "- [About ValidMind](#toc2__) \n", + " - [Before you begin](#toc2_1__) \n", + " - [New to ValidMind?](#toc2_2__) \n", + " - [Key concepts](#toc2_3__) \n", + "- [Setting up](#toc3__) \n", + " - [Install the ValidMind Library](#toc3_1__) \n", + " - [Initialize the ValidMind Library](#toc3_2__) \n", + " - [Register sample model](#toc3_2_1__) \n", + " - [Apply documentation template](#toc3_2_2__) \n", + " - [Get your code snippet](#toc3_2_3__) \n", + " - [Initialize the Python environment](#toc3_3__) \n", + "- [Getting to know ValidMind](#toc4__) \n", + " - [Preview the documentation template](#toc4_1__) \n", + " - [View model documentation in the ValidMind Platform](#toc4_2__) \n", + "- [Import the sample dataset](#toc5__) \n", + "- [Preprocessing the raw dataset](#toc6__) \n", + " - [Split the dataset](#toc6_1__) \n", + " - [Separate features and targets](#toc6_2__) \n", + "- [Training an XGBoost classifier model](#toc7__) \n", + " - [Set evaluation metrics](#toc7_1__) \n", + " - [Fit the model](#toc7_2__) \n", + "- [Initialize the ValidMind datasets](#toc8__) \n", + "- [Initialize a model object](#toc9__) \n", + " - [Assign predictions](#toc9_1__) \n", + "- [Run the full suite of tests](#toc10__) \n", + "- [In summary](#toc11__) \n", + "- [Next steps](#toc12__) \n", + " - [Work with your model documentation](#toc12_1__) \n", + " - [Discover more learning resources](#toc12_2__) \n", + "- [Upgrade ValidMind](#toc13__) \n", + "\n", + ":::\n", + "\n", + "" + ] + }, + { + "cell_type": "markdown", + "id": "16993535", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Introduction\n", + "\n", + "Model development aims to produce a fit-for-purpose *champion model* by conducting thorough testing and analysis, supporting the capabilities of the model with evidence in the form of documentation and test results. Model documentation should be clear and comprehensive, ideally following a structure or template covering all aspects of compliance with model risk regulation.\n", + "\n", + "A *binary classification model* is a type of predictive model used in churn analysis to identify customers who are likely to leave a service or subscription by analyzing various behavioral, transactional, and demographic factors.\n", + "\n", + "- This model helps businesses take proactive measures to retain at-risk customers by offering personalized incentives, improving customer service, or adjusting pricing strategies.\n", + "- Effective validation of a churn prediction model ensures that businesses can accurately identify potential churners, optimize retention efforts, and enhance overall customer satisfaction while minimizing revenue loss." + ] + }, + { + "cell_type": "markdown", + "id": "3b8e33f0", + "metadata": {}, + "source": [ + "\n", + "\n", + "## About ValidMind\n", + "\n", + "ValidMind is a suite of tools for managing model risk, including risk associated with AI and statistical models. \n", + "\n", + "You use the ValidMind Library to automate documentation and validation tests, and then use the ValidMind Platform to collaborate on model documentation. Together, these products simplify model risk management, facilitate compliance with regulations and institutional standards, and enhance collaboration between yourself and model validators." + ] + }, + { + "cell_type": "markdown", + "id": "24a5e851", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Before you begin\n", + "\n", + "This notebook assumes you have basic familiarity with Python, including an understanding of how functions work. If you are new to Python, you can still run the notebook but we recommend further familiarizing yourself with the language. \n", + "\n", + "If you encounter errors due to missing modules in your Python environment, install the modules with `pip install`, and then re-run the notebook. For more help, refer to [Installing Python Modules](https://docs.python.org/3/installing/index.html)." + ] + }, + { + "cell_type": "markdown", + "id": "87f8ed22", + "metadata": {}, + "source": [ + "\n", + "\n", + "### New to ValidMind?\n", + "\n", + "If you haven't already seen our documentation on the [ValidMind Library](https://docs.validmind.ai/developer/validmind-library.html), we recommend you begin by exploring the available resources in this section. There, you can learn more about documenting models and running tests, as well as find code samples and our Python Library API reference.\n", + "\n", + "
For access to all features available in this notebook, you'll need access to a ValidMind account.\n", + "

\n", + "Register with ValidMind
" + ] + }, + { + "cell_type": "markdown", + "id": "61497ac2", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Key concepts\n", + "\n", + "**Model documentation**: A structured and detailed record pertaining to a model, encompassing key components such as its underlying assumptions, methodologies, data sources, inputs, performance metrics, evaluations, limitations, and intended uses. It serves to ensure transparency, adherence to regulatory requirements, and a clear understanding of potential risks associated with the model’s application.\n", + "\n", + "**Documentation template**: Functions as a test suite and lays out the structure of model documentation, segmented into various sections and sub-sections. Documentation templates define the structure of your model documentation, specifying the tests that should be run, and how the results should be displayed.\n", + "\n", + "**Tests**: A function contained in the ValidMind Library, designed to run a specific quantitative test on the dataset or model. Tests are the building blocks of ValidMind, used to evaluate and document models and datasets, and can be run individually or as part of a suite defined by your model documentation template.\n", + "\n", + "**Metrics**: A subset of tests that do not have thresholds. In the context of this notebook, metrics and tests can be thought of as interchangeable concepts.\n", + "\n", + "**Custom metrics**: Custom metrics are functions that you define to evaluate your model or dataset. These functions can be registered with the ValidMind Library to be used in the ValidMind Platform.\n", + "\n", + "**Inputs**: Objects to be evaluated and documented in the ValidMind Library. They can be any of the following:\n", + "\n", + " - **model**: A single model that has been initialized in ValidMind with [`vm.init_model()`](https://docs.validmind.ai/validmind/validmind.html#init_model).\n", + " - **dataset**: Single dataset that has been initialized in ValidMind with [`vm.init_dataset()`](https://docs.validmind.ai/validmind/validmind.html#init_dataset).\n", + " - **models**: A list of ValidMind models - usually this is used when you want to compare multiple models in your custom metric.\n", + " - **datasets**: A list of ValidMind datasets - usually this is used when you want to compare multiple datasets in your custom metric. (Learn more: [Run tests with multiple datasets](https://docs.validmind.ai/notebooks/how_to/tests/run_tests/configure_tests/run_tests_that_require_multiple_datasets.html))\n", + "\n", + "**Parameters**: Additional arguments that can be passed when running a ValidMind test, used to pass additional information to a metric, customize its behavior, or provide additional context.\n", + "\n", + "**Outputs**: Custom metrics can return elements like tables or plots. Tables may be a list of dictionaries (each representing a row) or a pandas DataFrame. Plots may be matplotlib or plotly figures.\n", + "\n", + "**Test suites**: Collections of tests designed to run together to automate and generate model documentation end-to-end for specific use-cases.\n", + "\n", + "Example: the [`classifier_full_suite`](https://docs.validmind.ai/validmind/validmind/test_suites/classifier.html#ClassifierFullSuite) test suite runs tests from the [`tabular_dataset`](https://docs.validmind.ai/validmind/validmind/test_suites/tabular_datasets.html) and [`classifier`](https://docs.validmind.ai/validmind/validmind/test_suites/classifier.html) test suites to fully document the data and model sections for binary classification model use-cases." + ] + }, + { + "cell_type": "markdown", + "id": "5c4158bd", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Setting up" + ] + }, + { + "cell_type": "markdown", + "id": "fd4a5481", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Install the ValidMind Library\n", + "\n", + "
Recommended Python versions\n", + "

\n", + "Python 3.8 <= x <= 3.14
\n", + "\n", + "To install the library:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d1f6dbed", + "metadata": {}, + "outputs": [], + "source": [ + "%pip install -q validmind" + ] + }, + { + "cell_type": "markdown", + "id": "d32e4d62", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Initialize the ValidMind Library\n", + "\n", + "Use [`vm.init()`](https://docs.validmind.ai/validmind/validmind.html) with **`issuer`** and **`client_id`** (your IdP’s OAuth public client for device flow), plus **`model`** and **`api_host`** (or environment variables `VM_API_MODEL` / `VM_API_HOST`). Do not pass `api_key` / `api_secret` here; those are for the API-key quickstart. Optional **`audience`** (or `VM_OIDC_AUDIENCE`) and **`scope`** are often needed so tokens are accepted by the ValidMind API; your ValidMind or identity administrator can supply values that match your deployment. For general library usage, see the [ValidMind Library documentation](https://docs.validmind.ai/developer/validmind-library.html).\n", + "\n", + "When you run the cell below, the library may print a **verification URL** and **user code**: open the URL, enter the code, and complete sign-in; the notebook waits until the flow finishes. Valid tokens are cached under `~/.validmind/credentials.json`." + ] + }, + { + "cell_type": "markdown", + "id": "70757032", + "metadata": {}, + "source": [ + "\n", + "\n", + "#### Register sample model\n", + "\n", + "Let's first register a sample model for use with this notebook:\n", + "\n", + "1. In a browser, [log in to ValidMind](https://docs.validmind.ai/guide/configuration/log-in-to-validmind.html).\n", + "\n", + "2. In the left sidebar, navigate to **Inventory** and click **+ Register Model**.\n", + "\n", + "3. Enter the model details and click **Next >** to continue to assignment of model stakeholders. ([Need more help?](https://docs.validmind.ai/guide/model-inventory/register-models-in-inventory.html))\n", + "\n", + "4. Select your own name under the **MODEL OWNER** drop-down.\n", + "\n", + "5. Click **Register Model** to add the model to your inventory." + ] + }, + { + "cell_type": "markdown", + "id": "445ac2ce", + "metadata": {}, + "source": [ + "\n", + "\n", + "#### Apply documentation template\n", + "\n", + "Once you've registered your model, let's select a documentation template. A template predefines sections for your model documentation and provides a general outline to follow, making the documentation process much easier.\n", + "\n", + "1. In the left sidebar that appears for your model, click **Documents** and select **Development**.\n", + "\n", + "2. Under **TEMPLATE**, select `Binary classification`.\n", + "\n", + "3. Click **Use Template** to apply the template." + ] + }, + { + "cell_type": "markdown", + "id": "c30a58b5", + "metadata": {}, + "source": [ + "\n", + "\n", + "#### Configure `vm.init()` for OIDC\n", + "\n", + "Point the library at the correct **model** and **tracking API**, and supply OIDC **issuer** / **client_id** values your organization registered for device flow. If you see 401/403 responses after signing in at your IdP, verify **audience** and **scope** with your administrator.\n", + "\n", + "1. In the ValidMind Platform, open your model → **Getting Started** → **DOCUMENT** `Development` so you can copy the **model CUID** (same identifier as in the API-key snippet).\n", + "2. Fill in `issuer`, `client_id`, `model`, and `api_host` below. Use optional `audience` / `scope` if your platform team specified them.\n", + "3. Run the cell. Complete browser sign-in if prompted; when polling succeeds, continue with the rest of the notebook.\n", + "\n", + "You can optionally load `VM_API_HOST`, `VM_API_MODEL`, or `VM_OIDC_AUDIENCE` from a `.env` file instead of passing them inline—see [Store credentials in an env file](https://docs.validmind.ai/developer/model-documentation/store-credentials-in-env-file.html) for the pattern (environment variables still apply when arguments are omitted)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e2c1dd22", + "metadata": {}, + "outputs": [], + "source": [ + "# Optional: load VM_API_HOST, VM_API_MODEL, VM_OIDC_AUDIENCE from `.env`\n", + "# %load_ext dotenv\n", + "# %dotenv .env\n", + "\n", + "import validmind as vm\n", + "\n", + "vm.init(\n", + " issuer=\"https://login.microsoftonline.com//v2.0\", # your IdP issuer (OpenID discovery)\n", + " client_id=\"\",\n", + " model=\"\",\n", + " api_host=\"https:///api/v1/tracking/\",\n", + " # audience=\"\", # often required; or set VM_OIDC_AUDIENCE\n", + " # scope=\"openid profile email\", # optional; default if omitted\n", + " document=\"documentation\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "d1dcb3d0", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Initialize the Python environment\n", + "\n", + "Then, let's import the necessary libraries and set up your Python environment for data analysis:\n", + "\n", + "- Import **Extreme Gradient Boosting** (XGBoost) with an alias so that we can reference its functions in later calls. XGBoost is a powerful machine learning library designed for speed and performance, especially in handling structured or tabular data.\n", + "- Enable **`matplotlib`**, a plotting library used for visualizing data. Ensures that any plots you generate will render inline in our notebook output rather than opening in a separate window." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "62d7c2c1", + "metadata": {}, + "outputs": [], + "source": [ + "import xgboost as xgb\n", + "\n", + "%matplotlib inline" + ] + }, + { + "cell_type": "markdown", + "id": "7bbc5e0c", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Getting to know ValidMind" + ] + }, + { + "cell_type": "markdown", + "id": "b07067d5", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Preview the documentation template\n", + "\n", + "Let's verify that you have connected the ValidMind Library to the ValidMind Platform and that the appropriate *template* is selected for your model.\n", + "\n", + "You will upload documentation and test results unique to your model based on this template later on. For now, **take a look at the default structure that the template provides with [the `vm.preview_template()` function](https://docs.validmind.ai/validmind/validmind.html#preview_template)** from the ValidMind library and note the empty sections:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b2bce375", + "metadata": {}, + "outputs": [], + "source": [ + "vm.preview_template()" + ] + }, + { + "cell_type": "markdown", + "id": "1336b875", + "metadata": {}, + "source": [ + "\n", + "\n", + "### View model documentation in the ValidMind Platform\n", + "\n", + "Next, let's head to the ValidMind Platform to see the template in action:\n", + "\n", + "1. In a browser, [log in to ValidMind](https://docs.validmind.ai/guide/configuration/log-in-to-validmind.html).\n", + "\n", + "2. In the left sidebar, navigate to **Inventory** and select the model you registered for this notebook.\n", + "\n", + "3. Click **Development** under Documents for your model and note how the structure of the documentation matches our preview above." + ] + }, + { + "cell_type": "markdown", + "id": "e2281cec", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Import the sample dataset\n", + "\n", + "First, let's import the public [Bank Customer Churn Prediction](https://www.kaggle.com/datasets/shantanudhakadd/bank-customer-churn-prediction) dataset from Kaggle so that we have something to work with.\n", + "\n", + "In our below example, note that: \n", + "\n", + "- The target column, `Exited` has a value of `1` when a customer has churned and `0` otherwise.\n", + "- The ValidMind Library provides a wrapper to automatically load the dataset as a [Pandas DataFrame](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html) object. A Pandas Dataframe is a two-dimensional tabular data structure that makes use of rows and columns." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "58d1c94b", + "metadata": {}, + "outputs": [], + "source": [ + "from validmind.datasets.classification import customer_churn\n", + "\n", + "print(\n", + " f\"Loaded demo dataset with: \\n\\n\\t• Target column: '{customer_churn.target_column}' \\n\\t• Class labels: {customer_churn.class_labels}\"\n", + ")\n", + "\n", + "raw_df = customer_churn.load_data()\n", + "raw_df.head()" + ] + }, + { + "cell_type": "markdown", + "id": "0aafde18", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Preprocessing the raw dataset\n", + "\n", + "Before running tests with ValidMind, we'll need to preprocess our imported dataset. This involves splitting the data and separating the features (inputs) from the targets (outputs)." + ] + }, + { + "cell_type": "markdown", + "id": "dcd9848d", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Split the dataset\n", + "\n", + "Splitting our dataset helps assess how well the model generalizes to unseen data.\n", + "\n", + "Use [`preprocess()`](https://docs.validmind.ai/validmind/validmind/datasets/classification/customer_churn.html#preprocess) to split our dataset into three subsets:\n", + "\n", + "1. **train_df** — Used to train the model.\n", + "2. **validation_df** — Used to evaluate the model's performance during training.\n", + "3. **test_df** — Used later on to asses the model's performance on new, unseen data." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "418cb5aa", + "metadata": {}, + "outputs": [], + "source": [ + "train_df, validation_df, test_df = customer_churn.preprocess(raw_df)" + ] + }, + { + "cell_type": "markdown", + "id": "0ed6cb75", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Separate features and targets\n", + "\n", + "To train the model, we need to provide it with:\n", + "\n", + "1. **Inputs** — Features such as customer age, usage, etc.\n", + "2. **Outputs (Expected answers/labels)** — in our case, we would like to know whether the customer churned or not.\n", + "\n", + "Here, we'll use `x_train` and `x_val` to hold the input data (features), and `y_train` and `y_val` to hold the answers (the target we want to predict):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6fd365fd", + "metadata": {}, + "outputs": [], + "source": [ + "x_train = train_df.drop(customer_churn.target_column, axis=1)\n", + "y_train = train_df[customer_churn.target_column]\n", + "x_val = validation_df.drop(customer_churn.target_column, axis=1)\n", + "y_val = validation_df[customer_churn.target_column]" + ] + }, + { + "cell_type": "markdown", + "id": "3e1d226e", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Training an XGBoost classifier model\n", + "\n", + "Next, let's create an XGBoost classifier model that will automatically stop training if it doesn’t improve after 10 tries.\n", + "\n", + "Setting a threshold avoids wasting time and helps prevent overfitting by stopping training when further improvement isn’t happening." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3296cac6", + "metadata": {}, + "outputs": [], + "source": [ + "model = xgb.XGBClassifier(early_stopping_rounds=10)" + ] + }, + { + "cell_type": "markdown", + "id": "d641a3f1", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Set evaluation metrics\n", + "\n", + "Then, we'll set the evaluation metrics, which tells the model to use three different ways to measure its performance:\n", + "\n", + "1. **error** — Measures how often the model makes incorrect predictions.\n", + "2. **logloss** — Indicates how confident the predictions are.\n", + "3. **auc** — Evaluates how well the model distinguishes between churn and not churn.\n", + "\n", + "Using multiple metrics gives a more complete picture of how good (or bad) the model is." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32d3c3f4", + "metadata": {}, + "outputs": [], + "source": [ + "model.set_params(\n", + " eval_metric=[\"error\", \"logloss\", \"auc\"],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "ec0de49c", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Fit the model\n", + "\n", + "Finally, our actual training step — where the model learns patterns from the data, so it can make predictions later:\n", + "\n", + "- The model is trained on `x_train` and `y_train`, and evaluates its performance using `x_val` and `y_val` to check if it’s learning well.\n", + "- To turn off printed output while training, we'll set `verbose` to `False`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3fb95ce4", + "metadata": {}, + "outputs": [], + "source": [ + "model.fit(\n", + " x_train,\n", + " y_train,\n", + " eval_set=[(x_val, y_val)],\n", + " verbose=False,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "833a5047", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Initialize the ValidMind datasets\n", + "\n", + "Before you can run tests with your preprocessed datasets, you must first initialize a ValidMind `Dataset` object using the [`init_dataset`](https://docs.validmind.ai/validmind/validmind.html#init_dataset) function from the ValidMind (`vm`) module. **This step is always necessary every time you want to connect a dataset to documentation and produce test results through ValidMind,** but you only need to do it once per dataset.\n", + "\n", + "For this example, we'll pass in the following arguments:\n", + "\n", + "- **`dataset`** — The raw dataset that you want to provide as input to tests.\n", + "- **`input_id`** — A unique identifier that allows tracking what inputs are used when running each individual test.\n", + "- **`target_column`** — A required argument if tests require access to true values. This is the name of the target column in the dataset.\n", + "- **`class_labels`** — An optional value to map predicted classes to class labels." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bb6ad06a", + "metadata": {}, + "outputs": [], + "source": [ + "# Initialize the raw dataset\n", + "vm_raw_dataset = vm.init_dataset(\n", + " dataset=raw_df,\n", + " input_id=\"raw_dataset\",\n", + " target_column=customer_churn.target_column,\n", + " class_labels=customer_churn.class_labels,\n", + ")\n", + "\n", + "# Initialize the training dataset\n", + "vm_train_ds = vm.init_dataset(\n", + " dataset=train_df,\n", + " input_id=\"train_dataset\",\n", + " target_column=customer_churn.target_column,\n", + ")\n", + "\n", + "# Initialize the testing dataset\n", + "vm_test_ds = vm.init_dataset(\n", + " dataset=test_df,\n", + " input_id=\"test_dataset\",\n", + " target_column=customer_churn.target_column\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "316ae030", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Initialize a model object\n", + "\n", + "You'll also need to initialize a ValidMind model object (`vm_model`) that can be passed to other functions for analysis and tests on the data for our model.\n", + "\n", + "You simply initialize this model object with [`vm.init_model()`](https://docs.validmind.ai/validmind/validmind.html#init_model):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0e44eebd", + "metadata": {}, + "outputs": [], + "source": [ + "vm_model = vm.init_model(\n", + " model,\n", + " input_id=\"model\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "3002517f", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Assign predictions\n", + "\n", + "Once the model has been registered, you can assign model predictions to the training and testing datasets.\n", + "\n", + "- The [`assign_predictions()` method](https://docs.validmind.ai/validmind/validmind/vm_models.html#assign_predictions) from the `Dataset` object can link existing predictions to any number of models.\n", + "- This method links the model's class prediction values and probabilities to our `vm_train_ds` and `vm_test_ds` datasets.\n", + "\n", + "If no prediction values are passed, the method will compute predictions automatically:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "62bd94fc", + "metadata": {}, + "outputs": [], + "source": [ + "vm_train_ds.assign_predictions(\n", + " model=vm_model,\n", + ")\n", + "\n", + "vm_test_ds.assign_predictions(\n", + " model=vm_model,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "123d94f9", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Run the full suite of tests\n", + "\n", + "This is where it all comes together — you are now ready to **run the documentation tests for the model as defined by the documentation template** you looked at earlier.\n", + "\n", + "The [`vm.run_documentation_tests`](https://docs.validmind.ai/validmind/validmind.html#run_documentation_tests) function finds and runs every test specified in the template and then uploads all the documentation and test artifacts that get generated to the ValidMind Platform:\n", + "\n", + "- The function requires information about the inputs to use on every test. These inputs can be passed as an `inputs` argument if we want to use the same inputs for all tests. \n", + "- It's also possible to pass a `config` argument that has information about the `params` and `inputs` that each test requires. The `config` parameter is a dictionary with the following structure:\n", + "\n", + " ```python\n", + " config = {\n", + " \"\": {\n", + " \"params\": {\n", + " \"param1\": \"value1\",\n", + " \"param2\": \"value2\",\n", + " ...\n", + " },\n", + " \"inputs\": {\n", + " \"input1\": \"value1\",\n", + " \"input2\": \"value2\",\n", + " ...\n", + " }\n", + " },\n", + " ...\n", + " }\n", + " ```\n", + "\n", + " Each `` above corresponds to the test driven block identifiers shown by `vm.preview_template()`. For this model, we will use the default parameters for all tests, but we'll need to specify the input configuration for each one. The method `get_demo_test_config()` below constructs the default input configuration for our demo." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b3d6741b", + "metadata": {}, + "outputs": [], + "source": [ + "from validmind.utils import preview_test_config\n", + "\n", + "test_config = customer_churn.get_demo_test_config()\n", + "preview_test_config(test_config)" + ] + }, + { + "cell_type": "markdown", + "id": "323c9246", + "metadata": {}, + "source": [ + "Now we can pass the input configuration to `vm.run_documentation_tests()` and run the full suite of tests.\n", + "\n", + "The variable `full_suite` then holds the result of these tests:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ae3accf7", + "metadata": {}, + "outputs": [], + "source": [ + "full_suite = vm.run_documentation_tests(config=test_config)" + ] + }, + { + "cell_type": "markdown", + "id": "5235c139", + "metadata": {}, + "source": [ + "\n", + "\n", + "## In summary\n", + "\n", + "In this notebook, you learned how to:\n", + "\n", + "- [x] Register a model within the ValidMind Platform\n", + "- [x] Install and initialize the ValidMind Library\n", + "- [x] Preview the documentation template for your model\n", + "- [x] Import a sample dataset\n", + "- [x] Initialize ValidMind datasets and model objects\n", + "- [x] Assign model predictions to your ValidMind model objects\n", + "- [x] Run a full suite of documentation tests" + ] + }, + { + "cell_type": "markdown", + "id": "2c84651a", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Next steps\n", + "\n", + "You can look at the output produced by the ValidMind Library right in the notebook where you ran the code, as you would expect. But there is a better way — use the ValidMind Platform to work with your model documentation." + ] + }, + { + "cell_type": "markdown", + "id": "946e40b2", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Work with your model documentation\n", + "\n", + "1. From the **Inventory** in the ValidMind Platform, go to the model you registered earlier. ([Need more help?](https://docs.validmind.ai/guide/model-inventory/working-with-model-inventory.html))\n", + "\n", + "2. In the left sidebar that appears for your model, click **Development** under Documents.\n", + "\n", + "What you see is the full draft of your model documentation in a more easily consumable version. From here, you can make qualitative edits to model documentation, view guidelines, collaborate with validators, and submit your model documentation for approval when it's ready. [Learn more ...](https://docs.validmind.ai/guide/working-with-model-documentation.html)" + ] + }, + { + "cell_type": "markdown", + "id": "0b83397d", + "metadata": {}, + "source": [ + "\n", + "\n", + "### Discover more learning resources\n", + "\n", + "For a more in-depth introduction to using the ValidMind Library for development, check out our introductory development series and the accompanying interactive training:\n", + "\n", + "- **[ValidMind for model development](https://docs.validmind.ai/developer/validmind-library.html#for-model-development)**\n", + "- **[Developer Fundamentals](https://docs.validmind.ai/training/developer-fundamentals/developer-fundamentals-register.html)**\n", + "\n", + "We also offer many interactive notebooks to help you document models:\n", + "\n", + "- [Run tests & test suites](https://docs.validmind.ai/developer/how-to/testing-overview.html)\n", + "- [Use ValidMind Library features](https://docs.validmind.ai/developer/how-to/feature-overview.html)\n", + "- [Code samples by use case](https://docs.validmind.ai/guide/samples-jupyter-notebooks.html)\n", + "\n", + "Or, visit our [documentation](https://docs.validmind.ai/) to learn more about ValidMind." + ] + }, + { + "cell_type": "markdown", + "id": "9c78dfe5", + "metadata": {}, + "source": [ + "\n", + "\n", + "## Upgrade ValidMind\n", + "\n", + "
After installing ValidMind, you’ll want to periodically make sure you are on the latest version to access any new features and other enhancements.
\n", + "\n", + "Retrieve the information for the currently installed version of ValidMind:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "35955b6b", + "metadata": {}, + "outputs": [], + "source": [ + "%pip show validmind" + ] + }, + { + "cell_type": "markdown", + "id": "f6061b3d", + "metadata": {}, + "source": [ + "If the version returned is lower than the version indicated in our [production open-source code](https://github.com/validmind/validmind-library/blob/prod/validmind/__version__.py), restart your notebook and run:\n", + "\n", + "```bash\n", + "%pip install --upgrade validmind\n", + "```" + ] + }, + { + "cell_type": "markdown", + "id": "f7216e8c", + "metadata": {}, + "source": [ + "You may need to restart your kernel after running the upgrade package for changes to be applied." + ] + }, + { + "cell_type": "markdown", + "id": "copyright-e26871efeffc48e386d68e90db1838e7", + "metadata": {}, + "source": [ + "\n", + "\n", + "\n", + "\n", + "***\n", + "\n", + "Copyright © 2023-2026 ValidMind Inc. All rights reserved.
\n", + "Refer to [LICENSE](https://github.com/validmind/validmind-library/blob/main/LICENSE) for details.
\n", + "SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial
" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "ValidMind Library", + "language": "python", + "name": "validmind" + }, + "language_info": { + "name": "python", + "version": "3.10.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/poetry.lock b/poetry.lock index c725d1595..beb5a039e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14124,4 +14124,4 @@ xgboost = ["xgboost"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.15" -content-hash = "b596dabeb3fce78d4634ed1da7ef7fc1936a8cb225877e0ac2f34c2c04260bb4" +content-hash = "a406c8fef907fb4fa7f8adcd921266b6f7d98cedea1d3207147e6a075afbe361" diff --git a/pyproject.toml b/pyproject.toml index 63a6842a1..a80eeb22f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ authors = [ ] dependencies = [ "aiohttp[speedups] (<3.13.1)", + "requests (>=2.28.0,<3.0.0)", "ipywidgets", "kaleido (>=1.2.0,<2.0.0)", "matplotlib", diff --git a/tests/test_api_client.py b/tests/test_api_client.py index ba24f86ca..848000d7d 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -19,6 +19,7 @@ APIRequestError, MissingAPICredentialsError, MissingModelIdError, + ValidMindAuthError, ) from validmind.utils import md_to_html from validmind.vm_models.figure import Figure @@ -390,5 +391,166 @@ def test_log_text_rejects_invalid_context(self): ) +class TestAPIClientOIDC(unittest.TestCase): + """OIDC device-flow authentication via vm.init().""" + + def tearDown(self): + with patch("validmind.api_client._ping"): + api_client.init( + api_key=os.environ["VM_API_KEY"], + api_secret=os.environ["VM_API_SECRET"], + api_host=os.environ["VM_API_HOST"], + model=os.environ["VM_API_MODEL"], + document="documentation", + ) + + @patch("validmind.api_client.reload") + def test_init_rejects_api_key_with_oidc(self, mock_reload): + with self.assertRaises(ValidMindAuthError): + api_client.init( + api_key="x", + api_secret="y", + model="m", + api_host="http://h/", + issuer="https://issuer/", + client_id="cid", + ) + mock_reload.assert_not_called() + + @patch("validmind.api_client.reload") + def test_init_requires_client_id_with_issuer(self, mock_reload): + with self.assertRaises(ValidMindAuthError): + api_client.init( + model="m", + api_host="http://h/", + issuer="https://issuer/", + ) + mock_reload.assert_not_called() + + @patch("validmind.api_client._ping") + @patch("validmind.api_client._obtain_oidc_tokens") + def test_init_oidc_uses_bearer_headers(self, mock_obtain, mock_ping): + mock_obtain.return_value = { + "issuer": "https://issuer/", + "client_id": "cid", + "access_token": "tok", + "expires_at": "2099-01-01T00:00:00+00:00", + "refresh_token": None, + "id_token": None, + } + api_client.init( + model="model-cuid", + api_host="http://localhost/track/", + api_key="", + api_secret="", + issuer="https://issuer/", + client_id="cid", + document="documentation", + ) + headers = api_client._get_api_headers() + self.assertEqual(headers["Authorization"], "Bearer tok") + self.assertNotIn("X-API-KEY", headers) + + @patch("validmind.api_client._ping") + @patch("validmind.api_client._obtain_oidc_tokens") + def test_init_entra_oidc_uses_id_token(self, mock_obtain, mock_ping): + mock_obtain.return_value = { + "issuer": "https://login.microsoftonline.com/tenant-id/v2.0", + "client_id": "cid", + "access_token": "access-token", + "expires_at": "2099-01-01T00:00:00+00:00", + "refresh_token": None, + "id_token": "id-token", + } + api_client.init( + model="model-cuid", + api_host="http://localhost/track/", + api_key="", + api_secret="", + issuer="https://login.microsoftonline.com/tenant-id/v2.0", + client_id="cid", + document="documentation", + ) + headers = api_client._get_api_headers() + self.assertEqual(headers["Authorization"], "Bearer id-token") + + @patch("validmind.api_client._ping") + @patch("validmind.api_client._obtain_oidc_tokens") + def test_init_non_entra_oidc_prefers_access_token(self, mock_obtain, mock_ping): + mock_obtain.return_value = { + "issuer": "https://issuer.example.com/", + "client_id": "cid", + "access_token": "access-token", + "expires_at": "2099-01-01T00:00:00+00:00", + "refresh_token": None, + "id_token": "id-token", + } + api_client.init( + model="model-cuid", + api_host="http://localhost/track/", + api_key="", + api_secret="", + issuer="https://issuer.example.com/", + client_id="cid", + document="documentation", + ) + headers = api_client._get_api_headers() + self.assertEqual(headers["Authorization"], "Bearer access-token") + + @patch("validmind.api_client._ping") + @patch("validmind.api_client._obtain_oidc_tokens") + def test_init_oidc_passes_audience(self, mock_obtain, mock_ping): + mock_obtain.return_value = { + "issuer": "https://issuer/", + "client_id": "cid", + "access_token": "tok", + "expires_at": "2099-01-01T00:00:00+00:00", + "refresh_token": None, + "id_token": None, + "audience": "https://api.example.com", + } + api_client.init( + model="model-cuid", + api_host="http://localhost/track/", + api_key="", + api_secret="", + issuer="https://issuer/", + client_id="cid", + audience="https://api.example.com", + document="documentation", + ) + mock_obtain.assert_called_once() + _args, kwargs = mock_obtain.call_args + self.assertEqual( + kwargs.get("audience"), + "https://api.example.com", + ) + ctx = api_client._oidc_login_context + assert ctx is not None + self.assertEqual(ctx["audience"], "https://api.example.com") + + @patch("validmind.api_client._ping") + @patch("validmind.api_client._obtain_oidc_tokens") + def test_api_url_alias_sets_host(self, mock_obtain, mock_ping): + mock_obtain.return_value = { + "issuer": "https://issuer/", + "client_id": "cid", + "access_token": "tok", + "expires_at": "2099-01-01T00:00:00+00:00", + "refresh_token": None, + "id_token": None, + } + api_client.init( + model="model-cuid", + api_url="http://localhost/from-api-url/", + api_key="", + api_secret="", + issuer="https://issuer/", + client_id="cid", + document="documentation", + ) + self.assertEqual(api_client.get_api_host(), "http://localhost/from-api-url/") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_credentials_store.py b/tests/test_credentials_store.py new file mode 100644 index 000000000..811b8a8ce --- /dev/null +++ b/tests/test_credentials_store.py @@ -0,0 +1,110 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from validmind.credentials_store import ( + credential_key, + get_cached_entry, + is_expired, + normalize_audience, + normalize_client_id, + normalize_issuer, + upsert_cached_entry, +) +from validmind.errors import ValidMindAuthError + + +class TestCredentialsStore(unittest.TestCase): + def test_normalize_issuer_trailing_slash(self): + self.assertEqual( + normalize_issuer("https://login.example.com/tenant/v2.0/"), + "https://login.example.com/tenant/v2.0", + ) + + def test_normalize_issuer_strips_wrapping_quotes(self): + self.assertEqual( + normalize_issuer("'https://login.dev.vm.validmind.ai'"), + "https://login.dev.vm.validmind.ai", + ) + self.assertEqual( + normalize_issuer('"https://login.example.com/"'), + "https://login.example.com", + ) + self.assertEqual( + normalize_issuer("\"'https://login.example.com'\""), + "https://login.example.com", + ) + + def test_normalize_client_id_strips_wrapping_quotes(self): + self.assertEqual( + normalize_client_id("'iHpItBjLturosPqnrIFT5S7HbX1IeIUS'"), + "iHpItBjLturosPqnrIFT5S7HbX1IeIUS", + ) + + def test_credential_key_stable(self): + k1 = credential_key("https://idp.example.com/", "client-a") + k2 = credential_key("https://idp.example.com", "client-a") + self.assertEqual(k1, k2) + k3 = credential_key("https://idp.example.com", "'client-a'") + self.assertEqual(k1, k3) + + def test_credential_key_includes_audience(self): + base = credential_key("https://idp.example.com", "client-a") + with_aud = credential_key( + "https://idp.example.com", + "client-a", + audience="https://api.example.com", + ) + self.assertNotEqual(base, with_aud) + self.assertTrue(with_aud.endswith("|https://api.example.com")) + + def test_normalize_audience(self): + self.assertEqual(normalize_audience(None), "") + self.assertEqual( + normalize_audience("'https://api.example.com'"), + "https://api.example.com", + ) + + def test_roundtrip_and_expiry(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "credentials.json" + + expires_far = "2099-01-01T00:00:00+00:00" + upsert_cached_entry( + "https://issuer.example/", + "cid", + { + "access_token": "at", + "refresh_token": "rt", + "expires_at": expires_far, + }, + path=path, + ) + + entry = get_cached_entry("https://issuer.example", "cid", path=path) + self.assertIsNotNone(entry) + assert entry is not None + self.assertEqual(entry["access_token"], "at") + self.assertFalse(is_expired(entry)) + + expired_entry = {**entry, "expires_at": "2020-01-01T00:00:00+00:00"} + self.assertTrue(is_expired(expired_entry)) + + def test_invalid_json_raises(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "credentials.json" + path.write_text("{not json", encoding="utf-8") + with self.assertRaises(ValidMindAuthError): + get_cached_entry("https://x", "y", path=path) + + @patch("validmind.credentials_store.Path.home") + def test_default_path_under_validmind(self, mock_home): + mock_home.return_value = Path("/home/tester") + from validmind.credentials_store import credentials_path + + self.assertEqual( + credentials_path(), Path("/home/tester/.validmind/credentials.json") + ) diff --git a/tests/test_dataset.py b/tests/test_dataset.py index 4e9527220..21e048c6e 100644 --- a/tests/test_dataset.py +++ b/tests/test_dataset.py @@ -119,7 +119,7 @@ def test_dtype_preserved(self): # Verify original data is categorical self.assertTrue( - pd.api.types.is_categorical_dtype(test_df["col1"]), + isinstance(test_df["col1"].dtype, pd.CategoricalDtype), "Original DataFrame should have categorical dtype", ) @@ -127,7 +127,7 @@ def test_dtype_preserved(self): dataset = DataFrameDataset(raw_dataset=test_df, input_id="test_dataset") self.assertTrue( - pd.api.types.is_categorical_dtype(dataset.df["col1"]), + isinstance(dataset.df["col1"].dtype, pd.CategoricalDtype), "DataFrameDataset should preserve categorical dtype", ) diff --git a/tests/test_full_suite.py b/tests/test_full_suite.py index 9c2b97fa6..687b64db3 100644 --- a/tests/test_full_suite.py +++ b/tests/test_full_suite.py @@ -4,11 +4,15 @@ import unittest from unittest.mock import patch -import xgboost as xgb - from validmind.datasets.classification import customer_churn +try: + import xgboost as xgb +except ImportError: + xgb = None + +@unittest.skipUnless(xgb is not None, "xgboost optional extra required") class TestFullTestSuite(unittest.TestCase): @patch.multiple( "validmind.api_client", diff --git a/tests/test_full_suite_nb.py b/tests/test_full_suite_nb.py index e55ee93f1..37743babe 100644 --- a/tests/test_full_suite_nb.py +++ b/tests/test_full_suite_nb.py @@ -5,11 +5,15 @@ import unittest from unittest.mock import patch -import xgboost as xgb - from validmind.datasets.classification import customer_churn +try: + import xgboost as xgb +except ImportError: + xgb = None + +@unittest.skipUnless(xgb is not None, "xgboost optional extra required") class TestFullTestSuiteNB(unittest.TestCase): @patch("validmind.utils.is_notebook") @patch.multiple( diff --git a/tests/test_oidc_device.py b/tests/test_oidc_device.py new file mode 100644 index 000000000..ac198a243 --- /dev/null +++ b/tests/test_oidc_device.py @@ -0,0 +1,130 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. + +import unittest +from unittest.mock import MagicMock, patch + +from validmind.errors import ValidMindAuthError +from validmind.oidc_device import ( + clear_configuration_cache, + fetch_openid_configuration, + poll_device_token, + refresh_access_token, + request_device_authorization, +) +from validmind import oidc_device as oidc_device_module + + +class TestOIDCDevice(unittest.TestCase): + def tearDown(self): + clear_configuration_cache() + + def test_fetch_openid_configuration_requires_endpoints(self): + class Resp: + status_code = 200 + + def json(self): + return {"issuer": "https://example.com"} + + with patch.object( + oidc_device_module.requests, "get", return_value=Resp() + ): + with self.assertRaises(ValidMindAuthError) as ctx: + fetch_openid_configuration("https://example.com") + self.assertIn("device_authorization_endpoint", str(ctx.exception)) + + def test_request_device_authorization_success(self): + class Resp: + status_code = 200 + + def json(self): + return { + "device_code": "dc", + "user_code": "ABCD", + "verification_uri": "https://example.com/device", + "interval": 1, + "expires_in": 60, + } + + with patch.object( + oidc_device_module.requests, "post", return_value=Resp() + ) as mock_post: + out = request_device_authorization( + "https://example.com/device", "client", "openid profile" + ) + self.assertEqual(out["device_code"], "dc") + mock_post.assert_called_once() + args, kwargs = mock_post.call_args + self.assertIn("client_id", kwargs["data"]) + self.assertNotIn("audience", kwargs["data"]) + + def test_request_device_authorization_includes_audience(self): + class Resp: + status_code = 200 + + def json(self): + return { + "device_code": "dc", + "user_code": "ABCD", + "verification_uri": "https://example.com/device", + "interval": 1, + "expires_in": 60, + } + + with patch.object( + oidc_device_module.requests, "post", return_value=Resp() + ) as mock_post: + request_device_authorization( + "https://example.com/device", + "client", + "openid profile", + audience="https://api.example.com", + ) + kwargs = mock_post.call_args.kwargs + self.assertEqual(kwargs["data"]["audience"], "https://api.example.com") + + @patch("time.monotonic") + @patch("time.sleep") + def test_poll_device_token_success_after_pending( + self, mock_sleep, mock_monotonic + ): + pending = MagicMock() + pending.status_code = 401 + pending.json.return_value = {"error": "authorization_pending"} + + success = MagicMock() + success.status_code = 200 + success.json.return_value = { + "access_token": "token", + "expires_in": 3600, + "refresh_token": "rt", + } + + mock_monotonic.side_effect = [0, 10, 20, 100] + with patch.object( + oidc_device_module.requests, + "post", + side_effect=[pending, success], + ): + tok = poll_device_token( + "https://example.com/token", + "client-id", + "device-code", + interval=1, + expires_in=50, + ) + self.assertEqual(tok["access_token"], "token") + self.assertEqual(mock_sleep.call_count, 1) + + def test_refresh_access_token_failure(self): + bad = MagicMock() + bad.status_code = 400 + bad.json.return_value = {"error": "invalid_grant"} + bad.text = "" + + with patch.object( + oidc_device_module.requests, "post", return_value=bad + ): + with self.assertRaises(ValidMindAuthError): + refresh_access_token( + "https://example.com/token", "cid", "bad-refresh" + ) diff --git a/tests/unit_tests/model_validation/sklearn/test_ROCCurve.py b/tests/unit_tests/model_validation/sklearn/test_ROCCurve.py index f370fe5b4..72bf19c96 100644 --- a/tests/unit_tests/model_validation/sklearn/test_ROCCurve.py +++ b/tests/unit_tests/model_validation/sklearn/test_ROCCurve.py @@ -3,11 +3,16 @@ import pandas as pd import validmind as vm import plotly.graph_objects as go -from xgboost import XGBClassifier from sklearn.model_selection import train_test_split from validmind.tests.model_validation.sklearn.ROCCurve import ROCCurve +try: + from xgboost import XGBClassifier +except ImportError: + XGBClassifier = None # type: ignore[misc,assignment] + +@unittest.skipUnless(XGBClassifier is not None, "xgboost optional extra required") class TestROCCurve(unittest.TestCase): def setUp(self): # Create binary classification test dataset diff --git a/validmind/api_client.py b/validmind/api_client.py index fdde7561f..326b2edb3 100644 --- a/validmind/api_client.py +++ b/validmind/api_client.py @@ -21,7 +21,12 @@ from .__version__ import __version__ from .client_config import client_config -from .errors import MissingAPICredentialsError, MissingModelIdError, raise_api_error +from .errors import ( + MissingAPICredentialsError, + MissingModelIdError, + ValidMindAuthError, + raise_api_error, +) from .logging import get_logger, log_api_operation from .utils import NumpyEncoder, is_html, md_to_html, run_async from .vm_models.figure import Figure @@ -34,10 +39,30 @@ _model_cuid = os.getenv("VM_API_MODEL") _document = None _monitoring = False +_auth_mode = "api_key" +_access_token: Optional[str] = None +_oidc_login_context: Optional[Dict[str, str]] = None __api_session: Optional[aiohttp.ClientSession] = None +def _invalidate_async_session() -> None: + """Drop the aiohttp session so the next request picks up new headers.""" + global __api_session + sess = __api_session + __api_session = None + if sess is None or sess.closed: + return + try: + loop = asyncio.get_running_loop() + loop.create_task(sess.close()) + except RuntimeError: + try: + asyncio.run(sess.close()) + except RuntimeError: + pass + + @atexit.register def _close_session(): """Closes the async client session at exit.""" @@ -70,14 +95,21 @@ def get_api_model() -> Optional[str]: def _get_api_headers() -> Dict[str, str]: headers = { - "X-API-KEY": _api_key, - "X-API-SECRET": _api_secret, "X-MODEL-CUID": _model_cuid, "X-MONITORING": str(_monitoring), "X-LIBRARY-VERSION": __version__, } if _document: headers["X-DOCUMENT-TYPE"] = _document + if _auth_mode == "oidc": + if not _access_token: + raise ValidMindAuthError( + "OAuth access token is missing. Run vm.init() again with issuer and client_id." + ) + headers["Authorization"] = f"Bearer {_access_token}" + else: + headers["X-API-KEY"] = _api_key + headers["X-API-SECRET"] = _api_secret return headers @@ -192,14 +224,71 @@ def _ping() -> Dict[str, Any]: ) +def _obtain_oidc_tokens( + issuer: str, + client_id: str, + scope: str, + audience: Optional[str] = None, +) -> Dict[str, Any]: + """Return a credentials entry dict with access_token, expires_at, refresh_token, etc.""" + from .credentials_store import ( + delete_cached_entry, + get_cached_entry, + is_expired, + normalize_client_id, + normalize_issuer, + upsert_cached_entry, + ) + from .oidc_device import run_device_flow, try_refresh_cached_tokens + + norm_issuer = normalize_issuer(issuer) + norm_client_id = normalize_client_id(client_id) + cached = get_cached_entry(norm_issuer, norm_client_id, audience=audience) + if cached and not is_expired(cached): + return cached + if cached and cached.get("refresh_token"): + try: + new_tokens = try_refresh_cached_tokens( + norm_issuer, + norm_client_id, + cached["refresh_token"], + scope, + audience=audience, + ) + upsert_cached_entry( + norm_issuer, norm_client_id, new_tokens, audience=audience + ) + return new_tokens + except ValidMindAuthError: + delete_cached_entry(norm_issuer, norm_client_id, audience=audience) + tokens = run_device_flow(norm_issuer, norm_client_id, scope, audience=audience) + upsert_cached_entry(norm_issuer, norm_client_id, tokens, audience=audience) + return tokens + + +def _is_entra_issuer(issuer: str) -> bool: + return "login.microsoftonline.com" in issuer.lower() + + +def _select_oidc_bearer_token(entry: Dict[str, Any]) -> str: + if _is_entra_issuer(entry.get("issuer", "")) and entry.get("id_token"): + return entry["id_token"] + return entry["access_token"] + + def init( api_key: Optional[str] = None, api_secret: Optional[str] = None, api_host: Optional[str] = None, + api_url: Optional[str] = None, model: Optional[str] = None, monitoring: bool = False, generate_descriptions: Optional[bool] = None, document: Optional[str] = None, + issuer: Optional[str] = None, + client_id: Optional[str] = None, + scope: Optional[str] = None, + audience: Optional[str] = None, ): """ Initializes the API client instances and calls the /ping endpoint to ensure @@ -208,36 +297,70 @@ def init( If the API key and secret are not provided, the client will attempt to retrieve them from the environment variables `VM_API_KEY` and `VM_API_SECRET`. + Alternatively, pass ``issuer`` and ``client_id`` to authenticate via the OIDC + device authorization flow (RFC 8628). Tokens are cached under + ``~/.validmind/credentials.json``. Do not combine API keys with OIDC parameters. + Args: model (str, optional): The model CUID. Defaults to None. api_key (str, optional): The API key. Defaults to None. api_secret (str, optional): The API secret. Defaults to None. - api_host (str, optional): The API host. Defaults to None. + api_host (str, optional): The API host (tracking base URL). Defaults to None. + api_url (str, optional): Alias for ``api_host``. monitoring (bool): The ongoing monitoring flag. Defaults to False. generate_descriptions (bool, optional): Whether to use GenAI to generate test result descriptions. Defaults to True. document (str, optional): The name of the document. Omitting this argument is deprecated. + issuer (str, optional): OIDC issuer URL (e.g. Entra tenant ``.../v2.0``). + client_id (str, optional): OAuth public client id for device flow. + scope (str, optional): OAuth scopes (default ``openid profile email``). + audience (str, optional): Resource / API identifier for the access token + (e.g. Auth0 API Identifier). Use the same value the ValidMind backend + expects as ``api_audience`` so the provider can issue RS256 API tokens. + Can be set via env ``VM_OIDC_AUDIENCE``. + Raises: - ValueError: If the API key and secret are not provided + MissingAPICredentialsError: If neither API keys nor OIDC parameters can be resolved. + MissingModelIdError: If model id is missing. + ValidMindAuthError: If OIDC configuration conflicts or login fails. """ global _api_key, _api_secret, _api_host, _model_cuid, _monitoring, _document + global _auth_mode, _access_token, _oidc_login_context if api_key == "...": # special case to detect when running a notebook placeholder (...) # will override with environment variables for easier local development - api_host = api_key = api_secret = model = None + api_host = ( + api_url + ) = api_key = api_secret = model = issuer = client_id = audience = None _model_cuid = model or os.getenv("VM_API_MODEL") if _model_cuid is None: raise MissingModelIdError() - _api_key = api_key or os.getenv("VM_API_KEY") - _api_secret = api_secret or os.getenv("VM_API_SECRET") - if _api_key is None or _api_secret is None: - raise MissingAPICredentialsError() + resolved_host = ( + api_url + or api_host + or os.getenv("VM_API_HOST", "http://localhost:5000/api/v1/tracking/") + ) - _api_host = api_host or os.getenv( - "VM_API_HOST", "http://localhost:5000/api/v1/tracking/" + env_key = api_key if api_key is not None else os.getenv("VM_API_KEY") + env_secret = api_secret if api_secret is not None else os.getenv("VM_API_SECRET") + has_api_creds = ( + env_key is not None + and env_secret is not None + and env_key != "" + and env_secret != "" ) + has_oidc = bool(issuer and client_id) + if has_oidc and has_api_creds: + raise ValidMindAuthError( + "Provide either API credentials (api_key and api_secret) or OIDC " + "(issuer and client_id), not both." + ) + if issuer and not client_id: + raise ValidMindAuthError("client_id is required when issuer is set.") + if client_id and not issuer: + raise ValidMindAuthError("issuer is required when client_id is set.") _monitoring = monitoring @@ -251,6 +374,41 @@ def init( ) _document = document + + if has_oidc: + _auth_mode = "oidc" + _api_key = None + _api_secret = None + _api_host = resolved_host + scope_val = scope or "openid profile email" + from .credentials_store import normalize_audience + + oidc_audience_val = normalize_audience( + audience if audience is not None else os.getenv("VM_OIDC_AUDIENCE") + ) + oidc_audience_opt = oidc_audience_val or None + entry = _obtain_oidc_tokens( + issuer, client_id, scope_val, audience=oidc_audience_opt + ) + _access_token = _select_oidc_bearer_token(entry) + _oidc_login_context = { + "issuer": entry["issuer"], + "client_id": entry["client_id"], + "scope": scope_val, + "audience": entry.get("audience") or oidc_audience_val, + } + _invalidate_async_session() + else: + if env_key is None or env_secret is None: + raise MissingAPICredentialsError() + _auth_mode = "api_key" + _access_token = None + _oidc_login_context = None + _api_key = env_key + _api_secret = env_secret + _api_host = resolved_host + _invalidate_async_session() + reload() diff --git a/validmind/credentials_store.py b/validmind/credentials_store.py new file mode 100644 index 000000000..2f3f9eb7f --- /dev/null +++ b/validmind/credentials_store.py @@ -0,0 +1,179 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +"""Persist OIDC tokens for library authentication under ``~/.validmind/``.""" + +from __future__ import annotations + +import json +import os +import tempfile +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +from .errors import ValidMindAuthError + +_CREDENTIALS_VERSION = 1 + + +def normalize_issuer(issuer: str) -> str: + """Strip whitespace, trailing slash, and matching outer quotes. + + Surrounding quotes often appear when copying from ``.env`` or notebooks + (e.g. ``'https://idp.example.com'``), which would otherwise break HTTP clients. + """ + base = issuer.strip().rstrip("/") + while len(base) >= 2 and base[0] == base[-1] and base[0] in ('"', "'"): + base = base[1:-1].strip().rstrip("/") + return base + + +def normalize_client_id(client_id: str) -> str: + """Strip whitespace and matching outer quotes from OAuth ``client_id``.""" + base = client_id.strip() + while len(base) >= 2 and base[0] == base[-1] and base[0] in ('"', "'"): + base = base[1:-1].strip() + return base + + +def normalize_audience(audience: Optional[str]) -> str: + """Normalize OAuth resource/API ``audience`` (Identifier). Empty if unset.""" + if not audience: + return "" + base = audience.strip() + while len(base) >= 2 and base[0] == base[-1] and base[0] in ('"', "'"): + base = base[1:-1].strip() + return base + + +def credential_key(issuer: str, client_id: str, audience: Optional[str] = None) -> str: + base = f"{normalize_issuer(issuer)}|{normalize_client_id(client_id)}" + aud = normalize_audience(audience) + if aud: + return f"{base}|{aud}" + return base + + +def credentials_path() -> Path: + return Path.home() / ".validmind" / "credentials.json" + + +def _empty_store() -> Dict[str, Any]: + return {"version": _CREDENTIALS_VERSION, "credentials": {}} + + +def load_credentials_file(path: Optional[Path] = None) -> Dict[str, Any]: + path = path or credentials_path() + if not path.is_file(): + return _empty_store() + try: + with open(path, encoding="utf-8") as f: + data = json.load(f) + except (json.JSONDecodeError, OSError) as e: + raise ValidMindAuthError(f"Could not read credentials file {path}: {e}") from e + if not isinstance(data, dict): + raise ValidMindAuthError(f"Invalid credentials file format at {path}") + data.setdefault("version", _CREDENTIALS_VERSION) + data.setdefault("credentials", {}) + return data + + +def _atomic_write(path: Path, payload: Dict[str, Any]) -> None: + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + fd, tmp = tempfile.mkstemp( + dir=str(path.parent), prefix=".credentials-", suffix=".tmp", text=True + ) + tmp_path = Path(tmp) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(payload, handle, indent=2) + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, path) + except Exception: + try: + tmp_path.unlink() + except OSError: + pass + raise + + +def save_credentials_file(data: Dict[str, Any], path: Optional[Path] = None) -> None: + path = path or credentials_path() + data = dict(data) + data["version"] = _CREDENTIALS_VERSION + if "credentials" not in data or not isinstance(data["credentials"], dict): + data["credentials"] = {} + _atomic_write(path, data) + + +def get_cached_entry( + issuer: str, + client_id: str, + path: Optional[Path] = None, + audience: Optional[str] = None, +) -> Optional[Dict[str, Any]]: + key = credential_key(issuer, client_id, audience) + data = load_credentials_file(path) + entry = data.get("credentials", {}).get(key) + if not entry: + return None + return dict(entry) + + +def upsert_cached_entry( + issuer: str, + client_id: str, + entry: Dict[str, Any], + path: Optional[Path] = None, + audience: Optional[str] = None, +) -> None: + key = credential_key(issuer, client_id, audience) + norm_issuer = normalize_issuer(issuer) + aud = normalize_audience(audience) + data = load_credentials_file(path) + credentials = dict(data.get("credentials", {})) + row = { + "issuer": norm_issuer, + "client_id": client_id, + **entry, + } + if aud: + row["audience"] = aud + credentials[key] = row + data["credentials"] = credentials + save_credentials_file(data, path) + + +def delete_cached_entry( + issuer: str, + client_id: str, + path: Optional[Path] = None, + audience: Optional[str] = None, +) -> None: + key = credential_key(issuer, client_id, audience) + data = load_credentials_file(path) + credentials = dict(data.get("credentials", {})) + credentials.pop(key, None) + data["credentials"] = credentials + save_credentials_file(data, path) + + +def is_expired(entry: Dict[str, Any], skew_seconds: int = 120) -> bool: + raw = entry.get("expires_at") + if not raw: + return True + try: + expires = datetime.fromisoformat(raw.replace("Z", "+00:00")) + except ValueError: + return True + if expires.tzinfo is None: + expires = expires.replace(tzinfo=timezone.utc) + return datetime.now(timezone.utc) >= expires - timedelta(seconds=skew_seconds) + + +def expires_at_from_secs(expires_in: Optional[int]) -> str: + seconds = int(expires_in) if expires_in is not None else 3600 + when = datetime.now(timezone.utc) + timedelta(seconds=seconds) + return when.isoformat() diff --git a/validmind/errors.py b/validmind/errors.py index 4aa905657..bad208842 100644 --- a/validmind/errors.py +++ b/validmind/errors.py @@ -189,6 +189,10 @@ def description(self, *args, **kwargs): ) +class ValidMindAuthError(BaseError): + """OAuth/OIDC or library authentication failures (device flow, refresh, bad issuer).""" + + class MissingClassLabelError(BaseError): """ When the one or more class labels are missing from provided dataset targets. diff --git a/validmind/oidc_device.py b/validmind/oidc_device.py new file mode 100644 index 000000000..2b0c9f1f2 --- /dev/null +++ b/validmind/oidc_device.py @@ -0,0 +1,344 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +"""OIDC device authorization flow (RFC 8628) for notebook-style login.""" + +from __future__ import annotations + +import time +from typing import Any, Dict, Optional, Tuple + +import requests +from requests import Response + +from .credentials_store import ( + expires_at_from_secs, + normalize_audience, + normalize_issuer, +) +from .errors import ValidMindAuthError + +_OPENID_CONFIG_SUFFIX = "/.well-known/openid-configuration" +_DEFAULT_TIMEOUT = 30.0 + +_config_cache: Dict[str, Dict[str, Any]] = {} + + +def _print_device_authorization_prompt(verification_uri: str, user_code: str) -> None: + """Print RFC 8628 verification instructions for interactive login. + + ``verification_uri`` and ``user_code`` are intended for human-readable display; + they are not authentication secrets (see RFC 8628 § 3.3). The ``device_code`` + binding must remain confidential and is never written here. + """ + msg = ( + f"Visit: {verification_uri}\n" + f"Code: {user_code}\n" + "Waiting for authorization..." + ) + print(msg) # lgtm[py/clear-text-logging-sensitive-data] + + +def fetch_openid_configuration( + issuer: str, timeout: float = _DEFAULT_TIMEOUT +) -> Dict[str, Any]: + """GET OpenID Provider configuration document.""" + base = normalize_issuer(issuer) + if base in _config_cache: + return _config_cache[base] + url = f"{base}{_OPENID_CONFIG_SUFFIX}" + try: + r = requests.get(url, timeout=timeout) + except requests.RequestException as e: + raise ValidMindAuthError( + f"Could not reach OIDC discovery URL {url!r}: {e}" + ) from e + if r.status_code != 200: + raise ValidMindAuthError( + f"OIDC discovery failed for {url!r}: HTTP {r.status_code} {r.text[:500]}" + ) + try: + cfg = r.json() + except ValueError as e: + raise ValidMindAuthError( + f"OIDC discovery returned non-JSON from {url!r}" + ) from e + for key in ("device_authorization_endpoint", "token_endpoint"): + if key not in cfg: + raise ValidMindAuthError( + f"OIDC discovery document from {url!r} is missing {key!r}" + ) + _config_cache[base] = cfg + return cfg + + +def request_device_authorization( + device_authorization_endpoint: str, + client_id: str, + scope: str, + timeout: float = _DEFAULT_TIMEOUT, + audience: Optional[str] = None, +) -> Dict[str, Any]: + payload = {"client_id": client_id, "scope": scope} + aud = normalize_audience(audience) + if aud: + payload["audience"] = aud + try: + r = requests.post( + device_authorization_endpoint, + data=payload, + headers={"Accept": "application/json"}, + timeout=timeout, + ) + except requests.RequestException as e: + raise ValidMindAuthError(f"Device authorization request failed: {e}") from e + try: + body = r.json() + except ValueError: + body = {} + if r.status_code != 200: + raise ValidMindAuthError( + "Device authorization endpoint rejected the request: " + f"HTTP {r.status_code} {body or r.text[:500]}" + ) + for key in ("device_code", "user_code", "verification_uri"): + if key not in body: + raise ValidMindAuthError( + f"Device authorization response missing {key!r}: {body}" + ) + return body + + +def _post_device_token_poll( + token_endpoint: str, + client_id: str, + device_code: str, + *, + timeout: float, + audience: Optional[str], +) -> Tuple[Response, Dict[str, Any]]: + """POST once to the token endpoint for device-code grant; returns response and JSON body.""" + token_body: Dict[str, str] = { + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": device_code, + "client_id": client_id, + } + aud = normalize_audience(audience) + if aud: + token_body["audience"] = aud + try: + r = requests.post( + token_endpoint, + data=token_body, + headers={"Accept": "application/json"}, + timeout=timeout, + ) + except requests.RequestException as e: + raise ValidMindAuthError(f"Token poll request failed: {e}") from e + try: + body = r.json() + except ValueError: + body = {} + return r, body + + +def _handle_device_token_poll_response( + r: Response, + body: Dict[str, Any], + current_interval: float, +) -> Tuple[Optional[Dict[str, Any]], float]: + """Map poll response to success payload, retry with updated interval, or raise.""" + if r.status_code == 200 and "access_token" in body: + return body, current_interval + + error = body.get("error") + if error == "authorization_pending": + time.sleep(current_interval) + return None, current_interval + if error == "slow_down": + current_interval += 5 + time.sleep(current_interval) + return None, current_interval + if error == "expired_token": + raise ValidMindAuthError( + "Device login expired before completion. Run vm.init() again to start a new login." + ) + if error == "access_denied": + raise ValidMindAuthError("Device authorization was denied.") + raise ValidMindAuthError( + f"Token poll failed: HTTP {r.status_code} error={error!r} {body or r.text[:500]}" + ) + + +def poll_device_token( + token_endpoint: str, + client_id: str, + device_code: str, + *, + interval: float = 5.0, + expires_in: float = 900.0, + timeout: float = _DEFAULT_TIMEOUT, + audience: Optional[str] = None, +) -> Dict[str, Any]: + """Poll token endpoint until success or terminal OAuth error.""" + deadline = time.monotonic() + float(expires_in) + current_interval = float(interval) + + while time.monotonic() < deadline: + r, body = _post_device_token_poll( + token_endpoint, + client_id, + device_code, + timeout=timeout, + audience=audience, + ) + token_payload, current_interval = _handle_device_token_poll_response( + r, body, current_interval + ) + if token_payload is not None: + return token_payload + + raise ValidMindAuthError( + "Device login timed out waiting for authorization. Run vm.init() again." + ) + + +def refresh_access_token( + token_endpoint: str, + client_id: str, + refresh_token: str, + scope: Optional[str] = None, + timeout: float = _DEFAULT_TIMEOUT, + audience: Optional[str] = None, +) -> Dict[str, Any]: + data: Dict[str, str] = { + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "client_id": client_id, + } + if scope: + data["scope"] = scope + aud = normalize_audience(audience) + if aud: + data["audience"] = aud + try: + r = requests.post( + token_endpoint, + data=data, + headers={"Accept": "application/json"}, + timeout=timeout, + ) + except requests.RequestException as e: + raise ValidMindAuthError(f"Token refresh request failed: {e}") from e + try: + body = r.json() + except ValueError: + body = {} + if r.status_code != 200 or "access_token" not in body: + raise ValidMindAuthError( + f"Token refresh failed: HTTP {r.status_code} {body or r.text[:500]}" + ) + return body + + +def run_device_flow( + issuer: str, + client_id: str, + scope: str, + *, + audience: Optional[str] = None, + status_callback=None, +) -> Dict[str, Any]: + """ + Run full RFC 8628 device authorization flow. + + ``audience`` is the OAuth resource identifier (e.g. Auth0 API Identifier). When + set, providers such as Auth0 typically return an RS256 access token for that API. + + ``status_callback`` receives dict milestones (optional); default UX prints instructions. + """ + cfg = fetch_openid_configuration(issuer) + dev = request_device_authorization( + cfg["device_authorization_endpoint"], + client_id, + scope, + audience=audience, + ) + + verification_uri = dev["verification_uri"] + user_code = dev["user_code"] + if status_callback: + status_callback( + { + "verification_uri": verification_uri, + "user_code": user_code, + "verification_uri_complete": dev.get("verification_uri_complete"), + } + ) + else: + _print_device_authorization_prompt(verification_uri, user_code) + + interval = float(dev.get("interval", 5)) + expires_in = float(dev.get("expires_in", 900)) + token_payload = poll_device_token( + cfg["token_endpoint"], + client_id, + dev["device_code"], + interval=interval, + expires_in=expires_in, + audience=audience, + ) + + access_token = token_payload["access_token"] + refresh_tok = token_payload.get("refresh_token") + id_token = token_payload.get("id_token") + expires_at = expires_at_from_secs(token_payload.get("expires_in")) + + aud_norm = normalize_audience(audience) + out: Dict[str, Any] = { + "issuer": normalize_issuer(issuer), + "client_id": client_id, + "access_token": access_token, + "refresh_token": refresh_tok, + "id_token": id_token, + "expires_at": expires_at, + } + if aud_norm: + out["audience"] = aud_norm + return out + + +def try_refresh_cached_tokens( + issuer: str, + client_id: str, + refresh_token: str, + scope: Optional[str], + audience: Optional[str] = None, +) -> Dict[str, Any]: + cfg = fetch_openid_configuration(issuer) + refreshed = refresh_access_token( + cfg["token_endpoint"], + client_id, + refresh_token, + scope=scope, + audience=audience, + ) + new_refresh = refreshed.get("refresh_token") or refresh_token + aud_norm = normalize_audience(audience) + out: Dict[str, Any] = { + "issuer": normalize_issuer(issuer), + "client_id": client_id, + "access_token": refreshed["access_token"], + "refresh_token": new_refresh, + "id_token": refreshed.get("id_token"), + "expires_at": expires_at_from_secs(refreshed.get("expires_in")), + } + if aud_norm: + out["audience"] = aud_norm + return out + + +def clear_configuration_cache() -> None: + """Test helper: drop cached discovery documents.""" + _config_cache.clear() diff --git a/validmind/utils.py b/validmind/utils.py index 485d3a2be..455afc0d6 100644 --- a/validmind/utils.py +++ b/validmind/utils.py @@ -389,19 +389,22 @@ def run_async( The result of the function. """ try: - if asyncio.get_event_loop().is_running() and is_notebook(): - if __loop: - future = __loop.create_task(func(*args, **kwargs), name=name) - # wait for the future result - return __loop.run_until_complete(future) - - return asyncio.get_event_loop().create_task( - func(*args, **kwargs), name=name - ) + running_loop = asyncio.get_running_loop() except RuntimeError: - pass + running_loop = None + + if running_loop is not None and is_notebook(): + if __loop: + future = __loop.create_task(func(*args, **kwargs), name=name) + # wait for the future result + return __loop.run_until_complete(future) + + return running_loop.create_task(func(*args, **kwargs), name=name) - return asyncio.get_event_loop().run_until_complete(func(*args, **kwargs)) + # Plain interpreter / unittest: no running loop. asyncio.get_event_loop() is + # deprecated and may raise on Python 3.10+ when no loop is set; asyncio.run + # creates and tears down a loop for this call. + return asyncio.run(func(*args, **kwargs)) def run_async_check( @@ -839,9 +842,7 @@ def get_column_type_detail(df, column) -> dict: type_detail = {"type": "Boolean"} elif pd.api.types.is_datetime64_any_dtype(dtype): type_detail = {"type": "Datetime"} - elif pd.api.types.is_categorical_dtype(dtype) or pd.api.types.is_object_dtype( - dtype - ): + elif isinstance(dtype, pd.CategoricalDtype) or pd.api.types.is_object_dtype(dtype): type_detail = _get_text_type_detail(series) # Update result with type details @@ -880,7 +881,7 @@ def infer_datatypes(df, detailed=False) -> list: column_type_mappings[column] = {"id": column, "type": "Boolean"} elif pd.api.types.is_datetime64_any_dtype(dtype): column_type_mappings[column] = {"id": column, "type": "Datetime"} - elif pd.api.types.is_categorical_dtype(dtype) or pd.api.types.is_object_dtype( + elif isinstance(dtype, pd.CategoricalDtype) or pd.api.types.is_object_dtype( dtype ): # Check if this is more likely to be text than categorical diff --git a/validmind/vm_models/dataset/dataset.py b/validmind/vm_models/dataset/dataset.py index f2b3e0439..03bb88213 100644 --- a/validmind/vm_models/dataset/dataset.py +++ b/validmind/vm_models/dataset/dataset.py @@ -142,7 +142,7 @@ def _set_feature_columns(self, feature_columns=None): self.feature_columns_categorical = feature_dtypes[ feature_dtypes.apply( - lambda x: pd.api.types.is_categorical_dtype(x) + lambda x: isinstance(x, pd.CategoricalDtype) or pd.api.types.is_object_dtype(x) ) ].index.tolist()