diff --git a/.github/workflows/cloudinary-cli-test.yml b/.github/workflows/cloudinary-cli-test.yml
index 9d0664f..eeb590f 100644
--- a/.github/workflows/cloudinary-cli-test.yml
+++ b/.github/workflows/cloudinary-cli-test.yml
@@ -8,12 +8,32 @@ on:
jobs:
build:
+ # Run on every branch push, but avoid duplicate runs when a same-repo PR
+ # exists: same-repo changes run via the push event, while fork PRs (which
+ # can't trigger a push in this repo) run via the pull_request event.
+ if: >-
+ (github.event_name == 'push' && !github.event.pull_request.head.repo.fork) ||
+ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork)
- runs-on: ubuntu-latest
+ runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
- python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
+ # Full Python matrix on Linux; macOS and Windows get a single latest-Python smoke job each
+ # (enough to catch platform-specific regressions without 3x the runners and test clouds).
+ os: [ubuntu-latest]
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+ include:
+ - os: macos-latest
+ python-version: "3.14"
+ - os: windows-latest
+ python-version: "3.14"
+
+ # Git Bash ships on the GitHub Windows runners, so a single bash shell keeps every step
+ # identical across Linux, macOS, and Windows (no PowerShell variants to maintain).
+ defaults:
+ run:
+ shell: bash
steps:
- uses: actions/checkout@v4
@@ -35,7 +55,7 @@ jobs:
- name: Get test cloud
run: echo "CLOUDINARY_URL=$(bash tools/get_test_cloud.sh)" >> $GITHUB_ENV
- name: Show test cloud
- run: echo $CLOUDINARY_URL | cut -d'@' -f2
+ run: echo "$CLOUDINARY_URL" | cut -d'@' -f2
- name: Test with pytest
run: |
pytest
diff --git a/.gitignore b/.gitignore
index 796c08f..413b071 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,4 +11,5 @@ venv
.cld-sync
.cld-settings
+.cld-config
.venv
diff --git a/README.md b/README.md
index 3769a80..c759290 100644
--- a/README.md
+++ b/README.md
@@ -12,28 +12,118 @@ It is fully documented at [https://cloudinary.com/documentation/cloudinary_cli](
## Requirements
Your own Cloudinary account. If you don't already have one, sign up at [https://cloudinary.com/users/register/free](https://cloudinary.com/users/register/free).
-Python 3.6 or later. You can install Python from [https://www.python.org/](https://www.python.org/). Note that the Python Package Installer (pip) is installed with it.
+Python 3.8 or later. You can install Python from [https://www.python.org/](https://www.python.org/). Note that the Python Package Installer (pip) is installed with it.
-## Setup and Installation
+## Installation
-1. To install this package, run: `pip3 install cloudinary-cli`
-2. To make all your `cld` commands point to your Cloudinary account, set up your CLOUDINARY\_URL environment variable. For example:
- * On Mac or Linux: `export CLOUDINARY_URL=cloudinary://123456789012345:abcdefghijklmnopqrstuvwxyzA@cloud_name`
- * On Windows (cmd.exe): `set CLOUDINARY_URL=cloudinary://123456789012345:abcdefghijklmnopqrstuvwxyzA@cloud_name`
- * On Windows (PowerShell): `$Env:CLOUDINARY_URL="cloudinary://123456789012345:abcdefghijklmnopqrstuvwxyzA@cloud_name"`
+The CLI is published on PyPI as [`cloudinary-cli`](https://pypi.org/project/cloudinary-cli/). The package name (`cloudinary-cli`) is what you install; the command it provides is **`cld`** (it also installs a `cloudinary` alias). Pick the method that fits your setup. If you just want a working `cld` command and aren't sure, use **pipx** or **uv** — they install the CLI in its own isolated environment, so it won't conflict with other Python packages and you don't need to manage a virtual environment yourself.
+
+### Option 1 — pipx (recommended)
+
+[pipx](https://pipx.pypa.io) installs Python CLI tools into isolated environments and puts the `cld` command on your `PATH` automatically.
+
+```sh
+# Install pipx if you don't have it:
+# macOS: brew install pipx && pipx ensurepath
+# Debian/Ubuntu: sudo apt install pipx && pipx ensurepath
+# Any platform: python3 -m pip install --user pipx && python3 -m pipx ensurepath
+
+pipx install cloudinary-cli
+
+# Upgrade later with:
+pipx upgrade cloudinary-cli
+```
+
+After `pipx ensurepath`, open a new terminal so the updated `PATH` takes effect.
+
+### Option 2 — uv
+
+[uv](https://docs.astral.sh/uv/) is a fast Python package manager. Its `uv tool` command installs CLIs in isolation, like pipx:
+
+```sh
+uv tool install cloudinary-cli
+
+# Upgrade later with:
+uv tool upgrade cloudinary-cli
+```
+
+Or run it once without installing. The package's command is `cld`, so name it with `--from`:
+
+```sh
+uvx --from cloudinary-cli cld --help # uvx is shorthand for `uv tool run`
+```
+
+### Option 3 — pip
+
+A plain `pip` install also works. Prefer a virtual environment so the CLI and its dependencies don't collide with your system or other projects:
+
+```sh
+python3 -m venv ~/.venvs/cloudinary-cli
+source ~/.venvs/cloudinary-cli/bin/activate # Windows: .\.venvs\cloudinary-cli\Scripts\activate
+pip install cloudinary-cli
+```
+
+To install without a virtual environment, use a per-user install (avoids needing `sudo` and keeps it out of system Python):
+
+```sh
+python3 -m pip install --user cloudinary-cli
+```
+
+If `cld` is not found afterwards, the user scripts directory is not on your `PATH`. See [Troubleshooting](#troubleshooting-the-cld-command).
+
+### Option 4 — Docker (no Python needed)
+
+If you'd rather not install Python at all, run the CLI from the official Docker image. See [Docker Usage](#docker-usage) below.
+
+### Verify the installation
+
+```sh
+cld --version
+```
+
+### Troubleshooting the `cld` command
+
+If your shell reports `cld: command not found` after installing:
+
+- **pipx / uv:** run `pipx ensurepath` (or `uv tool update-shell`), then open a new terminal.
+- **pip `--user` install:** the user scripts directory is not on your `PATH`. Find it with `python3 -m site --user-base` (the scripts live in its `bin` subdirectory on macOS/Linux, or `Scripts` on Windows) and add that to your `PATH`. For example, on macOS/Linux add this to `~/.zshrc` or `~/.bash_profile`:
+
+ ```sh
+ export PATH="$PATH:$(python3 -m site --user-base)/bin"
+ ```
+
+- As a fallback, you can always invoke the CLI through Python: `python3 -m cloudinary_cli.cli `.
+
+## Configuration
+
+Once installed, point your `cld` commands at a Cloudinary account using **either** of the following.
+
+**Option A — Log in with OAuth (recommended).** Run:
+
+```sh
+cld login
+```
+
+This opens your browser to authorize the CLI, then saves the login as a configuration (named after the cloud) and sets it as the default. The CLI refreshes the token automatically, and you can remove the login at any time with `cld logout`.
+
+**Option B — Set your `CLOUDINARY_URL` environment variable.** For example:
+
+* On Mac or Linux: `export CLOUDINARY_URL=cloudinary://123456789012345:abcdefghijklmnopqrstuvwxyzA@cloud_name`
+* On Windows (cmd.exe): `set CLOUDINARY_URL=cloudinary://123456789012345:abcdefghijklmnopqrstuvwxyzA@cloud_name`
+* On Windows (PowerShell): `$Env:CLOUDINARY_URL="cloudinary://123456789012345:abcdefghijklmnopqrstuvwxyzA@cloud_name"`
_**Note:** you can copy and paste your account environment variable from the Account Details section of the Dashboard page in the Cloudinary console._
-3. Check your configuration by running `cld config`. A response of the following form is returned:
+Then check your configuration by running `cld config`. A response of the following form is returned:
- ```
- cloud_name:
- api_key:
- api_secret: ***************
- private_cdn:
- ```
+```
+cloud_name:
+api_key:
+api_secret: ***************
+private_cdn:
+```
- If you get an error message when running `cld config`, you may need to add your Python installation to your $PATH. To do so, you can run `PATH="$PATH:/Library/Python/Versions/3.8/bin"` in your terminal, and add `export PATH="$PATH:/Library/Python/Versions/3.8/bin"` to your `/.bash_profile` or `~/.zshrc`.
+If `cld` itself is not found, see [Troubleshooting the `cld` command](#troubleshooting-the-cld-command).
## Quickstart
@@ -47,6 +137,8 @@ Usage: cld [cli options] [command] [command options] [method] [method parameters
```
cld --help # Lists available commands.
+cld login # Logs in to a Cloudinary account via OAuth in your browser.
+cld logout # Revokes and removes a saved OAuth login.
cld search --help # Shows usage for the Search API.
cld admin # Lists Admin API methods.
cld uploader # Lists Upload API methods.
@@ -243,7 +335,7 @@ Whereas using the saved configuration "accountx":
cld -C accountx admin usage
```
-_**Caution:** Creating a saved configuration may put your API secret at risk as it is stored in a local plain text file._
+_**Caution:** Creating a saved configuration may put your credentials at risk as they are stored in a local plain text file. This applies to both API-key configurations and OAuth logins._
You can create, delete and list saved configurations using the `config` command.
@@ -252,3 +344,42 @@ cld config [options]
```
For details, see the [Cloudinary CLI documentation](https://cloudinary.com/documentation/cloudinary_cli#config).
+
+### Logging in with OAuth
+
+Instead of saving an API key and secret, you can log in to a Cloudinary account through your browser. The CLI saves the resulting session as a named configuration and refreshes its token automatically.
+
+```
+cld login # Log in and save the configuration (named after the cloud).
+cld login my-account # Save the login under a specific name.
+cld logout # Choose a saved OAuth login to log out of.
+cld logout my-account # Log out of a specific saved OAuth login.
+```
+
+The first login becomes the default automatically. When other configurations already exist, the new login is saved but not made the default; `cld login` tells you so and prints the command to make it the default. Once saved, an OAuth login is selected with `-C ` just like any other saved configuration.
+
+`cld logout` revokes the login's token at the server and removes the saved configuration. If the token cannot be revoked (for example, you are offline), the saved configuration is still removed.
+
+### Choosing a default configuration
+
+The default configuration is used when no `-c`/`-C` option is given and no `CLOUDINARY_URL` environment variable is set. The first OAuth login becomes the default automatically; you can change it at any time.
+
+```
+cld config -d # Set an existing saved configuration as the default.
+cld config --unset-default # Clear the stored default.
+cld config -ls # List saved configurations, marking the default and the active one.
+```
+
+When creating a configuration with `-n` or `--from_url`, add `--set-default` to make it the default in the same step. Resolution precedence is: `-c` (inline URL) > `-C` (saved name) > stored default > `CLOUDINARY_URL` environment variable.
+
+### Refreshing OAuth tokens
+
+OAuth tokens are refreshed automatically as needed, but you can refresh them manually.
+
+```
+cld config --refresh # Refresh a saved OAuth configuration's token.
+cld config --refresh-all # Refresh every saved OAuth configuration whose token is stale.
+cld config --refresh --force # Refresh even if the token is still fresh.
+```
+
+If a token can no longer be refreshed (for example, the login was revoked), the CLI reports the configuration and the `cld login` command to use to log in again.
diff --git a/cloudinary_cli/auth/__init__.py b/cloudinary_cli/auth/__init__.py
new file mode 100644
index 0000000..c0d6fc6
--- /dev/null
+++ b/cloudinary_cli/auth/__init__.py
@@ -0,0 +1,171 @@
+"""OAuth login façade: runs the PKCE loopback flow and persists each login as a named
+`cloudinary://` entry in `config.json`. Token refresh lives in `auth.refresh`, re-exported here."""
+import secrets
+import webbrowser
+
+import requests
+
+from cloudinary_cli.auth import flow
+from cloudinary_cli.auth.loopback_server import start_callback_server, wait_for_callback
+from cloudinary_cli.auth.session import (
+ Session,
+ to_cloudinary_url,
+ from_cloudinary_url,
+ is_oauth_url,
+)
+from cloudinary_cli.auth.refresh import (
+ refresh_url_if_stale,
+ refresh_config,
+ refresh_configs,
+ relogin_command,
+ list_oauth_login_names,
+)
+from cloudinary_cli.defaults import logger, normalize_region, DEFAULT_REGION, CLOUDINARY_REGION
+from cloudinary_cli.utils.config_utils import (
+ load_config,
+ update_config,
+ remove_config_keys,
+ user_config_names,
+ get_default_config_name,
+ set_default_config,
+ is_reserved_config_name,
+ is_env_configured,
+)
+from cloudinary_cli.utils.utils import log_exception, is_interactive
+
+__all__ = [
+ "login",
+ "logout",
+ "refresh_url_if_stale",
+ "refresh_config",
+ "refresh_configs",
+ "relogin_command",
+ "list_oauth_login_names",
+]
+
+
+def login(region=None, name=None, set_default=False):
+ """
+ Run the interactive browser login and persist the resulting session as a named config entry.
+
+ Returns (config_name, default_status), where default_status is:
+ "made" - this login just became the default (explicit --set-default, or auto-defaulted as
+ the sole login),
+ "already" - the re-logged-into config was already the stored default,
+ "no" - it is not the default.
+ """
+ if name and is_reserved_config_name(name):
+ raise RuntimeError(f"'{name}' is a reserved configuration name.")
+ region = normalize_region(region or CLOUDINARY_REGION)
+ session = _run_browser_flow(region)
+ if not session.cloud_name:
+ raise RuntimeError("Login token did not include a cloud name; cannot save this login.")
+ config_name = name or _derive_config_name(session.cloud_name, region)
+
+ was_default = get_default_config_name() == config_name # before we touch the config
+ update_config({config_name: to_cloudinary_url(session)})
+
+ if was_default:
+ return config_name, "already"
+ if set_default or _should_auto_default(config_name):
+ set_default_config(config_name)
+ return config_name, "made"
+ return config_name, "no"
+
+
+def _should_auto_default(name):
+ """
+ True when the just-saved login should become the default without an explicit flag: it is the
+ only saved config, the environment configures nothing, and no default is already stored.
+
+ A stored default outranks the environment, so auto-defaulting is suppressed when an env config
+ is present: a single `cld login` must not silently override a user's CLOUDINARY_URL. They can
+ still opt in with `--set-default`.
+ """
+ cfg = load_config()
+ return (
+ user_config_names(cfg) == [name]
+ and not is_env_configured()
+ and not get_default_config_name()
+ )
+
+
+def logout(name):
+ """
+ Log out of a saved OAuth login by name: revoke its refresh token at the authorization server,
+ then remove the saved configuration. The local entry is always removed even if revocation fails
+ (offline, server error), so logout never leaves a stale entry behind.
+
+ Returns "removed" (revoked and removed), "revoke_failed" (removed locally but the token could not
+ be revoked), "not_found", or "not_oauth".
+ """
+ saved = load_config()
+ if name not in saved:
+ return "not_found"
+ if not is_oauth_url(saved[name]):
+ return "not_oauth"
+
+ revoked = _revoke_login(name, saved[name])
+ remove_config_keys(name)
+ return "removed" if revoked else "revoke_failed"
+
+
+def _revoke_login(name, url):
+ """Best-effort revocation of a saved login's refresh token. Returns True on success (or when
+ there is nothing to revoke), False if the revoke request failed."""
+ session = from_cloudinary_url(url)
+ if not session.refresh_token:
+ return True
+ try:
+ flow.revoke(session.refresh_token, session.region)
+ return True
+ except requests.RequestException as e:
+ log_exception(e, debug_message=f"Could not revoke the OAuth token for '{name}'")
+ return False
+
+
+def _run_browser_flow(region):
+ verifier, challenge = flow.generate_pkce_pair()
+ state = secrets.token_urlsafe(16)
+ httpd, redirect_uri = start_callback_server()
+
+ authorize_url = flow.build_authorize_url(challenge, state, redirect_uri, region)
+ logger.info("Opening browser to log in to Cloudinary...")
+ opened = webbrowser.open(authorize_url)
+ if not opened and not is_interactive():
+ # No browser and no interactive terminal: nobody can complete the redirect, so fail fast
+ # instead of blocking until the callback times out. Headless runs use a pre-set config.
+ httpd.server_close()
+ raise RuntimeError(
+ "cld login needs an interactive browser session, but no browser could be opened and "
+ "this is not an interactive terminal. For headless/CI use, configure credentials with "
+ "an API-key URL instead: `cld -c cloudinary://:@` or save "
+ "one with `cld config -n ` and select it via `-C `."
+ )
+ if not opened:
+ logger.info(f"Could not open a browser. Visit this URL to log in:\n{authorize_url}")
+ else:
+ logger.info(f"If it doesn't open automatically, visit:\n{authorize_url}")
+
+ auth_code, returned_state = wait_for_callback(httpd)
+ if returned_state != state:
+ raise RuntimeError("State mismatch - possible CSRF, aborting.")
+
+ token_response = flow.exchange_code(auth_code, verifier, redirect_uri, region)
+ return Session.from_token_response(token_response, region=region)
+
+
+def _derive_config_name(cloud_name, region):
+ """
+ Build the saved name: cloud_name + region geo suffix (when not default) + auth-type suffix
+ only when the base name collides with a DIFFERENT auth type (re-login overwrites in place).
+ """
+ base = cloud_name
+ if region != DEFAULT_REGION:
+ base = f"{base}-{region[len('api-'):]}" # api-eu -> "-eu"
+
+ config = load_config()
+ existing = config.get(base)
+ if existing is None or is_oauth_url(existing):
+ return base # free, or same (oauth) type -> overwrite in place
+ return f"{base}-oauth" # taken by an api-key config -> suffix the new oauth entry
diff --git a/cloudinary_cli/auth/callback_page.py b/cloudinary_cli/auth/callback_page.py
new file mode 100644
index 0000000..8cda5aa
--- /dev/null
+++ b/cloudinary_cli/auth/callback_page.py
@@ -0,0 +1,94 @@
+"""HTML for the local OAuth callback page shown in the browser after `cld login`. Kept apart from
+the server logic because of the inline logo/CSS. The page is fully self-contained (inline SVG + CSS,
+no network fetch) so it renders even offline or right after an auth failure."""
+import html
+
+# Official Cloudinary wordmark (cloudinary.com header logo), inlined so the page needs no network.
+# Single brand-blue fill; sized via CSS on the wrapper, currentColor left untouched.
+_LOGO_SVG = (
+ ''
+)
+
+_BRAND_BLUE = "#3448c5"
+
+_PAGE_TEMPLATE = """
+
+
+
+
+Cloudinary CLI · {title}
+
+
+
+
+ {logo}
+
{badge}
+
{heading}
+
{message}
+ {reason}
+
+
+"""
+
+
+def callback_page(auth_error):
+ """Branded HTML for the OAuth callback. auth_error comes from the redirect query string
+ (untrusted), so it is HTML-escaped before rendering; the raw reason also reaches the terminal."""
+ if auth_error:
+ reason = f'
{html.escape(auth_error)}
'
+ return _PAGE_TEMPLATE.format(
+ title="Login failed", brand=_BRAND_BLUE, logo=_LOGO_SVG,
+ badge_class="err", badge="×", heading="Login failed",
+ message="Return to the terminal for details, then try again.", reason=reason,
+ )
+ return _PAGE_TEMPLATE.format(
+ title="Login successful", brand=_BRAND_BLUE, logo=_LOGO_SVG,
+ badge_class="ok", badge="✓", heading="Login successful",
+ message="You can close this tab and return to the terminal.", reason="",
+ )
diff --git a/cloudinary_cli/auth/flow.py b/cloudinary_cli/auth/flow.py
new file mode 100644
index 0000000..f7fcbf5
--- /dev/null
+++ b/cloudinary_cli/auth/flow.py
@@ -0,0 +1,102 @@
+"""OAuth 2.0 Authorization Code + PKCE protocol helpers (RFC 8252): build the authorize URL,
+exchange a code, refresh a token. Pure protocol, no file I/O or global state."""
+import base64
+import hashlib
+import secrets
+import urllib.parse
+
+import requests
+
+from cloudinary_cli.defaults import (
+ oauth_authorize_url_for_region,
+ oauth_token_url_for_region,
+ oauth_revoke_url_for_region,
+ OAUTH_CLIENT_ID,
+ OAUTH_SCOPES,
+ OAUTH_HTTP_TIMEOUT_SECONDS,
+)
+
+
+def generate_pkce_pair():
+ """Return (code_verifier, code_challenge) for the S256 PKCE method."""
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode("ascii")
+ digest = hashlib.sha256(verifier.encode("ascii")).digest()
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
+ return verifier, challenge
+
+
+def build_authorize_url(challenge, state, redirect_uri, region):
+ query = urllib.parse.urlencode({
+ "client_id": OAUTH_CLIENT_ID,
+ "response_type": "code",
+ "scope": OAUTH_SCOPES,
+ "redirect_uri": redirect_uri,
+ "state": state,
+ "code_challenge": challenge,
+ "code_challenge_method": "S256",
+ })
+ return f"{oauth_authorize_url_for_region(region)}?{query}"
+
+
+def exchange_code(auth_code, verifier, redirect_uri, region):
+ """Exchange the authorization code for tokens. Public PKCE client - no client_secret."""
+ resp = requests.post(oauth_token_url_for_region(region), data={
+ "grant_type": "authorization_code",
+ "code": auth_code,
+ "redirect_uri": redirect_uri,
+ "client_id": OAUTH_CLIENT_ID,
+ "code_verifier": verifier,
+ }, timeout=OAUTH_HTTP_TIMEOUT_SECONDS)
+ resp.raise_for_status()
+ return resp.json()
+
+
+def refresh(refresh_token, region):
+ resp = requests.post(oauth_token_url_for_region(region), data={
+ "grant_type": "refresh_token",
+ "refresh_token": refresh_token,
+ "client_id": OAUTH_CLIENT_ID,
+ }, timeout=OAUTH_HTTP_TIMEOUT_SECONDS)
+ resp.raise_for_status()
+ return resp.json()
+
+
+_MAX_OAUTH_DESCRIPTION = 80
+
+
+def oauth_error_body(exc):
+ """The raw response body text from a failed token request, or None if no response is attached.
+ Logged verbatim at debug for investigation - it carries the full server error_description."""
+ resp = getattr(exc, "response", None)
+ return resp.text if resp is not None else None
+
+
+def oauth_error_detail(exc):
+ """The server's OAuth error code from a failed token request (RFC 6749 §5.2), or None when the
+ response carries no parseable OAuth error body. The error_description is appended only when it is
+ short; the endpoint often returns a multi-sentence boilerplate paragraph that is noise in a log."""
+ resp = getattr(exc, "response", None)
+ if resp is None:
+ return None
+ try:
+ body = resp.json()
+ except ValueError:
+ return None
+ error = body.get("error")
+ if not error:
+ return None
+ description = body.get("error_description")
+ if description and len(description) <= _MAX_OAUTH_DESCRIPTION:
+ return f"{error}: {description}"
+ return error
+
+
+def revoke(token, region, token_type_hint="refresh_token"):
+ """Revoke a token at the authorization server (RFC 7009). Revoking the refresh token ends the
+ offline-access grant so it can no longer mint new access tokens."""
+ resp = requests.post(oauth_revoke_url_for_region(region), data={
+ "token": token,
+ "token_type_hint": token_type_hint,
+ "client_id": OAUTH_CLIENT_ID,
+ }, timeout=OAUTH_HTTP_TIMEOUT_SECONDS)
+ resp.raise_for_status()
diff --git a/cloudinary_cli/auth/loopback_server.py b/cloudinary_cli/auth/loopback_server.py
new file mode 100644
index 0000000..79eb58b
--- /dev/null
+++ b/cloudinary_cli/auth/loopback_server.py
@@ -0,0 +1,76 @@
+"""Single-shot loopback HTTP server that captures the OAuth redirect: binds a localhost port,
+serves until the `?code=&state=` (or `?error=`) redirect arrives or it times out."""
+import time
+import urllib.parse
+from http.server import BaseHTTPRequestHandler, HTTPServer
+
+from cloudinary_cli.auth.callback_page import callback_page
+from cloudinary_cli.defaults import (
+ OAUTH_REDIRECT_HOST,
+ OAUTH_REDIRECT_PORT,
+ OAUTH_CALLBACK_PATH,
+ OAUTH_CALLBACK_TIMEOUT_SECONDS,
+)
+
+
+class _CallbackHandler(BaseHTTPRequestHandler):
+ """Captures the ?code=&state= redirect from the authorization server."""
+
+ def do_GET(self): # noqa: N802 (http.server API)
+ parsed = urllib.parse.urlparse(self.path)
+ params = urllib.parse.parse_qs(parsed.query)
+
+ # Ignore stray requests (e.g. /favicon.ico, wrong path) so they don't consume the wait.
+ if parsed.path != OAUTH_CALLBACK_PATH or ("code" not in params and "error" not in params):
+ self.send_response(404)
+ self.end_headers()
+ return
+
+ self.server.auth_code = params.get("code", [None])[0]
+ self.server.auth_state = params.get("state", [None])[0]
+ self.server.auth_error = params.get("error", [None])[0]
+
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.end_headers()
+ self.wfile.write(callback_page(self.server.auth_error).encode("utf-8"))
+
+ def log_message(self, *args):
+ pass # silence the default stderr request logging
+
+
+def start_callback_server():
+ """Bind the loopback server and return (httpd, redirect_uri)."""
+ try:
+ httpd = HTTPServer((OAUTH_REDIRECT_HOST, OAUTH_REDIRECT_PORT), _CallbackHandler)
+ except OSError as e:
+ raise RuntimeError(
+ f"Could not start the local login server on {OAUTH_REDIRECT_HOST}:{OAUTH_REDIRECT_PORT} "
+ f"({e.strerror or e}). Another login may be in progress, or the port is in use. "
+ f"Close it and retry."
+ ) from e
+ httpd.auth_code = httpd.auth_state = httpd.auth_error = None
+ httpd.timeout = OAUTH_CALLBACK_TIMEOUT_SECONDS
+ redirect_uri = f"http://{OAUTH_REDIRECT_HOST}:{OAUTH_REDIRECT_PORT}{OAUTH_CALLBACK_PATH}"
+ return httpd, redirect_uri
+
+
+def wait_for_callback(httpd):
+ """
+ Serve requests until the redirect arrives (ignoring favicon/etc.) or the timeout elapses.
+ Returns (auth_code, auth_state); raises on error or timeout.
+ """
+ deadline = time.monotonic() + OAUTH_CALLBACK_TIMEOUT_SECONDS
+ try:
+ while httpd.auth_code is None and httpd.auth_error is None:
+ if time.monotonic() > deadline:
+ break
+ httpd.handle_request()
+ finally:
+ httpd.server_close()
+
+ if httpd.auth_error:
+ raise RuntimeError(f"Authorization failed: {httpd.auth_error}")
+ if not httpd.auth_code:
+ raise RuntimeError("Timed out waiting for the authorization redirect.")
+ return httpd.auth_code, httpd.auth_state
diff --git a/cloudinary_cli/auth/oauth_config.py b/cloudinary_cli/auth/oauth_config.py
new file mode 100644
index 0000000..5189f8d
--- /dev/null
+++ b/cloudinary_cli/auth/oauth_config.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+import threading
+
+import cloudinary
+from cloudinary.exceptions import AuthorizationRequired
+
+from cloudinary_cli.auth.session import is_oauth_url, from_cloudinary_url
+from cloudinary_cli.auth.refresh import refresh_url_if_stale
+from cloudinary_cli.defaults import logger
+from cloudinary_cli.utils import config_utils
+from cloudinary_cli.utils.utils import token_hint
+
+
+class OAuthConfig(cloudinary.Config):
+ """
+ A Cloudinary config whose `oauth_token` refreshes itself on read. Presence/type checks read
+ `has_oauth` instead, which never touches the network, so offline paths stay offline.
+
+ Rotation single-flights: the first worker to see a token invalid (clock-stale, a peer rotated on
+ disk, or a 401) takes `_refresh_lock` and rotates once; the rest adopt the result. The decision is
+ keyed on the specific token a worker saw invalid, not the clock, so a token a peer already replaced
+ is adopted rather than re-rotated (burning a single-use refresh token). Static configs (env /
+ inline `-c` / api-key, `_session` is None) never refresh.
+ """
+
+ def _init_oauth_state(self, name, session):
+ self._saved_name = name # None for static configs: they never refresh
+ self._session = session
+ self._session_mtime = config_utils.config_mtime() # a later config mtime = a peer rotated on disk
+ self._refresh_lock = threading.Lock()
+
+ @property
+ def oauth_token_refresh_callback(self):
+ # SDK hook (uploader.upload_large): rotate on a chunk 401, retrying the chunk to resume.
+ # A property (not a __dict__ entry) so it stays out of config serialization, which dumps
+ # the public keys of __dict__.
+ return self._refresh_for_sdk
+
+ def _refresh_for_sdk(self, rejected):
+ # invalidate_token returns False when no usable token results (static config, dead refresh);
+ # the SDK contract signals that by raising, so the rejected token is not retried.
+ logger.debug(f"Upload chunk got a 401 on token {token_hint(rejected)}; attempting OAuth refresh")
+ if not self.invalidate_token(rejected):
+ logger.debug(f"OAuth refresh did not yield a new token for {token_hint(rejected)}; chunk upload fails")
+ raise AuthorizationRequired("OAuth token refresh produced no usable token")
+ logger.debug(f"OAuth token refreshed to {token_hint(self.__dict__.get('oauth_token'))}; retrying upload chunk")
+
+ def bind_saved(self, name, url):
+ session = from_cloudinary_url(url) if (name and url and is_oauth_url(url)) else None
+ self._init_oauth_state(name, session)
+
+ @classmethod
+ def from_env(cls):
+ """An OAuthConfig from the environment. Static: never refreshes."""
+ cfg = cls()
+ cfg._init_oauth_state(None, None)
+ return cfg
+
+ @classmethod
+ def from_url(cls, url):
+ """An OAuthConfig from a cloudinary:// URL, not bound to a saved name. Static: never refreshes."""
+ cfg = cls()
+ cfg._load_from_url(url)
+ cfg._init_oauth_state(None, None)
+ return cfg
+
+ @property
+ def has_oauth(self):
+ """True if this config carries an OAuth token. Cheap, never refreshes."""
+ return bool(self.__dict__.get("oauth_token"))
+
+ def _is_invalid(self, session):
+ if not session.refresh_token:
+ return False # unrefreshable: serve it and let it fail
+ return not session.is_fresh() or config_utils.config_mtime() > self._session_mtime
+
+ @property
+ def oauth_token(self):
+ session = getattr(self, "_session", None)
+ if session is None or not self._is_invalid(session):
+ return self.__dict__.get("oauth_token")
+
+ stale_token = self.__dict__.get("oauth_token")
+ with self._refresh_lock:
+ if self.__dict__.get("oauth_token") != stale_token:
+ return self.__dict__.get("oauth_token") # a peer rotated while we waited; adopt it
+ return self._refresh_locked(stale_token)
+
+ def _refresh_locked(self, stale_token):
+ # Caller holds _refresh_lock. Rotates only while disk still holds `stale_token`, else adopts.
+ url = config_utils.load_config().get(self._saved_name)
+ if not url:
+ return self.__dict__.get("oauth_token") # config removed underneath us; serve what we have
+ url = refresh_url_if_stale(self._saved_name, url, expected=stale_token)
+ self._session = from_cloudinary_url(url)
+ self.__dict__["oauth_token"] = self._session.access_token
+ self._session_mtime = config_utils.config_mtime()
+ return self.__dict__["oauth_token"]
+
+ @oauth_token.setter
+ def oauth_token(self, value):
+ self.__dict__["oauth_token"] = value
+
+ def invalidate_token(self, rejected):
+ """
+ Recover after the server rejected `rejected` (AuthorizationRequired): adopt a peer's rotated
+ token, else rotate once, keyed on `rejected` so an old-token rejection adopts rather than
+ re-rotates. Returns True when a usable token is now in place; False for static configs.
+ """
+ if not (getattr(self, "_session", None) and getattr(self, "_saved_name", None)):
+ return False
+ with self._refresh_lock:
+ if self.__dict__.get("oauth_token") != rejected:
+ return True # a peer already rotated; adopt it
+ self._refresh_locked(rejected)
+ return self.__dict__.get("oauth_token") != rejected
+
+
+def install_oauth_config(cloudinary_url, saved_name=None):
+ """
+ Load `cloudinary_url` and install it as the active SDK config. The installed object is always an
+ OAuthConfig (so every active config exposes `has_oauth`); it self-refreshes only when bound to a
+ saved OAuth `saved_name`, and is static for api-key / inline `-c` URLs.
+ """
+ cloudinary.reset_config()
+ cfg = OAuthConfig()
+ cfg._load_from_url(cloudinary_url)
+ cfg.bind_saved(saved_name, cloudinary_url)
+ cloudinary._config = cfg
+ return cfg
+
+
+def install_env_config():
+ """Install the environment config as a (static) OAuthConfig, so the active global is always an
+ OAuthConfig and exposes has_oauth without a refresh."""
+ cfg = OAuthConfig.from_env()
+ cloudinary._config = cfg
+ return cfg
diff --git a/cloudinary_cli/auth/refresh.py b/cloudinary_cli/auth/refresh.py
new file mode 100644
index 0000000..4974f19
--- /dev/null
+++ b/cloudinary_cli/auth/refresh.py
@@ -0,0 +1,123 @@
+"""Non-interactive OAuth token refresh: rotates saved tokens on read/401 under a cross-process lock."""
+import requests
+
+from cloudinary_cli.auth import flow
+from cloudinary_cli.auth.session import from_cloudinary_url, to_cloudinary_url, is_oauth_url
+from cloudinary_cli.defaults import logger, DEFAULT_REGION
+from cloudinary_cli.utils.config_utils import (
+ load_config,
+ update_config,
+ config_lock,
+ user_config_names,
+)
+from cloudinary_cli.utils.utils import token_hint, expiry_hint
+
+# Configs already warned about a failed refresh, so a bulk run warns once per config, not per asset.
+_refresh_warned = set()
+
+
+def _should_refresh(session, expected, force):
+ """Whether `session` should be rotated. `force` rotates any refreshable token; `expected` rotates
+ only while disk still holds that exact token (else a peer rotated -> adopt); otherwise rotate when
+ clock-stale."""
+ if not session.refresh_token:
+ return False
+ if force:
+ return True
+ if expected is not None:
+ return session.access_token == expected
+ return not session.is_fresh()
+
+
+def refresh_url_if_stale(name, url, force=False, expected=None):
+ """
+ Refresh a saved config value if its OAuth token should rotate, rewriting the stored URL; other
+ URLs are returned unchanged. The single-use refresh runs under a cross-process lock, re-checking
+ the freshly re-read disk token so a peer's rotation is adopted instead of burning another refresh.
+ """
+ if not is_oauth_url(url):
+ return url
+
+ if not _should_refresh(from_cloudinary_url(url), expected, force):
+ return url
+
+ with config_lock():
+ url = load_config().get(name, url) # re-read: a peer may have rotated while we waited
+ session = from_cloudinary_url(url)
+ if not _should_refresh(session, expected, force):
+ return url
+
+ try:
+ token_response = flow.refresh(session.refresh_token, session.region)
+ except requests.RequestException as e:
+ body = flow.oauth_error_body(e)
+ logger.debug(f"OAuth token refresh failed for '{name}': {e}"
+ + (f"; response body: {body}" if body else ""), exc_info=True)
+ if name not in _refresh_warned:
+ _refresh_warned.add(name)
+ detail = flow.oauth_error_detail(e)
+ reason = f" ({detail})" if detail else ""
+ logger.warning(f"Could not refresh the OAuth token for '{name}'{reason}; using the "
+ f"existing token, which may be expired. Re-login with "
+ f"`{relogin_command(name)}`.")
+ return url
+
+ _refresh_warned.discard(name)
+
+ # Refresh tokens rotate; keep the old one only if a new one was not returned.
+ token_response.setdefault("refresh_token", session.refresh_token)
+ refreshed = session.updated_from(token_response)
+ refreshed_url = to_cloudinary_url(refreshed)
+ update_config({name: refreshed_url})
+ logger.debug(f"Refreshed OAuth token for '{name}': "
+ f"access {token_hint(session.access_token)} -> {token_hint(refreshed.access_token)}, "
+ f"refresh {token_hint(session.refresh_token)} -> {token_hint(refreshed.refresh_token)}, "
+ f"expires {expiry_hint(session.expires_at)} -> {expiry_hint(refreshed.expires_at)}")
+ return refreshed_url
+
+
+def refresh_config(name, force=False):
+ """
+ Refresh a single saved OAuth config by name and report the outcome. Returns one of:
+ "not_found", "not_oauth", "fresh" (skipped, still valid), "refreshed", or "failed"
+ ("failed" = stale/forced but no refresh token, or the network refresh did not rotate it).
+ """
+ cfg = load_config()
+ if name not in user_config_names(cfg):
+ return "not_found"
+ url = cfg[name]
+ if not is_oauth_url(url):
+ return "not_oauth"
+
+ session = from_cloudinary_url(url)
+ if session.is_fresh() and not force:
+ return "fresh"
+ if not session.refresh_token:
+ return "failed"
+
+ new_url = refresh_url_if_stale(name, url, force=force)
+ return "refreshed" if new_url != url else "failed"
+
+
+def refresh_configs(force=False):
+ """Refresh every saved OAuth config. Returns {name: outcome} (see refresh_config)."""
+ return {name: refresh_config(name, force=force) for name in list_oauth_login_names()}
+
+
+def relogin_command(name):
+ """
+ Build the `cld login` command to re-authenticate a saved OAuth config, preserving its region
+ (a non-default region must be passed explicitly so the right OAuth host is used).
+ """
+ cmd = f"cld login {name}"
+ url = load_config().get(name)
+ region = from_cloudinary_url(url).region if url and is_oauth_url(url) else None
+ if region and region != DEFAULT_REGION:
+ cmd += f" --region {region}"
+ return cmd
+
+
+def list_oauth_login_names():
+ """Return the names of all saved OAuth logins."""
+ cfg = load_config()
+ return [name for name in user_config_names(cfg) if is_oauth_url(cfg[name])]
diff --git a/cloudinary_cli/auth/session.py b/cloudinary_cli/auth/session.py
new file mode 100644
index 0000000..46b6fe3
--- /dev/null
+++ b/cloudinary_cli/auth/session.py
@@ -0,0 +1,119 @@
+"""The OAuth session and its `cloudinary://` URL codec. A login is persisted as a `cloudinary://`
+URL so it flows through the SDK parser and existing config machinery unchanged; the `Session`
+dataclass is the in-memory form, `to_cloudinary_url`/`from_cloudinary_url` the persisted one."""
+import base64
+import json
+import time
+import urllib.parse
+from dataclasses import dataclass
+
+from cloudinary_cli.defaults import (
+ OAUTH_EXPIRY_SKEW_SECONDS,
+ api_host_for_region,
+)
+
+# Query-string keys that carry the OAuth session inside a cloudinary:// URL.
+_OAUTH_MARKER = "oauth_token"
+
+_OAUTH_INTERNAL_KEYS = frozenset({"refresh_token", "issued_at", "expires_at", "region", "issuer"})
+
+
+def strip_oauth_internal_keys(config_dict):
+ return {k: v for k, v in config_dict.items() if k not in _OAUTH_INTERNAL_KEYS}
+
+
+@dataclass
+class Session:
+ cloud_name: str
+ access_token: str
+ refresh_token: str = None
+ issued_at: int = 0
+ expires_at: int = 0
+ region: str = "api"
+ issuer: str = None
+
+ def is_fresh(self, skew=OAUTH_EXPIRY_SKEW_SECONDS):
+ return int(self.expires_at or 0) - skew > int(time.time())
+
+ @classmethod
+ def from_token_response(cls, token_response, cloud_name=None, region="api"):
+ # exp/iat come from the token's JWT claims, not the local clock.
+ access_token = token_response["access_token"]
+ claims = _decode_jwt_payload(access_token)
+ cloud_name = cloud_name or _claim_cloud_name(claims)
+ if not cloud_name:
+ raise ValueError("OAuth access token has no cloud name (ext.cloud_name claim); "
+ "cannot perform requests without it")
+ return cls(
+ cloud_name=cloud_name,
+ access_token=access_token,
+ refresh_token=token_response.get("refresh_token"),
+ issued_at=_required_claim(claims, "iat"),
+ expires_at=_required_claim(claims, "exp"),
+ region=region,
+ issuer=claims.get("iss"),
+ )
+
+ def updated_from(self, token_response):
+ """Return a new Session with refreshed tokens, preserving cloud_name/region."""
+ return Session.from_token_response(
+ token_response, cloud_name=self.cloud_name, region=self.region)
+
+
+def to_cloudinary_url(session):
+ """Encode a Session as a key-less cloudinary:// URL (Bearer auth, region-derived host)."""
+ params = {
+ "oauth_token": session.access_token,
+ "refresh_token": session.refresh_token or "",
+ "issued_at": session.issued_at,
+ "expires_at": session.expires_at,
+ "region": session.region,
+ "issuer": session.issuer or "",
+ "upload_prefix": api_host_for_region(session.region),
+ }
+ return f"cloudinary://{session.cloud_name}?{urllib.parse.urlencode(params)}"
+
+
+def from_cloudinary_url(url):
+ """Parse an OAuth cloudinary:// URL back into a Session."""
+ parsed = urllib.parse.urlparse(url)
+ q = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()}
+ return Session(
+ cloud_name=parsed.hostname,
+ access_token=q.get("oauth_token"),
+ refresh_token=q.get("refresh_token") or None,
+ issued_at=int(q.get("issued_at", 0) or 0),
+ expires_at=int(q.get("expires_at", 0) or 0),
+ region=q.get("region", "api"),
+ issuer=q.get("issuer") or None,
+ )
+
+
+def is_oauth_url(url):
+ if not isinstance(url, str):
+ return False
+ query = urllib.parse.urlparse(url).query
+ return _OAUTH_MARKER in urllib.parse.parse_qs(query)
+
+
+def _decode_jwt_payload(access_token):
+ """Decode the (unverified) JWT payload of an access token; raises ValueError on a non-JWT token."""
+ try:
+ payload_b64 = access_token.split(".")[1]
+ payload_b64 += "=" * (-len(payload_b64) % 4) # pad to a multiple of 4
+ return json.loads(base64.urlsafe_b64decode(payload_b64))
+ except (AttributeError, IndexError, ValueError) as e:
+ raise ValueError(f"OAuth access token is not a decodable JWT: {e}") from e
+
+
+def _required_claim(claims, name):
+ value = claims.get(name)
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ raise ValueError(
+ f"OAuth access token has a missing or non-numeric '{name}' claim: {value!r}") from None
+
+
+def _claim_cloud_name(claims):
+ return (claims.get("ext") or {}).get("cloud_name")
diff --git a/cloudinary_cli/cli_group.py b/cloudinary_cli/cli_group.py
index 9cd4147..a1b5301 100644
--- a/cloudinary_cli/cli_group.py
+++ b/cloudinary_cli/cli_group.py
@@ -7,8 +7,7 @@
import cloudinary
from cloudinary_cli.defaults import logger
-from cloudinary_cli.utils.config_utils import load_config, refresh_cloudinary_config, \
- is_valid_cloudinary_config
+from cloudinary_cli.utils.config_resolver import resolve_cli_config
from cloudinary_cli.version import __version__ as cli_version
CONTEXT_SETTINGS = dict(max_content_width=shutil.get_terminal_size()[0], terminal_width=shutil.get_terminal_size()[0])
@@ -29,19 +28,8 @@
@click_log.simple_verbosity_option(logger)
@click.pass_context
def cli(ctx, config, config_saved):
- if config:
- refresh_cloudinary_config(config)
- elif config_saved:
- config = load_config()
- if config_saved not in config:
- raise Exception(f"Config {config_saved} does not exist")
+ resolve_cli_config(config, config_saved)
- refresh_cloudinary_config(config[config_saved])
-
- if not is_valid_cloudinary_config():
- logger.warning("No Cloudinary configuration found.")
-
- # If no subcommand was invoked, show help and exit with code 0
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
ctx.exit(0)
diff --git a/cloudinary_cli/core/__init__.py b/cloudinary_cli/core/__init__.py
index 7646e1c..8e2edeb 100644
--- a/cloudinary_cli/core/__init__.py
+++ b/cloudinary_cli/core/__init__.py
@@ -1,7 +1,8 @@
import click
from cloudinary_cli.core.admin import admin
-from cloudinary_cli.core.config import config
+from cloudinary_cli.core.auth import login, logout
+from cloudinary_cli.core.config import config_command
from cloudinary_cli.core.search import search, search_folders
from cloudinary_cli.core.uploader import uploader
from cloudinary_cli.core.provisioning import provisioning
@@ -11,7 +12,9 @@
setattr(click.Group, "resolve_command", resolve_command)
commands = [
- config,
+ config_command,
+ login,
+ logout,
search,
search_folders,
admin,
diff --git a/cloudinary_cli/core/auth.py b/cloudinary_cli/core/auth.py
new file mode 100644
index 0000000..6fb4402
--- /dev/null
+++ b/cloudinary_cli/core/auth.py
@@ -0,0 +1,91 @@
+from click import command, argument, option, echo
+
+from cloudinary_cli.auth import login as run_login, logout as run_logout, list_oauth_login_names
+from cloudinary_cli.defaults import logger
+from cloudinary_cli.utils.utils import log_exception, prompt_user
+
+
+@command("login", help="Log in to Cloudinary via OAuth (opens a browser). The session is saved "
+ "as a named configuration you can select with `-C`.")
+@argument("name", required=False)
+@option("--region",
+ help="Cloudinary region to log in to (e.g. eu, ap, or api-eu). Defaults to the "
+ "global region (api).")
+@option("--set-default", "set_default", is_flag=True,
+ help="Set this login as the default configuration used when no -c/-C and no environment "
+ "config is given.")
+def login(name, region, set_default):
+ try:
+ config_name, default_status = run_login(region=region, name=name, set_default=set_default)
+ except Exception as e:
+ log_exception(e, "Login failed")
+ return False
+
+ logger.info(f"Logged in. Saved as '{config_name}'.")
+ if default_status == "made":
+ logger.info(f"This is now the default configuration. Run `cld ` to use it, "
+ f"or `cld -C {config_name} ` to select it explicitly.")
+ elif default_status == "already":
+ logger.info(f"This is the default configuration. Run `cld ` to use it, "
+ f"or `cld -C {config_name} ` to select it explicitly.")
+ else:
+ logger.info(f"Run `cld -C {config_name} ` to use it, "
+ f"or make it the default with `cld config -d {config_name}`.")
+ return True
+
+
+@command("logout", help="Log out: revoke a saved OAuth login's token and remove its configuration. "
+ "Run without a name to choose from the saved logins.")
+@argument("name", required=False)
+def logout(name):
+ if not name:
+ action, name = _select_oauth_login()
+ if action == "invalid":
+ return False
+ if action != "selected":
+ return True
+
+ status = run_logout(name)
+ if status == "removed":
+ logger.info(f"Logged out of '{name}'. Its token was revoked and the saved login removed.")
+ elif status == "revoke_failed":
+ logger.warning(f"Removed '{name}', but could not revoke its token at the server "
+ f"(it may still be valid until it expires).")
+ elif status == "not_oauth":
+ logger.error(f"'{name}' is not an OAuth login; refusing to remove it. "
+ f"Use `config -rm {name}` to delete a saved configuration.")
+ return False
+ else:
+ logger.info(f"No saved OAuth configuration named '{name}'.")
+ return True
+
+
+def _select_oauth_login():
+ """
+ Prompt the user to pick a saved OAuth login by number.
+
+ Returns ("selected", name), ("cancelled", None), ("none", None), or ("invalid", None).
+ """
+ names = list_oauth_login_names()
+ if not names:
+ logger.info("No saved OAuth logins to log out of.")
+ return "none", None
+
+ echo("Saved OAuth logins:")
+ for i, name in enumerate(names, start=1):
+ echo(f" {i}) {name}")
+
+ # The selection needs real input that no flag replaces, so on non-interactive stdin prompt_user
+ # returns None (after logging the hint) and we report it as an invalid (non-zero) outcome.
+ choice = prompt_user(
+ f"Select a login to log out of [1-{len(names)}] (or Enter to cancel): ",
+ noninteractive_hint="Pass the configuration name directly: `cld logout `.")
+ if choice is None:
+ return "invalid", None
+ choice = choice.strip()
+ if not choice:
+ return "cancelled", None
+ if not (choice.isdigit() and 1 <= int(choice) <= len(names)):
+ logger.error(f"Invalid selection '{choice}'. Expected a number between 1 and {len(names)}.")
+ return "invalid", None
+ return "selected", names[int(choice) - 1]
diff --git a/cloudinary_cli/core/config.py b/cloudinary_cli/core/config.py
index c558e17..0c9897d 100644
--- a/cloudinary_cli/core/config.py
+++ b/cloudinary_cli/core/config.py
@@ -1,25 +1,80 @@
import cloudinary
-from click import command, option, echo, BadParameter
+from click import command, option, echo, BadParameter, UsageError
-from cloudinary_cli.defaults import logger
-from cloudinary_cli.utils.config_utils import load_config, verify_cloudinary_url, update_config, remove_config_keys, \
- show_cloudinary_config
+from cloudinary_cli.defaults import logger, DEFAULT_CONFIG_KEY
+from cloudinary_cli.utils.config_utils import (
+ load_config,
+ verify_cloudinary_url,
+ update_config,
+ remove_config_keys,
+ show_cloudinary_config,
+ is_valid_cloudinary_config,
+ user_config_names,
+ get_default_config_name,
+ set_default_config,
+ clear_default_config,
+ is_reserved_config_name,
+ config_type,
+)
+from cloudinary_cli.utils.utils import ConfigurationError
+from cloudinary_cli.utils.json_utils import print_json
+from cloudinary_cli.utils.config_resolver import active_config_name, active_config_is_url
+from cloudinary_cli.auth import refresh_config, refresh_configs, relogin_command
+from cloudinary_cli.utils.config_listing import (
+ list_configs,
+ render_config_table,
+ config_meta,
+ active_config_meta,
+ config_type_label,
+ SYNTHETIC_NAMES,
+)
@command("config", help="Display the current configuration, and manage additional configurations.")
@option("-n", "--new", help="""\b Create and name a configuration from a Cloudinary account environment variable.
e.g. cld config -n """, nargs=2)
@option("-ls", "--ls", help="List all saved configurations.", is_flag=True)
+@option("-j", "--json", "as_json",
+ help="Output as JSON (with -ls, -s, or the bare config view).", is_flag=True)
@option("-s", "--show", help="Show details of a specified configuration.", nargs=1)
@option("-rm", "--rm", help="Delete a specified configuration.", nargs=1)
@option("-url", "--from_url",
help="Create a configuration from a Cloudinary account environment variable. "
"The configuration name is the cloud name.",
nargs=1)
-def config(new, ls, show, rm, from_url):
+@option("-d", "--default", "default", nargs=1,
+ help="Set the named saved configuration as the default.")
+@option("--set-default", "set_default", is_flag=True,
+ help="Set the configuration created by this command (-n / --from_url) as the default.")
+@option("-ud", "--unset-default", "unset_default", is_flag=True,
+ help="Clear the stored default configuration.")
+@option("-r", "--refresh", "refresh", nargs=1,
+ help="Refresh the OAuth token of a saved configuration (use the active config if no name).",
+ is_flag=False, flag_value="")
+@option("-ra", "--refresh-all", "refresh_all", is_flag=True,
+ help="Refresh every saved OAuth configuration whose token is stale.")
+@option("-f", "--force", "force", is_flag=True,
+ help="With --refresh/--refresh-all, refresh even tokens that are still fresh.")
+def config_command(new, ls, as_json, show, rm, from_url, default, set_default, unset_default,
+ refresh, refresh_all, force):
+ if set_default and not (new or from_url):
+ raise UsageError("--set-default requires -n or --from_url; "
+ "to default an existing config use -d .")
+
+ if force and refresh is None and not refresh_all:
+ raise UsageError("--force only applies to --refresh or --refresh-all.")
+
+ if refresh_all:
+ return _refresh_all(force)
+ if refresh is not None:
+ return _refresh_one(refresh, force)
+
if new or from_url:
config_name, cloudinary_url = new or [None, from_url]
+ if config_name and is_reserved_config_name(config_name):
+ raise BadParameter(f"'{config_name}' is a reserved configuration name.")
+
if not verify_cloudinary_url(cloudinary_url):
return False
@@ -29,22 +84,113 @@ def config(new, ls, show, rm, from_url):
logger.info("Config '{}' saved!".format(config_name))
logger.info("Example usage: cld -C {} ".format(config_name))
+
+ if set_default:
+ set_default_config(config_name)
+ logger.info(f"Default set to '{config_name}'. Run `cld ` to use it, "
+ f"or `cld -C {config_name} ` to select it explicitly.")
+ elif default:
+ if default not in user_config_names(load_config()):
+ raise BadParameter(f"Configuration {default} does not exist, "
+ f"use -ls to list available configurations.")
+ set_default_config(default)
+ logger.info(f"Default set to '{default}'. Run `cld ` to use it, "
+ f"or `cld -C {default} ` to select it explicitly.")
+ elif unset_default:
+ clear_default_config()
+ logger.info("Default configuration cleared.")
elif rm:
if remove_config_keys(rm):
logger.warning(f"Configuration '{rm}' not found.")
else:
+ if get_default_config_name() == rm:
+ clear_default_config()
logger.info(f"Configuration '{rm}' deleted.")
elif ls:
- echo("\n".join(load_config().keys()))
+ rows = list_configs()
+ if as_json:
+ print_json(rows)
+ else:
+ echo(render_config_table(rows))
elif show:
curr_config = load_config()
- if show not in curr_config:
+ if show not in user_config_names(curr_config):
raise BadParameter(f"Configuration {show} does not exist, use -ls to list available configurations.")
config_obj = cloudinary.Config()
# noinspection PyProtectedMember
- config_obj._setup_from_parsed_url(config_obj._parse_cloudinary_url(load_config()[show]))
+ config_obj._setup_from_parsed_url(config_obj._parse_cloudinary_url(curr_config[show]))
+
+ if as_json:
+ return print_json(config_meta(show, curr_config, config_obj))
+ _show_config_header(show, curr_config)
return show_cloudinary_config(config_obj)
else:
+ if not is_valid_cloudinary_config():
+ raise ConfigurationError("No Cloudinary configuration found.")
+ if as_json:
+ return print_json(active_config_meta(cloudinary.config()))
+ _show_active_header()
return show_cloudinary_config(cloudinary.config())
+
+
+_REFRESH_MESSAGES = {
+ "not_oauth": ("info", "'{name}' is an api-key config; nothing to refresh."),
+ "fresh": ("info", "'{name}' token is still fresh; nothing to refresh (use --force to refresh anyway)."),
+ "refreshed": ("info", "Refreshed '{name}'."),
+ "failed": ("error", "'{name}' could not be refreshed; re-login with `{relogin}`."),
+}
+
+
+def _report_refresh(name, outcome):
+ """Log the outcome of a single refresh. Returns True on success (or a benign no-op)."""
+ level, template = _REFRESH_MESSAGES[outcome]
+ # The re-login hint must carry the config's region so the right OAuth host is used.
+ relogin = relogin_command(name) if outcome == "failed" else None
+ getattr(logger, level)(template.format(name=name, relogin=relogin))
+ return outcome != "failed"
+
+
+def _refresh_one(name, force):
+ name = name or active_config_name()
+ if not name:
+ raise UsageError("No active saved configuration to refresh; pass a name: "
+ "cld config --refresh .")
+ outcome = refresh_config(name, force=force)
+ if outcome == "not_found":
+ raise BadParameter(f"Configuration {name} does not exist, use -ls to list available configurations.")
+ return _report_refresh(name, outcome)
+
+
+def _refresh_all(force):
+ results = refresh_configs(force=force)
+ if not results:
+ logger.info("No saved OAuth configurations to refresh.")
+ return True
+ ok = True
+ for name, outcome in results.items():
+ ok = _report_refresh(name, outcome) and ok
+ return ok
+
+
+def _show_config_header(name, cfg):
+ flags = []
+ if cfg.get(DEFAULT_CONFIG_KEY) == name:
+ flags.append("default")
+ if active_config_name() == name:
+ flags.append("active")
+ suffix = f" [{', '.join(flags)}]" if flags else ""
+ echo(f"name: {name} ({config_type(cfg[name])}){suffix}\n")
+
+
+def _show_active_header():
+ """Header for bare `cld config`: identify the active config (saved name, -c URL, or env)."""
+ name = active_config_name()
+ if name is not None:
+ _show_config_header(name, load_config())
+ return
+ active = cloudinary.config()
+ type_label = config_type_label(active)
+ label = SYNTHETIC_NAMES["url"] if active_config_is_url() else SYNTHETIC_NAMES["env"]
+ echo(f"name: {label} ({type_label}) [active]\n")
diff --git a/cloudinary_cli/core/search.py b/cloudinary_cli/core/search.py
index 55cb6b3..d6c1c1b 100644
--- a/cloudinary_cli/core/search.py
+++ b/cloudinary_cli/core/search.py
@@ -6,6 +6,7 @@
from cloudinary_cli.utils.json_utils import write_json_to_file, print_json
from cloudinary_cli.utils.utils import write_json_list_to_csv, confirm_action, whitelist_keys, \
normalize_list_params
+from cloudinary_cli.utils.api_utils import call_api
from cloudinary_cli.utils.search_utils import parse_aggregate
DEFAULT_MAX_RESULTS = 500
@@ -134,7 +135,7 @@ def _perform_search(query, with_field, fields, sort_by, aggregate, max_results,
def execute_single_request(expression, fields_to_keep, result_field='resources'):
- res = expression.execute()
+ res = call_api(expression.execute)
if fields_to_keep:
res[result_field] = whitelist_keys(res[result_field], fields_to_keep)
diff --git a/cloudinary_cli/defaults.py b/cloudinary_cli/defaults.py
index eb41905..cba172d 100644
--- a/cloudinary_cli/defaults.py
+++ b/cloudinary_cli/defaults.py
@@ -24,6 +24,67 @@
CLOUDINARY_CLI_CONFIG_FILE = abspath(path_join(CLOUDINARY_HOME, 'config.json'))
+# Reserved key inside config.json that names the default saved configuration. Double-underscore
+# names are rejected as user config names, so this can't collide with a saved config.
+DEFAULT_CONFIG_KEY = "__default__"
+
+# OAuth configuration for `cld login`. The region string derives both the API and
+# OAuth hosts; an unknown region simply fails to resolve.
+DEFAULT_REGION = 'api'
+
+
+def normalize_region(region):
+ # Bare geo codes ('eu') become 'api-'; 'api' and 'api-*' pass through.
+ region = (region or DEFAULT_REGION).strip()
+ return region if region.startswith('api') else f'api-{region}'
+
+
+def _oauth_host_for(region):
+ # Short suffixes (geo codes) use the central authz server; longer ones route to oauth-.
+ _, _, suffix = region.partition('-')
+ return 'oauth.cloudinary.com' if len(suffix) <= 2 else f'oauth-{suffix}.cloudinary.com'
+
+
+def api_host_for_region(region):
+ return f'https://{normalize_region(region)}.cloudinary.com'
+
+
+def oauth_base_url_for_region(region):
+ return f'https://{_oauth_host_for(normalize_region(region))}'
+
+
+def oauth_authorize_url_for_region(region):
+ return f'{oauth_base_url_for_region(region)}/oauth2/auth'
+
+
+def oauth_token_url_for_region(region):
+ return f'{oauth_base_url_for_region(region)}/oauth2/token'
+
+
+def oauth_revoke_url_for_region(region):
+ return f'{oauth_base_url_for_region(region)}/oauth2/revoke'
+
+
+CLOUDINARY_REGION = normalize_region(os.environ.get('CLOUDINARY_REGION'))
+
+# Public PKCE client (no secret). Overridable for testing against a non-prod authorization server
+# registered with a different client; production uses the single registered client below.
+OAUTH_DEFAULT_CLIENT_ID = 'a920ea9c-531b-4613-9783-1d4f4cc10655'
+OAUTH_CLIENT_ID = os.environ.get('CLOUDINARY_OAUTH_CLIENT_ID', OAUTH_DEFAULT_CLIENT_ID)
+OAUTH_DEFAULT_SCOPES = 'openid offline_access asset_management upload'
+OAUTH_SCOPES = os.environ.get('CLOUDINARY_OAUTH_SCOPES', OAUTH_DEFAULT_SCOPES)
+
+# The authorization server requires an exact redirect match, so the port is fixed and must match the registered client.
+OAUTH_DEFAULT_REDIRECT_HOST = '127.0.0.1'
+OAUTH_REDIRECT_HOST = os.environ.get('CLOUDINARY_OAUTH_REDIRECT_HOST', OAUTH_DEFAULT_REDIRECT_HOST)
+OAUTH_DEFAULT_REDIRECT_PORT = 49421
+OAUTH_REDIRECT_PORT = int(os.environ.get('CLOUDINARY_OAUTH_REDIRECT_PORT', OAUTH_DEFAULT_REDIRECT_PORT))
+OAUTH_CALLBACK_PATH = '/callback'
+
+OAUTH_CALLBACK_TIMEOUT_SECONDS = 300
+OAUTH_EXPIRY_SKEW_SECONDS = 280
+OAUTH_HTTP_TIMEOUT_SECONDS = 30
+
TEMPLATE_FOLDER_NAME = 'templates'
CLOUDINARY_CLI_ROOT = dirname(__file__)
TEMPLATE_FOLDER = path_join(CLOUDINARY_CLI_ROOT, TEMPLATE_FOLDER_NAME)
diff --git a/cloudinary_cli/modules/clone.py b/cloudinary_cli/modules/clone.py
index 44bc856..c93b8d1 100644
--- a/cloudinary_cli/modules/clone.py
+++ b/cloudinary_cli/modules/clone.py
@@ -4,7 +4,7 @@
from cloudinary.auth_token import _digest
from cloudinary_cli.utils.utils import run_tasks_concurrently
from cloudinary_cli.utils.api_utils import upload_file
-from cloudinary_cli.utils.config_utils import get_cloudinary_config, config_to_dict
+from cloudinary_cli.utils.config_resolver import get_cloudinary_config, config_to_api_kwargs
from cloudinary_cli.defaults import logger
from cloudinary_cli.core.search import execute_single_request, handle_auto_pagination
import time
@@ -115,7 +115,7 @@ def _prepare_upload_list(source_assets, target_config, overwrite, async_,
notification_url,
auth_token, url_expiry,
normalize_list_params(fields))
- updated_options.update(config_to_dict(target_config))
+ updated_options.update(config_to_api_kwargs(target_config))
upload_list.append((asset_url, {**updated_options}))
return upload_list
diff --git a/cloudinary_cli/modules/migrate.py b/cloudinary_cli/modules/migrate.py
index ad97b1c..ea53e61 100644
--- a/cloudinary_cli/modules/migrate.py
+++ b/cloudinary_cli/modules/migrate.py
@@ -6,6 +6,7 @@
from cloudinary.utils import cloudinary_url
from requests import head
+from cloudinary_cli.utils.api_utils import call_api
from cloudinary_cli.utils.utils import logger, log_exception
@@ -30,7 +31,7 @@ def migrate(upload_mapping, file, delimiter, verbose):
return False
try:
- mapping = api.upload_mapping(upload_mapping)
+ mapping = call_api(api.upload_mapping, upload_mapping)
except Error as e:
log_exception(e, f"Failed retrieving upload mapping: '{upload_mapping}'")
return False
diff --git a/cloudinary_cli/modules/sync.py b/cloudinary_cli/modules/sync.py
index 8e82c82..4f0be0f 100644
--- a/cloudinary_cli/modules/sync.py
+++ b/cloudinary_cli/modules/sync.py
@@ -6,15 +6,16 @@
from os import path, remove
from click import command, argument, option, style, UsageError, Choice
+import cloudinary
from cloudinary import api
from cloudinary_cli.utils.api_utils import query_cld_folder, upload_file, download_file, get_folder_mode, \
- get_default_upload_options, get_destination_folder_options, cld_folder_exists
+ get_default_upload_options, get_destination_folder_options, cld_folder_exists, call_api
from cloudinary_cli.utils.file_utils import (walk_dir, delete_empty_dirs, normalize_file_extension, posix_rel_path,
populate_duplicate_name)
from cloudinary_cli.utils.json_utils import print_json, read_json_from_file, write_json_to_file
from cloudinary_cli.utils.utils import logger, run_tasks_concurrently, get_user_action, invert_dict, chunker, \
- group_params, parse_option_value, duplicate_values
+ group_params, parse_option_value, duplicate_values, should_dump_responses
_DEFAULT_DELETION_BATCH_SIZE = 30
_DEFAULT_CONCURRENT_WORKERS = 30
@@ -83,7 +84,7 @@ def __init__(self, local_dir, remote_dir, include_hidden, concurrent_workers, fo
self.sync_meta_file = path.join(self.local_dir, _SYNC_META_FILE)
- self.verbose = logger.getEffectiveLevel() < logging.INFO
+ self.verbose = should_dump_responses()
self.local_files = {}
self.local_folder_exists = os.path.isdir(path.abspath(self.local_dir))
@@ -177,7 +178,8 @@ def push(self):
logger.info(f"{file}")
return True
- logger.info(f"Uploading {len(files_to_push)} items to Cloudinary folder '{self.user_friendly_remote_dir}'")
+ logger.info(f"Uploading {len(files_to_push)} items to Cloudinary folder '{self.user_friendly_remote_dir}' "
+ f"in cloud '{cloudinary.config().cloud_name}'")
options = {
**get_default_upload_options(self.folder_mode),
@@ -336,7 +338,7 @@ def _save_sync_meta_file(self, upload_results):
current_diverse_files.update(diverse_filenames)
try:
logger.debug(f"Updating '{self.sync_meta_file}' file")
- write_json_to_file(current_diverse_files, self.sync_meta_file)
+ write_json_to_file(current_diverse_files, self.sync_meta_file, atomic=True)
logger.debug(f"Updated '{self.sync_meta_file}' file")
except Exception as e:
# Meta file is not critical for the sync itself, in case we cannot write it, we just log a warning
@@ -369,7 +371,8 @@ def _handle_unique_remote_files(self):
logger.info(f"Dry run mode enabled. Would delete {len(deletion_batch)} resources:\n" +
"\n".join(deletion_batch))
continue
- res = api.delete_resources(deletion_batch, invalidate=True, resource_type=attrs[0], type=attrs[1])
+ res = call_api(api.delete_resources, deletion_batch, invalidate=True,
+ resource_type=attrs[0], type=attrs[1])
num_deleted = Counter(res['deleted'].values())["deleted"]
if self.verbose:
print_json(res)
diff --git a/cloudinary_cli/utils/api_utils.py b/cloudinary_cli/utils/api_utils.py
index 3cd0b89..3dbd6ca 100644
--- a/cloudinary_cli/utils/api_utils.py
+++ b/cloudinary_cli/utils/api_utils.py
@@ -1,9 +1,11 @@
import logging
from os import path, makedirs
+import cloudinary
import requests
from click import style, launch
from cloudinary import Search, SearchFolders, uploader, api
+from cloudinary.exceptions import AuthorizationRequired
from cloudinary.utils import cloudinary_url
from cloudinary_cli.defaults import logger
@@ -12,7 +14,7 @@
populate_duplicate_name)
from cloudinary_cli.utils.json_utils import print_json, write_json_to_file
from cloudinary_cli.utils.utils import log_exception, confirm_action, get_command_params, merge_responses, \
- normalize_list_params, ConfigurationError, print_api_help, duplicate_values
+ normalize_list_params, ConfigurationError, print_api_help, duplicate_values, should_dump_responses
import re
from cloudinary.utils import is_remote_url
@@ -67,7 +69,7 @@ def query_cld_folder(folder, folder_mode, status=None):
next_cursor = True
while next_cursor:
- res = search.execute()
+ res = call_api(search.execute)
for asset in res['resources']:
rel_path = _relative_path(asset, folder)
@@ -104,7 +106,7 @@ def cld_folder_exists(folder):
if not folder:
return True # root folder
- res = SearchFolders().expression(f"path=\"{folder}\"").execute()
+ res = call_api(SearchFolders().expression(f"path=\"{folder}\"").execute)
return res.get("total_count", 0) > 0
@@ -144,7 +146,7 @@ def regen_derived_version(public_id, delivery_type, res_type,
"eager_notification_url": eager_notification_url,
"overwrite": True, "invalidate": True}
try:
- exp_res = uploader.explicit(public_id, **options)
+ exp_res = call_api(uploader.explicit, public_id, **options)
derived_url = f'{exp_res.get("eager")[0].get("secure_url")}'
msg = ('Processing' if options.get('eager_async') else 'Regenerated') + f' {derived_url}'
logger.info(style(msg, fg="green"))
@@ -158,14 +160,17 @@ def regen_derived_version(public_id, delivery_type, res_type,
def upload_file(file_path, options, uploaded=None, failed=None):
uploaded = uploaded if uploaded is not None else {}
failed = failed if failed is not None else {}
- verbose = logger.getEffectiveLevel() < logging.INFO
+ verbose = should_dump_responses()
try:
size = 0 if is_remote_url(file_path) else path.getsize(file_path)
- upload_func = uploader.upload
+ # Fresh options copy: upload_large mutates it (sets public_id), so a retry stays independent.
if size > 20000000:
- upload_func = uploader.upload_large
- result = upload_func(file_path, **options)
+ # upload_large recovers from a token 401 per chunk (SDK oauth_token_refresh_callback),
+ # resuming the upload; a whole-file retry here would restart from byte 0.
+ result = uploader.upload_large(file_path, **dict(options))
+ else:
+ result = call_api(uploader.upload, file_path, **dict(options))
disp_path = _display_path(result)
if "batch_id" in result:
starting_msg = "Uploading"
@@ -278,7 +283,7 @@ def get_folder_mode():
:return: String representing folder mode. Can be "fixed" or "dynamic".
"""
try:
- config_res = api.config(settings="true")
+ config_res = call_api(api.config, settings="true")
mode = config_res["settings"]["folder_mode"]
logger.debug(f"Using {mode} folder mode")
except Exception as e:
@@ -288,11 +293,25 @@ def get_folder_mode():
return mode
-def call_api(func, args, kwargs):
+def call_api(func, *args, **kwargs):
+ """
+ Run an SDK call (function-style API or Search().execute), retrying once on an OAuth 401 after
+ invalidating the rejected token, then log at debug and re-raise on failure.
+ """
+ config = cloudinary.config()
+ token = getattr(config, "oauth_token", None) # the token this request will carry
+ # Pin the token so the value sent is provably the value handed to invalidate_token: without it the
+ # SDK re-reads the self-refreshing oauth_token and a peer rotation between reads breaks the decision.
+ pinned = dict(kwargs, oauth_token=token) if token else kwargs
try:
- return func(*args, **kwargs)
- except Exception as e:
- log_exception(e, debug_message=f"Failed calling '{func.__name__}' with args: {args} and optional args {kwargs}")
+ try:
+ return func(*args, **pinned)
+ except AuthorizationRequired:
+ if not getattr(config, "has_oauth", False) or not config.invalidate_token(token):
+ raise
+ return func(*args, **kwargs) # retry unpinned: the SDK re-reads the freshly rotated token
+ except Exception:
+ logger.debug(f"Failed calling '{func.__name__}' with args: {args} and optional args {kwargs}", exc_info=True)
raise
@@ -309,7 +328,11 @@ def handle_command(
log_exception(e)
return False
- return call_api(func, args, kwargs)
+ try:
+ return call_api(func, *args, **kwargs)
+ except Exception as e:
+ log_exception(e)
+ return False
def handle_api_command(
@@ -349,8 +372,9 @@ def handle_api_command(
raise ConfigurationError("No Cloudinary configuration found.")
try:
- res = call_api(func, args, kwargs)
- except Exception:
+ res = call_api(func, *args, **kwargs)
+ except Exception as e:
+ log_exception(e)
return False
if auto_paginate:
@@ -393,7 +417,7 @@ def handle_auto_pagination(res, func, args, kwargs, force, filter_fields):
pagination_field = None
while res.get(cursor_field, None):
kwargs[cursor_field] = res.get(cursor_field, None)
- res = call_api(func, args, kwargs)
+ res = call_api(func, *args, **kwargs)
all_results, pagination_field = merge_responses(all_results, res, fields_to_keep=fields_to_keep,
pagination_field=pagination_field)
diff --git a/cloudinary_cli/utils/config_listing.py b/cloudinary_cli/utils/config_listing.py
new file mode 100644
index 0000000..1c138e7
--- /dev/null
+++ b/cloudinary_cli/utils/config_listing.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+"""Presentation of the saved-config inventory: the rows behind `config -ls`, the table renderer,
+and the per-config metadata used for `config`/`config -s` (text headers and JSON)."""
+import cloudinary
+
+from cloudinary_cli.auth.oauth_config import OAuthConfig
+from cloudinary_cli.defaults import DEFAULT_CONFIG_KEY
+from cloudinary_cli.utils.config_utils import (
+ load_config,
+ user_config_names,
+ cloud_name_from_url,
+ config_type,
+ cloudinary_config_details,
+ is_env_configured,
+)
+from cloudinary_cli.utils.config_resolver import (
+ active_config_name,
+ active_config_is_env,
+ active_config_is_url,
+)
+
+# Display names for the synthetic (non-saved) configs. Parenthesized so they read as a source
+# label, not a saved config name, in both the table and JSON.
+SYNTHETIC_NAMES = {"env": "(environment)", "url": "(command-line)"}
+
+
+def config_type_label(config_obj):
+ """oauth/api_key for a config OBJECT. Every active config the CLI installs is an OAuthConfig, so
+ presence is read via has_oauth (refresh-free). (config_utils.config_type classifies a URL str.)"""
+ return "oauth" if config_obj.has_oauth else "api_key"
+
+_TABLE_COLUMNS = [("name", "NAME"), ("cloud_name", "CLOUD"), ("type", "TYPE"),
+ ("default", "DEFAULT"), ("active", "ACTIVE")]
+
+
+def list_configs():
+ cfg = load_config()
+ # "default" is the persistent user choice (-d); "active" is the config this very invocation
+ # resolved to (honoring -c/-C/default/env precedence), as recorded by the resolver.
+ default = cfg.get(DEFAULT_CONFIG_KEY)
+ active_name = active_config_name()
+
+ rows = []
+ if active_config_is_url():
+ rows.append(_url_row()) # an inline -c URL: not a saved config, but it is what's active now
+ if is_env_configured():
+ rows.append(_env_row(env_active=active_config_is_env()))
+ rows += [
+ {
+ "name": name,
+ "cloud_name": cloud_name_from_url(cfg[name]),
+ "type": config_type(cfg[name]),
+ "source": "saved",
+ "default": name == default,
+ "active": name == active_name,
+ }
+ for name in user_config_names(cfg)
+ ]
+ return rows
+
+
+def config_meta(name, cfg, config_obj):
+ """JSON view of a named saved config: header metadata plus the masked detail fields."""
+ return {
+ "name": name,
+ "source": "saved",
+ "type": config_type(cfg[name]),
+ "default": cfg.get(DEFAULT_CONFIG_KEY) == name,
+ "active": active_config_name() == name,
+ **cloudinary_config_details(config_obj),
+ }
+
+
+def active_config_meta(config_obj):
+ """JSON view of the active config for bare `cld config` (saved name, -c URL, or env)."""
+ name = active_config_name()
+ if name is not None:
+ return config_meta(name, load_config(), config_obj)
+ source = "url" if active_config_is_url() else "env"
+ return {
+ "name": SYNTHETIC_NAMES[source],
+ "source": source,
+ "type": config_type_label(config_obj),
+ "default": False,
+ "active": True,
+ **cloudinary_config_details(config_obj),
+ }
+
+
+def render_config_table(rows):
+ headers = [title for _, title in _TABLE_COLUMNS]
+ cells = [[_cell(row, key) for key, _ in _TABLE_COLUMNS] for row in rows]
+ widths = [max(len(headers[i]), *(len(r[i]) for r in cells)) if cells else len(headers[i])
+ for i in range(len(headers))]
+ line = lambda values: " ".join(v.ljust(widths[i]) for i, v in enumerate(values)).rstrip()
+ return "\n".join([line(headers)] + [line(r) for r in cells])
+
+
+def _url_row():
+ active = cloudinary.config() # the CLI global, which the resolver loaded from the -c URL
+ return {
+ "name": SYNTHETIC_NAMES["url"],
+ "cloud_name": active.cloud_name or "",
+ "type": config_type_label(active),
+ "source": "url",
+ "default": False, # an inline URL is never the stored default
+ "active": True, # it outranks everything else for this invocation
+ }
+
+
+def _env_row(env_active):
+ env_config = OAuthConfig.from_env() # constructed fresh from the environment, not the CLI global
+ return {
+ "name": SYNTHETIC_NAMES["env"],
+ "cloud_name": env_config.cloud_name or "",
+ "type": config_type_label(env_config),
+ "source": "env",
+ "default": False, # the environment is never the *stored* default
+ "active": env_active, # active only when no stored default outranks it
+ }
+
+
+def _cell(row, key):
+ if key in ("default", "active"):
+ return "*" if row[key] else ""
+ return str(row.get(key) or "")
diff --git a/cloudinary_cli/utils/config_resolver.py b/cloudinary_cli/utils/config_resolver.py
new file mode 100644
index 0000000..24fc308
--- /dev/null
+++ b/cloudinary_cli/utils/config_resolver.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+import cloudinary
+from click import UsageError
+
+from cloudinary_cli.auth import refresh_url_if_stale
+from cloudinary_cli.auth.session import strip_oauth_internal_keys
+from cloudinary_cli.defaults import logger, DEFAULT_CONFIG_KEY
+from cloudinary_cli.utils.config_utils import (
+ load_config,
+ config_to_dict,
+ ping_cloudinary,
+ refresh_cloudinary_config,
+ is_valid_cloudinary_config,
+ is_env_configured,
+ user_config_names,
+)
+
+_UNCONFIGURED_MESSAGE = (
+ "No Cloudinary configuration found.\n"
+ " - Log in with OAuth: cld login\n"
+ " - Add an API-key config: cld config -n "
+ "cloudinary://:@ --set-default\n"
+ " - Set an existing config\n"
+ " as the default: cld config -d "
+)
+
+# What the last resolve_cli_config selected, by precedence. One of:
+# "url" -> an inline -c CLOUDINARY_URL
+# "env" -> the environment fallback
+# None -> nothing configured
+# plus _active_name, the saved-config name when a -C/default saved entry was selected (else None),
+# read by `config -ls` to mark the row active for this invocation. Token freshness is no longer
+# handled here: a saved OAuth config installs a self-refreshing OAuthConfig that refreshes lazily
+# when the SDK reads its oauth_token at request time.
+_active_name = None
+_active_source = None
+
+
+def resolve_cli_config(config=None, config_saved=None):
+ """Select a config by precedence and load it into the SDK global. No network I/O."""
+ global _active_name, _active_source
+ _active_name = None
+ _active_source = None
+
+ if config and config_saved:
+ raise UsageError("-c/--config and -C/--config_saved are mutually exclusive; pass only one.")
+
+ if config:
+ _active_source = "url"
+ refresh_cloudinary_config(config)
+ return _format_ok()
+
+ cfg = load_config()
+
+ if config_saved:
+ if config_saved not in user_config_names(cfg):
+ raise Exception(f"Config {config_saved} does not exist")
+ _active_name = config_saved
+ _active_source = "saved"
+ refresh_cloudinary_config(cfg[config_saved], saved_name=config_saved)
+ return _format_ok()
+
+ default = cfg.get(DEFAULT_CONFIG_KEY)
+ if default and default in cfg:
+ _active_name = default
+ _active_source = "saved"
+ refresh_cloudinary_config(cfg[default], saved_name=default)
+ return _format_ok()
+
+ # No stored default: fall back to the environment. Install it as an OAuthConfig (static, no
+ # saved name -> never refreshes) so the active global is always an OAuthConfig and exposes
+ # has_oauth uniformly; if nothing is configured, _format_ok warns.
+ if is_env_configured():
+ _active_source = "env"
+ from cloudinary_cli.auth.oauth_config import install_env_config
+ install_env_config()
+ return _format_ok()
+
+
+def active_config_name():
+ """The saved-config name selected by the last resolution, or None for -c/env/unconfigured."""
+ return _active_name
+
+
+def active_config_is_env():
+ """True when the last resolution fell through to the environment fallback."""
+ return _active_source == "env"
+
+
+def active_config_is_url():
+ """True when the last resolution loaded an inline -c CLOUDINARY_URL."""
+ return _active_source == "url"
+
+
+def _format_ok():
+ """Format-only check: is a usable-SHAPED config loaded? Does NOT contact the network."""
+ if not is_valid_cloudinary_config():
+ logger.warning(_UNCONFIGURED_MESSAGE)
+ return False
+ return True
+
+
+def get_cloudinary_config(target):
+ target_config = cloudinary.Config()
+ if target.startswith("cloudinary://"):
+ parsed_url = target_config._parse_cloudinary_url(target)
+ elif target in load_config():
+ url = refresh_url_if_stale(target, load_config().get(target))
+ parsed_url = target_config._parse_cloudinary_url(url)
+ else:
+ return False
+
+ target_config._setup_from_parsed_url(parsed_url)
+
+ if not ping_cloudinary(**config_to_api_kwargs(target_config)):
+ logger.error(f"Invalid Cloudinary config: {target}")
+ return False
+
+ return target_config
+
+
+def config_to_api_kwargs(config):
+ return strip_oauth_internal_keys(config_to_dict(config))
diff --git a/cloudinary_cli/utils/config_utils.py b/cloudinary_cli/utils/config_utils.py
index 7b5732a..e5a5926 100644
--- a/cloudinary_cli/utils/config_utils.py
+++ b/cloudinary_cli/utils/config_utils.py
@@ -1,45 +1,130 @@
#!/usr/bin/env python3
import os
+import re
+import time
+from datetime import datetime, timezone
import cloudinary
from click import echo
from cloudinary import api
-
-from cloudinary_cli.defaults import CLOUDINARY_CLI_CONFIG_FILE, OLD_CLOUDINARY_CLI_CONFIG_FILE, logger
+from filelock import FileLock
+
+from cloudinary_cli.defaults import (
+ CLOUDINARY_CLI_CONFIG_FILE,
+ OLD_CLOUDINARY_CLI_CONFIG_FILE,
+ DEFAULT_CONFIG_KEY,
+ logger,
+)
from cloudinary_cli.utils.json_utils import write_json_to_file, read_json_from_file
from cloudinary_cli.utils.utils import log_exception
+# Cross-process lock guarding read-modify-write of the config file. Reentrant within a process,
+# so callers may hold it across a multi-step update (e.g. token refresh) without deadlocking.
+_config_lock = FileLock(CLOUDINARY_CLI_CONFIG_FILE + ".lock")
+
+
+def config_lock():
+ # The lock file lives in the config dir, which may not exist yet on a fresh install.
+ _verify_file_path(CLOUDINARY_CLI_CONFIG_FILE)
+ return _config_lock
+
+
+# Parsed-config cache keyed on the file's (mtime_ns, size). The config file is read on nearly every
+# code path; caching skips the re-read + JSON parse when it has not changed on disk (including
+# changes written by a peer process, which os.replace stamps with a new mtime).
+_config_cache = None
+_config_cache_stat = None
+
+
+def _config_stat():
+ try:
+ st = os.stat(CLOUDINARY_CLI_CONFIG_FILE)
+ return st.st_mtime_ns, st.st_size
+ except FileNotFoundError:
+ return None
+
+
+def config_mtime():
+ """The config file's last-modified time in ns (0 if absent). A cheap cross-process signal for
+ whether a peer rotated the token."""
+ stat = _config_stat()
+ return stat[0] if stat else 0
+
+
+def _invalidate_config_cache():
+ global _config_cache, _config_cache_stat
+ _config_cache = None
+ _config_cache_stat = None
+
def load_config():
- return read_json_from_file(CLOUDINARY_CLI_CONFIG_FILE, does_not_exist_ok=True)
+ global _config_cache, _config_cache_stat
+ stat = _config_stat()
+ if stat is not None and stat == _config_cache_stat and _config_cache is not None:
+ return dict(_config_cache) # copy: callers mutate the result in place (e.g. cfg.update(...))
+ cfg = read_json_from_file(CLOUDINARY_CLI_CONFIG_FILE, does_not_exist_ok=True)
+ _config_cache, _config_cache_stat = cfg, stat
+ return dict(cfg)
def save_config(config):
+ # 0600 from the start: the config file holds secrets (api_secret, account_url, OAuth tokens),
+ # and writing the temp file 0600 before the atomic replace means it is never momentarily
+ # world-readable (unlike a chmod applied after the replace).
_verify_file_path(CLOUDINARY_CLI_CONFIG_FILE)
- write_json_to_file(config, CLOUDINARY_CLI_CONFIG_FILE)
+ write_json_to_file(config, CLOUDINARY_CLI_CONFIG_FILE, atomic=True, mode=0o600)
+ _invalidate_config_cache() # next load_config re-stats and reloads our own write
def update_config(new_config):
- curr_config = load_config()
- curr_config.update(new_config)
- save_config(curr_config)
+ with config_lock():
+ curr_config = load_config()
+ curr_config.update(new_config)
+ save_config(curr_config)
def remove_config_keys(*keys):
- curr_config = load_config()
- not_found = []
- for key in keys:
- if not curr_config.pop(key, None):
- not_found.append(key)
+ with config_lock():
+ curr_config = load_config()
+ not_found = []
+ for key in keys:
+ if not curr_config.pop(key, None):
+ not_found.append(key)
- save_config(curr_config)
+ save_config(curr_config)
return not_found
-def refresh_cloudinary_config(cloudinary_url):
- os.environ.update({'CLOUDINARY_URL': cloudinary_url})
- cloudinary.reset_config()
+def get_default_config_name():
+ """Return the stored default config name, or None if none is set."""
+ return load_config().get(DEFAULT_CONFIG_KEY)
+
+
+def set_default_config(name):
+ update_config({DEFAULT_CONFIG_KEY: name})
+
+
+def clear_default_config():
+ remove_config_keys(DEFAULT_CONFIG_KEY)
+
+
+def user_config_names(cfg=None):
+ """Saved config names with the reserved default key filtered out."""
+ cfg = cfg if cfg is not None else load_config()
+ return [k for k in cfg if k != DEFAULT_CONFIG_KEY]
+
+
+def is_reserved_config_name(name):
+ """Names wrapped in double underscores are reserved for internal keys (e.g. the default)."""
+ return name.startswith("__") and name.endswith("__")
+
+
+def refresh_cloudinary_config(cloudinary_url, saved_name=None):
+ """Install cloudinary_url as the active config. OAuth URLs install a self-refreshing config
+ bound to saved_name (so token rotations persist); other URLs use the plain SDK config."""
+ from cloudinary_cli.auth.oauth_config import install_oauth_config
+ install_oauth_config(cloudinary_url, saved_name=saved_name)
def verify_cloudinary_url(cloudinary_url):
@@ -47,42 +132,186 @@ def verify_cloudinary_url(cloudinary_url):
return ping_cloudinary()
-def get_cloudinary_config(target):
- target_config = cloudinary.Config()
- if target.startswith("cloudinary://"):
- parsed_url = target_config._parse_cloudinary_url(target)
- elif target in load_config():
- parsed_url = target_config._parse_cloudinary_url(load_config().get(target))
- else:
- return False
+def config_to_dict(config):
+ return {k: v for k, v in config.__dict__.items() if not k.startswith("_")}
- target_config._setup_from_parsed_url(parsed_url)
- if not ping_cloudinary(**config_to_dict(target_config)):
- logger.error(f"Invalid Cloudinary config: {target}")
- return False
+def cloud_name_from_url(url):
+ """Parse a saved cloudinary:// URL and return its cloud name, or "" if it cannot be parsed."""
+ config_obj = cloudinary.Config()
+ try:
+ # noinspection PyProtectedMember
+ config_obj._setup_from_parsed_url(config_obj._parse_cloudinary_url(url))
+ except Exception:
+ return ""
+ return config_obj.cloud_name or ""
- return target_config
-def config_to_dict(config):
- return {k: v for k, v in config.__dict__.items() if not k.startswith("_")}
+def config_type(url):
+ """Classify a saved config URL as "oauth" or "api_key"."""
+ from cloudinary_cli.auth.session import is_oauth_url
+ return "oauth" if is_oauth_url(url) else "api_key"
+
+
+_SECRET_KEYS = {"api_secret", "oauth_token", "refresh_token"}
+_URL_SECRET_KEYS = {"account_url"}
+# Fixed mask width so a long secret (e.g. an OAuth JWT) does not print a wall of asterisks and the
+# real length is not leaked. The last 4 chars are kept as a fingerprint to identify the value.
+_MASK_PREFIX = "****"
+
+
+def _mask_secret(value):
+ value = str(value)
+ return _MASK_PREFIX + value[-4:] if len(value) > 4 else "*" * len(value)
+
+
+def _mask_url_secret(url):
+ # Mask the password between `:` and `@` in scheme://user:secret@host.
+ return re.sub(r'(://[^:/?#]+:)([^@]+)(@)',
+ lambda m: m.group(1) + _mask_secret(m.group(2)) + m.group(3), str(url))
+
+
+# account://:@
+_ACCOUNT_URL_RE = re.compile(r'^account://([^:/?#]+):([^@]+)@(.+)$')
+
+
+def _account_url_fields(url):
+ """The provisioning account URL as labeled, secret-masked fields (None if unparsable)."""
+ match = _ACCOUNT_URL_RE.match(str(url))
+ if not match:
+ return None
+ api_key, api_secret, account_id = match.groups()
+ return {
+ "account_id": account_id,
+ "provisioning_api_key": api_key,
+ "provisioning_api_secret": _mask_secret(api_secret),
+ }
+
+
+def _expires_at_fields(value):
+ """An OAuth expiry epoch expanded into {epoch, utc, expired}, or None if not an int."""
+ try:
+ epoch = int(value)
+ except (TypeError, ValueError):
+ return None
+ return {
+ "epoch": epoch,
+ "utc": datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
+ "expired": epoch <= int(time.time()),
+ }
+
+
+def _format_account_url(url):
+ fields = _account_url_fields(url)
+ if fields is None:
+ return None
+ width = len(max(fields, key=len)) + 1
+ template = "{0:" + str(width) + "} {1}"
+ return "\n".join(template.format(f"{k}:", v) for k, v in fields.items())
+
+
+def _issued_at_fields(value):
+ """An OAuth issued-at epoch expanded into {epoch, utc}, or None if not an int."""
+ try:
+ epoch = int(value)
+ except (TypeError, ValueError):
+ return None
+ return {
+ "epoch": epoch,
+ "utc": datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC"),
+ }
+
+
+def _format_epoch(value):
+ parts = _issued_at_fields(value)
+ if parts is None:
+ return value
+ return f"{parts['epoch']} ({parts['utc']})"
+
+
+def _format_expires_at(value):
+ parts = _expires_at_fields(value)
+ if parts is None:
+ return value
+ state = "expired" if parts["expired"] else "valid"
+ return f"{parts['epoch']} ({parts['utc']}, {state})"
+
def show_cloudinary_config(cloudinary_config):
obfuscated_config = config_to_dict(cloudinary_config)
- if "api_secret" in obfuscated_config:
- api_secret = obfuscated_config["api_secret"]
- obfuscated_config["api_secret"] = "*" * (len(api_secret) - 4) + f"{api_secret[-4:]}"
-
# omit default signature algorithm
if obfuscated_config.get("signature_algorithm", None) == cloudinary.utils.SIGNATURE_SHA1:
obfuscated_config.pop("signature_algorithm")
- if not obfuscated_config:
+ # The account URL is long and structurally distinct, so it is shown in its own section below.
+ account_url = obfuscated_config.pop("account_url", None)
+
+ obfuscated_config = {
+ key: _display_value(key, value)
+ for key, value in obfuscated_config.items()
+ if value not in (None, "") # drop empty/None fields (e.g. api_key on an OAuth config)
+ }
+
+ if not obfuscated_config and not account_url:
return False
- template = "{0:" + str(len(max(obfuscated_config, key=len)) + 1) + "} {1}" # Gets the maximal length of the keys.
- echo('\n'.join([template.format(f"{k}:", v) for k, v in obfuscated_config.items()]))
+ if obfuscated_config:
+ width = len(max(obfuscated_config, key=len)) + 1
+ template = "{0:" + str(width) + "} {1}"
+ echo('\n'.join([template.format(f"{k}:", v) for k, v in obfuscated_config.items()]))
+
+ if account_url:
+ structured = _format_account_url(account_url)
+ if structured is not None:
+ echo(f"\nAccount (provisioning) API:\n{structured}")
+ else:
+ echo(f"\naccount_url: {_mask_url_secret(account_url)}")
+
+
+def cloudinary_config_details(cloudinary_config):
+ """
+ JSON-friendly, secret-masked view of a Cloudinary config: the same fields shown by
+ show_cloudinary_config, with secrets masked, empties dropped, expires_at expanded into a
+ structured object, and account_url decomposed into a nested `account` object.
+ """
+ raw = config_to_dict(cloudinary_config)
+
+ if raw.get("signature_algorithm", None) == cloudinary.utils.SIGNATURE_SHA1:
+ raw.pop("signature_algorithm")
+
+ account_url = raw.pop("account_url", None)
+
+ details = {}
+ for key, value in raw.items():
+ if value in (None, ""):
+ continue
+ if key in _SECRET_KEYS:
+ details[key] = _mask_secret(value)
+ elif key == "expires_at":
+ details[key] = _expires_at_fields(value) or value
+ elif key == "issued_at":
+ details[key] = _issued_at_fields(value) or value
+ else:
+ details[key] = value
+
+ account = _account_url_fields(account_url) if account_url else None
+ if account is not None:
+ details["account"] = account
+ elif account_url:
+ details["account_url"] = _mask_url_secret(account_url)
+
+ return details
+
+
+def _display_value(key, value):
+ if key in _SECRET_KEYS:
+ return _mask_secret(value)
+ if key == "expires_at":
+ return _format_expires_at(value)
+ if key == "issued_at":
+ return _format_epoch(value)
+ return value
def migrate_old_config():
@@ -105,7 +334,17 @@ def migrate_old_config():
def is_valid_cloudinary_config():
- return None not in [cloudinary.config().cloud_name, cloudinary.config().api_key, cloudinary.config().api_secret]
+ config = cloudinary.config()
+ # has_oauth reports token presence without triggering OAuthConfig's refresh-on-read. Fall back
+ # to a refresh-free __dict__ read for a plain SDK Config (e.g. before any config is installed).
+ has_oauth = config.has_oauth if hasattr(config, "has_oauth") else bool(config.__dict__.get("oauth_token"))
+ if config.cloud_name and has_oauth:
+ return True
+ return None not in [config.cloud_name, config.api_key, config.api_secret]
+
+
+def is_env_configured():
+ return bool(cloudinary.Config().cloud_name)
def initialize():
diff --git a/cloudinary_cli/utils/file_utils.py b/cloudinary_cli/utils/file_utils.py
index 561d686..a13a62e 100644
--- a/cloudinary_cli/utils/file_utils.py
+++ b/cloudinary_cli/utils/file_utils.py
@@ -1,5 +1,6 @@
import os
import stat
+import tempfile
from os import walk, path, listdir, rmdir, sep
from os.path import split, relpath, abspath
from pathlib import PurePath
@@ -39,6 +40,49 @@
}
+def atomic_write(filename, write_fn, mode=None):
+ """
+ Writes via a temp file in the same directory, then atomically replaces the target, so a
+ concurrent reader never sees a half-written file and an interleaved write can't truncate it.
+
+ :param filename: The destination file path.
+ :param write_fn: Callable receiving the open temp file object; performs the actual write.
+ :param mode: Final permission bits to set on the file. When given, the temp file is set to
+ this mode before the replace, so the destination is never momentarily wider
+ (mkstemp creates it 0600, so a secret file is never world-readable mid-write).
+ When omitted, normalize to the process umask default like a plain open().
+ """
+ directory = path.dirname(filename) or "."
+ fd, tmp_path = tempfile.mkstemp(dir=directory, prefix=".tmp-")
+ try:
+ with os.fdopen(fd, 'w') as file:
+ write_fn(file)
+ if mode is not None:
+ os.chmod(tmp_path, mode)
+ else:
+ _apply_umask_permissions(tmp_path)
+ os.replace(tmp_path, filename)
+ except BaseException:
+ try:
+ os.remove(tmp_path)
+ except OSError:
+ pass
+ raise
+
+
+def _apply_umask_permissions(file):
+ # mkstemp creates the temp file as 0600, and os.replace preserves that mode onto the
+ # destination. Normalize to the process umask default so output files keep the same
+ # permissions a plain open() would have produced; callers needing 0600 (e.g. the config
+ # file) tighten it explicitly afterwards.
+ current_umask = os.umask(0)
+ os.umask(current_umask)
+ try:
+ os.chmod(file, 0o666 & ~current_umask)
+ except OSError as e:
+ logger.debug(f"Could not normalize permissions on {file}: {e}")
+
+
def walk_dir(root_dir, include_hidden=False):
all_files = {}
for root, dirs, files in walk(root_dir):
diff --git a/cloudinary_cli/utils/json_utils.py b/cloudinary_cli/utils/json_utils.py
index 7f90869..b976c34 100644
--- a/cloudinary_cli/utils/json_utils.py
+++ b/cloudinary_cli/utils/json_utils.py
@@ -1,9 +1,11 @@
import json
-from platform import system
+import sys
from os import path
import click
from pygments import highlight, lexers, formatters
+from cloudinary_cli.utils.file_utils import atomic_write
+
def read_json_from_file(filename, does_not_exist_ok=False):
if does_not_exist_ok and (not path.exists(filename) or path.getsize(filename) < 1):
@@ -13,21 +15,29 @@ def read_json_from_file(filename, does_not_exist_ok=False):
return json.loads(file.read() or "{}")
-def write_json_to_file(json_obj, filename, indent=2, sort_keys=False):
- with open(filename, 'w') as file:
+def write_json_to_file(json_obj, filename, indent=2, sort_keys=False, atomic=False, mode=None):
+ def dump(file):
json.dump(json_obj, file, indent=indent, sort_keys=sort_keys)
+ if atomic:
+ atomic_write(filename, dump, mode=mode)
+ else:
+ with open(filename, 'w') as file:
+ dump(file)
+
-def update_json_file(json_obj, filename, indent=2, sort_keys=False):
+def update_json_file(json_obj, filename, indent=2, sort_keys=False, atomic=False):
curr_obj = read_json_from_file(filename, True)
curr_obj.update(json_obj)
- write_json_to_file(curr_obj, filename, indent, sort_keys)
+ write_json_to_file(curr_obj, filename, indent, sort_keys, atomic)
def print_json(res):
res_str = json.dumps(res, indent=2)
- if system() != "Windows":
+ # Colorize only for an interactive terminal. When stdout is piped/redirected/captured (e.g. an
+ # LLM agent or `| jq`), emit plain JSON so ANSI escapes never corrupt the parsed output.
+ if sys.stdout.isatty():
res_str = highlight(res_str.encode('UTF-8'), lexers.JsonLexer(), formatters.TerminalFormatter()).strip()
click.echo(res_str)
diff --git a/cloudinary_cli/utils/utils.py b/cloudinary_cli/utils/utils.py
index e0c69a4..a4bd68c 100644
--- a/cloudinary_cli/utils/utils.py
+++ b/cloudinary_cli/utils/utils.py
@@ -1,9 +1,12 @@
#!/usr/bin/env python3
import builtins
import json
+import logging
import os
+import sys
from collections import OrderedDict
from csv import DictWriter
+from datetime import datetime, timezone
from functools import reduce
from hashlib import md5
from inspect import signature, getfullargspec
@@ -70,6 +73,20 @@ def print_api_help(api, block_list=not_callable, allow_list=()):
logger.info(get_help_str(api, block_list=block_list, allow_list=allow_list))
+def token_hint(token):
+ """Non-sensitive token fingerprint (trailing chars + length) for debug logs."""
+ if not token:
+ return ""
+ return f"…{token[-6:]}({len(token)} chars)"
+
+
+def expiry_hint(epoch):
+ try:
+ return datetime.fromtimestamp(int(epoch), tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
+ except (TypeError, ValueError):
+ return str(epoch)
+
+
def log_exception(e, message=None, debug_message=None):
message = f"{message}, error: {str(e)}" if message is not None else str(e)
debug_message = debug_message or message
@@ -198,6 +215,35 @@ def run_tasks_concurrently(func, tasks, concurrent_workers):
thread_pool.starmap(func, tasks)
+def is_interactive():
+ """True when we can prompt the user (stdin is an interactive terminal). The single home for the
+ interactivity check, so flow code never pokes sys.stdin directly."""
+ return sys.stdin.isatty()
+
+
+def should_dump_responses():
+ """True when full SDK API responses should be echoed (per-asset JSON under upload/sync). On at
+ DEBUG verbosity, but suppressed by CLOUDINARY_CLI_LOG_ONLY=1 to keep CLI log lines without the
+ bulky response bodies."""
+ if os.environ.get("CLOUDINARY_CLI_LOG_ONLY", "").strip() not in ("", "0", "false", "False"):
+ return False
+ return logger.getEffectiveLevel() < logging.INFO
+
+
+def prompt_user(message, noninteractive_hint=None):
+ """
+ Read a line of user input. The single place that calls input(): returns None when no input can
+ be read (closed/non-interactive stdin), logging noninteractive_hint (if given) so the caller's
+ decision is never a silent no-op.
+ """
+ try:
+ return input(message)
+ except EOFError:
+ if noninteractive_hint:
+ logger.warning(f"No input available (non-interactive terminal). {noninteractive_hint}")
+ return None
+
+
def confirm_action(message="Continue? (y/N)"):
"""
Confirms whether the user wants to continue.
@@ -208,10 +254,12 @@ def confirm_action(message="Continue? (y/N)"):
:return: Boolean indicating whether user wants to continue.
:rtype bool
"""
- return get_user_action(message, {"y": True, "default": False})
+ return get_user_action(
+ message, {"y": True, "default": False},
+ noninteractive_hint="Pass --force (-F) to skip this confirmation in non-interactive runs.")
-def get_user_action(message, options):
+def get_user_action(message, options, noninteractive_hint=None):
"""
Reads user input and returns value specified in options.
@@ -222,10 +270,14 @@ def get_user_action(message, options):
:type message: string
:param options: Options mapping.
:type options: dict
+ :param noninteractive_hint: Logged when no input can be read (closed/non-interactive stdin), to
+ point the user at the flag or piped input that replaces this prompt. The default option is
+ then applied.
:return: Value according to the user selection.
"""
- r = input(message).lower()
+ r = prompt_user(message, noninteractive_hint)
+ r = r.lower() if r is not None else "" # no input -> apply the default option
return options.get(r, options.get("default"))
diff --git a/requirements.txt b/requirements.txt
index a0ab04f..856c21d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,8 +1,9 @@
-cloudinary>=1.42.2
+cloudinary>=1.44.4
pygments
jinja2
click
click-log
+filelock
requests
docstring-parser
urllib3>=2.2.2 # not directly required, pinned by Snyk to avoid a vulnerability
diff --git a/setup.py b/setup.py
index d6f1c5a..b2644d7 100644
--- a/setup.py
+++ b/setup.py
@@ -35,7 +35,7 @@
keywords='cloudinary cli pycloudinary image video digital asset management command line interface transformation '
'friendly easy flexible',
license="MIT",
- python_requires='>=3.6.0',
+ python_requires='>=3.8.0',
setup_requires=["pytest-runner"],
tests_require=["pytest", "mock", "urllib3"],
install_requires=requirements,
diff --git a/test/conftest.py b/test/conftest.py
new file mode 100644
index 0000000..fc68033
--- /dev/null
+++ b/test/conftest.py
@@ -0,0 +1,16 @@
+from unittest.mock import patch
+
+import pytest
+from filelock import FileLock
+
+from cloudinary_cli.utils import config_utils
+
+
+@pytest.fixture(autouse=True)
+def isolate_cli_config(tmp_path):
+ """Redirect the CLI config file to a fresh per-test path so the developer's real
+ ~/.cloudinary-cli/config.json (saved accounts and __default__) never leaks into tests."""
+ config_file = str(tmp_path / "config.json")
+ with patch.object(config_utils, "CLOUDINARY_CLI_CONFIG_FILE", config_file), \
+ patch.object(config_utils, "_config_lock", FileLock(config_file + ".lock")):
+ yield
diff --git a/test/helper_test.py b/test/helper_test.py
index 0a3fb06..ce2f118 100644
--- a/test/helper_test.py
+++ b/test/helper_test.py
@@ -5,12 +5,18 @@
from functools import wraps
from pathlib import Path
+import cloudinary
import cloudinary.api
from cloudinary import logger
from cloudinary_cli.utils.api_utils import query_cld_folder
from urllib3 import HTTPResponse, disable_warnings
from urllib3._collections import HTTPHeaderDict
+# Many CLI tests mock the HTTP layer but still need a resolvable config to run; without one the
+# command exits "No Cloudinary configuration found". Gate those tests on a config being present.
+CONFIG_PRESENT = bool(cloudinary.config().cloud_name)
+REQUIRES_CONFIG = "Requires a Cloudinary configuration (set CLOUDINARY_URL or a saved config)"
+
SUFFIX = os.environ.get('TRAVIS_JOB_ID') or random.randint(10000, 99999)
RESOURCES_DIR = Path.joinpath(Path(__file__).resolve().parent, "resources")
diff --git a/test/oauth_helpers.py b/test/oauth_helpers.py
new file mode 100644
index 0000000..fb7d350
--- /dev/null
+++ b/test/oauth_helpers.py
@@ -0,0 +1,30 @@
+"""Build a real (unsigned) JWT access token. Production reads exp/iat/cloud_name/iss from the token's
+claims, so fixtures must carry them rather than use opaque strings."""
+import base64
+import json
+import time
+
+_OMIT = object() # sentinel: omit the claim entirely (to test the missing-claim path)
+
+
+def _b64url(data):
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
+
+
+def jwt_access_token(cloud_name="eu-cloud", iat=_OMIT, exp=_OMIT, expires_delta=300,
+ issuer="https://oauth.cloudinary.com/", tag=None):
+ """A decodable (unsigned) JWT access token. `iat`/`exp` are absolute epochs, defaulting to
+ now / now+expires_delta; pass `None` to omit a claim. `tag` varies the signature so successive
+ rotations produce distinct token strings."""
+ now = int(time.time())
+ iat = now if iat is _OMIT else iat
+ exp = (now + expires_delta) if exp is _OMIT else exp
+ header = _b64url(json.dumps({"alg": "RS256", "typ": "JWT"}).encode())
+ claims = {"iss": issuer, "ext": {"cloud_name": cloud_name}}
+ if iat is not None:
+ claims["iat"] = iat
+ if exp is not None:
+ claims["exp"] = exp
+ payload = _b64url(json.dumps(claims).encode())
+ sig = tag if tag is not None else "sig"
+ return f"{header}.{payload}.{sig}"
diff --git a/test/test_auth_flow.py b/test/test_auth_flow.py
new file mode 100644
index 0000000..3304ede
--- /dev/null
+++ b/test/test_auth_flow.py
@@ -0,0 +1,136 @@
+import base64
+import hashlib
+import unittest
+from unittest.mock import patch, MagicMock
+from urllib.parse import urlparse, parse_qs
+
+import requests
+
+from cloudinary_cli.auth import flow
+
+
+def _http_error(body=None, no_response=False, not_json=False):
+ e = requests.HTTPError("400 Client Error: Bad Request for url: https://oauth.cloudinary.com/oauth2/token")
+ if no_response:
+ return e
+ resp = MagicMock()
+ if not_json:
+ resp.json.side_effect = ValueError("no json")
+ resp.text = body if body is not None else "500"
+ else:
+ resp.json.return_value = body
+ resp.text = str(body)
+ e.response = resp
+ return e
+
+
+class TestAuthFlow(unittest.TestCase):
+ def test_pkce_pair_s256_no_padding(self):
+ verifier, challenge = flow.generate_pkce_pair()
+ self.assertNotIn("=", verifier)
+ self.assertNotIn("=", challenge)
+ # challenge must be the S256 of the verifier
+ expected = base64.urlsafe_b64encode(
+ hashlib.sha256(verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
+ self.assertEqual(expected, challenge)
+
+ def test_build_authorize_url(self):
+ url = flow.build_authorize_url("the_challenge", "the_state", "http://127.0.0.1:49421/callback", "api")
+ q = parse_qs(urlparse(url).query)
+ self.assertTrue(url.startswith("https://oauth.cloudinary.com/oauth2/auth?"))
+ self.assertEqual("code", q["response_type"][0])
+ self.assertEqual("S256", q["code_challenge_method"][0])
+ self.assertEqual("the_challenge", q["code_challenge"][0])
+ self.assertEqual("the_state", q["state"][0])
+ self.assertEqual("http://127.0.0.1:49421/callback", q["redirect_uri"][0])
+ self.assertIn("client_id", q)
+
+ def test_build_authorize_url_region_drives_host(self):
+ url = flow.build_authorize_url("c", "s", "http://127.0.0.1:49421/callback", "test")
+ self.assertTrue(url.startswith("https://oauth-test.cloudinary.com/oauth2/auth?"))
+
+ def test_exchange_code_posts_pkce_no_secret(self):
+ resp = MagicMock()
+ resp.json.return_value = {"access_token": "tok"}
+ with patch("cloudinary_cli.auth.flow.requests.post", return_value=resp) as post:
+ flow.exchange_code("the_code", "the_verifier", "http://127.0.0.1:49421/callback", "test")
+ self.assertEqual("https://oauth-test.cloudinary.com/oauth2/token", post.call_args.args[0])
+ data = post.call_args.kwargs["data"]
+ self.assertEqual("authorization_code", data["grant_type"])
+ self.assertEqual("the_code", data["code"])
+ self.assertEqual("the_verifier", data["code_verifier"])
+ self.assertNotIn("client_secret", data)
+ self.assertIn("timeout", post.call_args.kwargs)
+
+ def test_refresh_posts_refresh_token(self):
+ resp = MagicMock()
+ resp.json.return_value = {"access_token": "tok2"}
+ with patch("cloudinary_cli.auth.flow.requests.post", return_value=resp) as post:
+ flow.refresh("rt_abc", "api-eu")
+ self.assertEqual("https://oauth.cloudinary.com/oauth2/token", post.call_args.args[0])
+ data = post.call_args.kwargs["data"]
+ self.assertEqual("refresh_token", data["grant_type"])
+ self.assertEqual("rt_abc", data["refresh_token"])
+ self.assertIn("timeout", post.call_args.kwargs)
+
+ def test_revoke_posts_token_to_revoke_endpoint(self):
+ resp = MagicMock()
+ with patch("cloudinary_cli.auth.flow.requests.post", return_value=resp) as post:
+ flow.revoke("rt_abc", "api-eu")
+ self.assertEqual("https://oauth.cloudinary.com/oauth2/revoke", post.call_args.args[0])
+ data = post.call_args.kwargs["data"]
+ self.assertEqual("rt_abc", data["token"])
+ self.assertEqual("refresh_token", data["token_type_hint"])
+ self.assertIn("client_id", data)
+ self.assertIn("timeout", post.call_args.kwargs)
+ resp.raise_for_status.assert_called_once()
+
+
+class TestOAuthErrorDetail(unittest.TestCase):
+ """flow.oauth_error_detail extracts the RFC 6749 error code, appending a short description but
+ never the multi-sentence boilerplate the token endpoint returns for invalid_grant."""
+
+ def test_short_description_is_appended(self):
+ e = _http_error({"error": "invalid_client", "error_description": "Unknown client"})
+ self.assertEqual("invalid_client: Unknown client", flow.oauth_error_detail(e))
+
+ def test_long_description_is_suppressed(self):
+ # The real invalid_grant body is a >80-char paragraph; only the code should surface.
+ long_desc = ("The provided authorization grant or refresh token is invalid, expired, "
+ "revoked, or was issued to another client. The refresh token is malformed.")
+ e = _http_error({"error": "invalid_grant", "error_description": long_desc})
+ self.assertEqual("invalid_grant", flow.oauth_error_detail(e))
+
+ def test_error_only_no_description(self):
+ e = _http_error({"error": "invalid_grant"})
+ self.assertEqual("invalid_grant", flow.oauth_error_detail(e))
+
+ def test_description_exactly_at_limit_is_kept(self):
+ desc = "x" * 80
+ e = _http_error({"error": "invalid_request", "error_description": desc})
+ self.assertEqual(f"invalid_request: {desc}", flow.oauth_error_detail(e))
+
+ def test_description_one_over_limit_is_dropped(self):
+ e = _http_error({"error": "invalid_request", "error_description": "x" * 81})
+ self.assertEqual("invalid_request", flow.oauth_error_detail(e))
+
+ def test_no_error_key_returns_none(self):
+ self.assertIsNone(flow.oauth_error_detail(_http_error({"foo": "bar"})))
+
+ def test_non_json_body_returns_none(self):
+ self.assertIsNone(flow.oauth_error_detail(_http_error(not_json=True)))
+
+ def test_no_response_returns_none(self):
+ self.assertIsNone(flow.oauth_error_detail(_http_error(no_response=True)))
+
+
+class TestOAuthErrorBody(unittest.TestCase):
+ """flow.oauth_error_body returns the raw response text verbatim for debug logging."""
+
+ def test_returns_raw_text(self):
+ raw = '{"error":"invalid_grant","error_description":"long boilerplate here"}'
+ e = _http_error(not_json=True, body=raw)
+ self.assertEqual(raw, flow.oauth_error_body(e))
+
+ def test_no_response_returns_none(self):
+ self.assertIsNone(flow.oauth_error_body(_http_error(no_response=True)))
diff --git a/test/test_auth_loopback.py b/test/test_auth_loopback.py
new file mode 100644
index 0000000..232a71c
--- /dev/null
+++ b/test/test_auth_loopback.py
@@ -0,0 +1,107 @@
+import threading
+import unittest
+import urllib.request
+from http.server import HTTPServer
+from unittest.mock import patch
+
+from cloudinary_cli.auth.callback_page import callback_page
+from cloudinary_cli.auth.loopback_server import (
+ _CallbackHandler,
+ start_callback_server,
+ wait_for_callback,
+)
+
+
+class TestLoopbackServer(unittest.TestCase):
+ def setUp(self):
+ # Bind an OS-assigned port on loopback so tests don't collide with the real default.
+ self.httpd = HTTPServer(("127.0.0.1", 0), _CallbackHandler)
+ self.httpd.auth_code = self.httpd.auth_state = self.httpd.auth_error = None
+ self.httpd.timeout = 5
+ self.port = self.httpd.server_address[1]
+
+ def tearDown(self):
+ try:
+ self.httpd.server_close()
+ except Exception:
+ pass
+
+ def _get(self, path):
+ try:
+ urllib.request.urlopen(f"http://127.0.0.1:{self.port}{path}", timeout=5).read()
+ except Exception:
+ pass
+
+ def test_captures_code_and_state(self):
+ waiter = threading.Thread(target=lambda: setattr(self, "result", wait_for_callback(self.httpd)))
+ waiter.start()
+ self._get("/callback?code=the_code&state=the_state")
+ waiter.join(timeout=5)
+ self.assertEqual(("the_code", "the_state"), self.result)
+
+ def test_ignores_favicon_then_captures(self):
+ waiter = threading.Thread(target=lambda: setattr(self, "result", wait_for_callback(self.httpd)))
+ waiter.start()
+ self._get("/favicon.ico") # must NOT end the wait
+ self._get("/callback?code=c2&state=s2")
+ waiter.join(timeout=5)
+ self.assertEqual(("c2", "s2"), self.result)
+
+ def test_ignores_code_on_wrong_path_then_captures(self):
+ waiter = threading.Thread(target=lambda: setattr(self, "result", wait_for_callback(self.httpd)))
+ waiter.start()
+ self._get("/anything?code=stray&state=s") # wrong path must NOT end the wait
+ self._get("/callback?code=real&state=s3")
+ waiter.join(timeout=5)
+ self.assertEqual(("real", "s3"), self.result)
+
+ def test_error_raises(self):
+ error = {}
+
+ def run():
+ try:
+ wait_for_callback(self.httpd)
+ except Exception as e:
+ error["e"] = e
+
+ waiter = threading.Thread(target=run)
+ waiter.start()
+ self._get("/callback?error=access_denied")
+ waiter.join(timeout=5)
+ self.assertIsInstance(error.get("e"), RuntimeError)
+ self.assertIn("access_denied", str(error["e"]))
+
+
+class TestCallbackPage(unittest.TestCase):
+ """auth_error comes from the redirect query string (untrusted) and must be HTML-escaped
+ before being rendered into the callback page."""
+
+ def test_error_is_html_escaped(self):
+ page = callback_page("")
+ self.assertNotIn("", page)
+ self.assertIn("<script>alert(1)</script>", page)
+
+ def test_normal_error_rendered(self):
+ page = callback_page("access_denied")
+ self.assertIn("Login failed", page)
+ self.assertIn("access_denied", page)
+
+ def test_success_page(self):
+ page = callback_page(None)
+ self.assertIn("Login successful", page)
+
+
+class TestStartCallbackServerPortBusy(unittest.TestCase):
+ """A2: a failed bind (e.g. busy redirect port) must surface a clear RuntimeError, not a raw
+ OSError. The bind is mocked to fail so the test is deterministic across OSes (Windows does not
+ raise on a double-bind the way POSIX does)."""
+
+ def test_bind_failure_raises_friendly_error(self):
+ with patch("cloudinary_cli.auth.loopback_server.HTTPServer",
+ side_effect=OSError(48, "Address already in use")):
+ with self.assertRaises(RuntimeError) as ctx:
+ start_callback_server()
+ msg = str(ctx.exception)
+ self.assertIn("local login server", msg)
+ self.assertIn("in use", msg)
+ self.assertIsInstance(ctx.exception.__cause__, OSError) # chains the original
diff --git a/test/test_auth_region.py b/test/test_auth_region.py
new file mode 100644
index 0000000..c8ac3c7
--- /dev/null
+++ b/test/test_auth_region.py
@@ -0,0 +1,65 @@
+import importlib
+import unittest
+from unittest.mock import patch
+
+import cloudinary_cli.defaults as defaults
+from cloudinary_cli.defaults import normalize_region, _oauth_host_for, api_host_for_region
+
+
+class TestAuthRegion(unittest.TestCase):
+ def test_normalize_region(self):
+ self.assertEqual('api', normalize_region(None))
+ self.assertEqual('api', normalize_region(''))
+ self.assertEqual('api', normalize_region('api'))
+ self.assertEqual('api-eu', normalize_region('eu'))
+ self.assertEqual('api-ap', normalize_region('ap'))
+ self.assertEqual('api-eu', normalize_region('api-eu'))
+ self.assertEqual('api-eu', normalize_region(' api-eu '))
+ self.assertEqual('api-test', normalize_region('test'))
+ self.assertEqual('api-test', normalize_region('api-test'))
+
+ def test_api_host_for_region(self):
+ self.assertEqual('https://api.cloudinary.com', api_host_for_region('api'))
+ self.assertEqual('https://api-eu.cloudinary.com', api_host_for_region('api-eu'))
+ # short codes are normalized first
+ self.assertEqual('https://api-ap.cloudinary.com', api_host_for_region('ap'))
+ self.assertEqual('https://api-test.cloudinary.com', api_host_for_region('test'))
+ self.assertEqual('https://api-test.cloudinary.com', api_host_for_region('api-test'))
+
+ def test_oauth_host_central_for_geo_regions(self):
+ # <= 2-char suffixes (and bare 'api') use the central authz server
+ self.assertEqual('oauth.cloudinary.com', _oauth_host_for('api'))
+ self.assertEqual('oauth.cloudinary.com', _oauth_host_for('api-eu'))
+ self.assertEqual('oauth.cloudinary.com', _oauth_host_for('api-ap'))
+
+ def test_oauth_host_dedicated_for_long_suffix(self):
+ # longer suffixes route to their own oauth- host
+ self.assertEqual('oauth-test.cloudinary.com', _oauth_host_for('api-test'))
+
+
+class TestOAuthClientConfig(unittest.TestCase):
+ """OAUTH_CLIENT_ID / OAUTH_SCOPES are env-overridable (resolved at module import)."""
+
+ def _reload(self, env):
+ # The values are read at import time, so reload defaults under the patched environment.
+ with patch.dict("os.environ", env, clear=False):
+ for key in ("CLOUDINARY_OAUTH_CLIENT_ID", "CLOUDINARY_OAUTH_SCOPES"):
+ if key not in env:
+ __import__("os").environ.pop(key, None)
+ return importlib.reload(defaults)
+
+ def tearDown(self):
+ importlib.reload(defaults) # restore the unpatched module for other tests
+
+ def test_defaults_when_unset(self):
+ d = self._reload({})
+ self.assertEqual('a920ea9c-531b-4613-9783-1d4f4cc10655', d.OAUTH_CLIENT_ID)
+ self.assertEqual('openid offline_access asset_management upload', d.OAUTH_SCOPES)
+
+ def test_client_id_override(self):
+ d = self._reload({"CLOUDINARY_OAUTH_CLIENT_ID": "non-prod-client"})
+ self.assertEqual("non-prod-client", d.OAUTH_CLIENT_ID)
+
+ def test_scopes_override(self):
+ d = self._reload({"CLOUDINARY_OAUTH_SCOPES": "openid upload"})
+ self.assertEqual("openid upload", d.OAUTH_SCOPES)
diff --git a/test/test_auth_session.py b/test/test_auth_session.py
new file mode 100644
index 0000000..bdef819
--- /dev/null
+++ b/test/test_auth_session.py
@@ -0,0 +1,401 @@
+import time
+import unittest
+from unittest import mock
+from unittest.mock import patch
+
+import cloudinary
+
+from cloudinary_cli.auth import (
+ login,
+ refresh_url_if_stale,
+ refresh_config,
+ refresh_configs,
+ _derive_config_name,
+)
+from cloudinary_cli.auth.session import (
+ Session,
+ to_cloudinary_url,
+ from_cloudinary_url,
+ is_oauth_url,
+ strip_oauth_internal_keys,
+)
+from test.oauth_helpers import jwt_access_token
+
+
+def _session(**overrides):
+ base = dict(cloud_name="eu-cloud", access_token="eyJ.aaa.bbb", refresh_token="rt_123",
+ expires_at=int(time.time()) + 300, region="api-eu",
+ issuer="https://oauth.cloudinary.com/")
+ base.update(overrides)
+ return Session(**base)
+
+
+# A refresh response carries a real JWT (production reads exp/iat/cloud_name from its claims). Use a
+# distinct cloud_name so the resulting access token differs from the stale one being replaced.
+_NEW_TOKEN = jwt_access_token(cloud_name="eu-cloud", tag="newsig")
+
+
+def _token_response(access_token=_NEW_TOKEN, refresh_token="rt_new"):
+ return {"access_token": access_token, "refresh_token": refresh_token, "expires_in": 300}
+
+
+class TestSessionCodec(unittest.TestCase):
+ def test_round_trip(self):
+ s = _session()
+ parsed = from_cloudinary_url(to_cloudinary_url(s))
+ self.assertEqual(s.cloud_name, parsed.cloud_name)
+ self.assertEqual(s.access_token, parsed.access_token)
+ self.assertEqual(s.refresh_token, parsed.refresh_token)
+ self.assertEqual(s.region, parsed.region)
+ self.assertEqual(s.issuer, parsed.issuer)
+ self.assertEqual(s.expires_at, parsed.expires_at)
+ self.assertIsInstance(parsed.expires_at, int)
+
+ def test_parses_through_sdk_as_bearer(self):
+ url = to_cloudinary_url(_session())
+ config = cloudinary.Config()
+ config._setup_from_parsed_url(config._parse_cloudinary_url(url))
+ self.assertEqual("eu-cloud", config.cloud_name)
+ self.assertEqual("eyJ.aaa.bbb", config.oauth_token)
+ self.assertIsNone(config.api_key)
+ self.assertIsNone(config.api_secret)
+ self.assertEqual("https://api-eu.cloudinary.com", config.upload_prefix)
+
+ def test_is_oauth_url(self):
+ self.assertTrue(is_oauth_url(to_cloudinary_url(_session())))
+ self.assertFalse(is_oauth_url("cloudinary://key:secret@cloud"))
+ self.assertFalse(is_oauth_url(None))
+ # substring 'oauth_token' outside the query key must not match
+ self.assertFalse(is_oauth_url("cloudinary://key:secret@oauth_token.example.com"))
+ self.assertFalse(is_oauth_url("cloudinary://key:secret@cloud?cname=oauth_token.io"))
+
+ def test_is_fresh(self):
+ self.assertTrue(_session().is_fresh())
+ self.assertFalse(_session(expires_at=int(time.time()) - 10).is_fresh())
+
+ def test_expiry_and_issued_at_come_from_jwt_not_local_clock(self):
+ # exp/iat are read straight from the token's claims, NOT computed from the local clock or
+ # expires_in — so a skewed local clock can't distort the stored lifetime.
+ token = jwt_access_token(cloud_name="c", iat=1000, exp=1300)
+ s = Session.from_token_response({"access_token": token, "expires_in": 999}, cloud_name="c")
+ self.assertEqual(1000, s.issued_at)
+ self.assertEqual(1300, s.expires_at) # 1300 from exp, not 1000+999 and not now+999
+
+ def test_missing_exp_claim_fails_loudly(self):
+ token = jwt_access_token(cloud_name="c", iat=1000, exp=None)
+ with self.assertRaises(ValueError):
+ Session.from_token_response({"access_token": token}, cloud_name="c")
+
+ def test_non_numeric_exp_claim_fails_loudly(self):
+ token = jwt_access_token(cloud_name="c", iat=1000, exp="soon")
+ with self.assertRaises(ValueError):
+ Session.from_token_response({"access_token": token}, cloud_name="c")
+
+ def test_non_jwt_access_token_fails_loudly(self):
+ with self.assertRaises(ValueError):
+ Session.from_token_response({"access_token": "not-a-jwt"}, cloud_name="c")
+
+ def test_cloud_name_read_from_ext_claim_when_not_passed(self):
+ token = jwt_access_token(cloud_name="from-token", iat=1000, exp=1300)
+ s = Session.from_token_response({"access_token": token}) # no cloud_name arg
+ self.assertEqual("from-token", s.cloud_name)
+
+ def test_missing_cloud_name_fails_loudly(self):
+ # No cloud_name passed and the token carries none -> we cannot address any cloud, so fail.
+ token = jwt_access_token(cloud_name=None, iat=1000, exp=1300)
+ with self.assertRaises(ValueError):
+ Session.from_token_response({"access_token": token})
+
+ def test_refresh_preserves_cloud_name_without_reading_token(self):
+ # updated_from passes the existing cloud_name, so a refreshed token need not re-carry it.
+ original = jwt_access_token(cloud_name="orig", iat=1000, exp=1300)
+ sess = Session.from_token_response({"access_token": original})
+ rotated = jwt_access_token(cloud_name=None, iat=2000, exp=2300, tag="rot")
+ refreshed = sess.updated_from({"access_token": rotated, "refresh_token": "rt"})
+ self.assertEqual("orig", refreshed.cloud_name)
+ self.assertEqual(2300, refreshed.expires_at)
+
+
+class TestStripOAuthInternalKeys(unittest.TestCase):
+ def test_drops_bookkeeping_keeps_auth_and_host(self):
+ url = to_cloudinary_url(_session())
+ config = cloudinary.Config()
+ config._setup_from_parsed_url(config._parse_cloudinary_url(url))
+ full = {k: v for k, v in config.__dict__.items() if not k.startswith("_")}
+ self.assertEqual({"refresh_token", "expires_at", "region", "issuer"}, full.keys() &
+ {"refresh_token", "expires_at", "region", "issuer"})
+
+ sanitized = strip_oauth_internal_keys(full)
+ for leaked in ("refresh_token", "expires_at", "region", "issuer"):
+ self.assertNotIn(leaked, sanitized)
+ self.assertEqual("eyJ.aaa.bbb", sanitized["oauth_token"])
+ self.assertEqual("https://api-eu.cloudinary.com", sanitized["upload_prefix"])
+ self.assertEqual("eu-cloud", sanitized["cloud_name"])
+
+ def test_noop_on_api_key_config(self):
+ full = {"cloud_name": "c", "api_key": "k", "api_secret": "s"}
+ self.assertEqual(full, strip_oauth_internal_keys(full))
+
+
+class TestRefreshUrlIfStale(unittest.TestCase):
+ def test_non_oauth_passthrough(self):
+ url = "cloudinary://key:secret@cloud"
+ self.assertEqual(url, refresh_url_if_stale("c", url))
+
+ def test_fresh_unchanged(self):
+ url = to_cloudinary_url(_session())
+ with patch("cloudinary_cli.auth.flow.refresh") as refresh:
+ self.assertEqual(url, refresh_url_if_stale("eu-cloud", url))
+ refresh.assert_not_called()
+
+ def test_force_refreshes_fresh_token(self):
+ url = to_cloudinary_url(_session()) # fresh
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value={"eu-cloud": url}), \
+ patch("cloudinary_cli.auth.flow.refresh", return_value=_token_response()) as refresh, \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ new_url = refresh_url_if_stale("eu-cloud", url, force=True)
+ refresh.assert_called_once()
+ self.assertEqual(_NEW_TOKEN, from_cloudinary_url(new_url).access_token)
+
+ def test_stale_refreshes_and_rewrites(self):
+ stale_url = to_cloudinary_url(_session(expires_at=int(time.time()) - 10))
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value={"eu-cloud": stale_url}), \
+ patch("cloudinary_cli.auth.flow.refresh", return_value=_token_response()), \
+ patch("cloudinary_cli.auth.refresh.update_config") as update_config:
+ new_url = refresh_url_if_stale("eu-cloud", stale_url)
+ self.assertEqual(_NEW_TOKEN, from_cloudinary_url(new_url).access_token)
+ self.assertEqual("rt_new", from_cloudinary_url(new_url).refresh_token)
+ update_config.assert_called_once()
+
+ def test_no_refresh_token_returns_unchanged(self):
+ url = to_cloudinary_url(_session(expires_at=int(time.time()) - 10, refresh_token=None))
+ self.assertEqual(url, refresh_url_if_stale("eu-cloud", url))
+
+ def test_refresh_timeout_returns_stale_url(self):
+ import requests
+ stale_url = to_cloudinary_url(_session(expires_at=int(time.time()) - 10))
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value={"eu-cloud": stale_url}), \
+ patch("cloudinary_cli.auth.flow.refresh", side_effect=requests.Timeout()), \
+ patch("cloudinary_cli.auth.refresh.update_config") as update_config:
+ self.assertEqual(stale_url, refresh_url_if_stale("eu-cloud", stale_url))
+ update_config.assert_not_called()
+
+ def test_refresh_failure_warns_once_per_config(self):
+ # A3a: a failed background refresh must surface a re-login hint (not just a debug line), but
+ # only once per config so a bulk run does not log it per asset.
+ import requests
+ import cloudinary_cli.auth.refresh as refresh_mod
+ refresh_mod._refresh_warned.discard("eu-cloud")
+ self.addCleanup(refresh_mod._refresh_warned.discard, "eu-cloud")
+ stale_url = to_cloudinary_url(_session(expires_at=int(time.time()) - 10))
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value={"eu-cloud": stale_url}), \
+ patch("cloudinary_cli.auth.flow.refresh", side_effect=requests.ConnectionError()), \
+ patch("cloudinary_cli.auth.refresh.update_config"), \
+ patch("cloudinary_cli.auth.refresh.logger.warning") as warn:
+ refresh_url_if_stale("eu-cloud", stale_url)
+ refresh_url_if_stale("eu-cloud", stale_url) # second stale read in the same run
+ warn.assert_called_once()
+ self.assertIn("cld login eu-cloud", warn.call_args[0][0])
+
+ def test_refresh_failure_warning_includes_oauth_error_code(self):
+ # An HTTPError carrying an OAuth body surfaces the server's error code in the warning.
+ import requests
+ import cloudinary_cli.auth.refresh as refresh_mod
+ refresh_mod._refresh_warned.discard("eu-cloud")
+ self.addCleanup(refresh_mod._refresh_warned.discard, "eu-cloud")
+ resp = mock.MagicMock()
+ resp.json.return_value = {"error": "invalid_grant", "error_description": "x" * 200}
+ http_error = requests.HTTPError("400 Client Error")
+ http_error.response = resp
+ stale_url = to_cloudinary_url(_session(expires_at=int(time.time()) - 10))
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value={"eu-cloud": stale_url}), \
+ patch("cloudinary_cli.auth.flow.refresh", side_effect=http_error), \
+ patch("cloudinary_cli.auth.refresh.update_config"), \
+ patch("cloudinary_cli.auth.refresh.logger.warning") as warn:
+ refresh_url_if_stale("eu-cloud", stale_url)
+ msg = warn.call_args[0][0]
+ self.assertIn("(invalid_grant)", msg) # code surfaced
+ self.assertNotIn("x" * 200, msg) # long boilerplate not dumped into the warning
+ self.assertIn("cld login eu-cloud", msg)
+
+ def test_refresh_failure_warning_without_response_omits_detail(self):
+ # A bare connection error (no response body) still warns, just without an error code paren.
+ import requests
+ import cloudinary_cli.auth.refresh as refresh_mod
+ refresh_mod._refresh_warned.discard("eu-cloud")
+ self.addCleanup(refresh_mod._refresh_warned.discard, "eu-cloud")
+ stale_url = to_cloudinary_url(_session(expires_at=int(time.time()) - 10))
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value={"eu-cloud": stale_url}), \
+ patch("cloudinary_cli.auth.flow.refresh", side_effect=requests.ConnectionError()), \
+ patch("cloudinary_cli.auth.refresh.update_config"), \
+ patch("cloudinary_cli.auth.refresh.logger.warning") as warn:
+ refresh_url_if_stale("eu-cloud", stale_url)
+ msg = warn.call_args[0][0]
+ self.assertNotIn("(", msg.split("using the")[0]) # no error-code paren before the hint
+ self.assertIn("cld login eu-cloud", msg)
+
+ def test_refresh_success_rearms_the_warning(self):
+ # After a successful refresh the warning is re-armed, so a later failure warns again.
+ import cloudinary_cli.auth.refresh as refresh_mod
+ refresh_mod._refresh_warned.add("eu-cloud")
+ self.addCleanup(refresh_mod._refresh_warned.discard, "eu-cloud")
+ stale_url = to_cloudinary_url(_session(expires_at=int(time.time()) - 10))
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value={"eu-cloud": stale_url}), \
+ patch("cloudinary_cli.auth.flow.refresh", return_value=_token_response()), \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ refresh_url_if_stale("eu-cloud", stale_url)
+ self.assertNotIn("eu-cloud", refresh_mod._refresh_warned)
+
+ def test_adopts_peer_refresh_without_calling_refresh(self):
+ # Peer already rewrote the saved URL to a fresh token while we waited for the lock.
+ stale_url = to_cloudinary_url(_session(expires_at=int(time.time()) - 10))
+ peer_fresh_url = to_cloudinary_url(_session(
+ access_token="eyJ.peer.tok", expires_at=int(time.time()) + 300))
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value={"eu-cloud": peer_fresh_url}), \
+ patch("cloudinary_cli.auth.flow.refresh") as refresh, \
+ patch("cloudinary_cli.auth.refresh.update_config") as update_config:
+ result = refresh_url_if_stale("eu-cloud", stale_url)
+ self.assertEqual(peer_fresh_url, result)
+ refresh.assert_not_called() # we did not burn the (already-rotated) refresh token
+ update_config.assert_not_called()
+
+ def test_refreshes_when_peer_value_still_stale(self):
+ stale_url = to_cloudinary_url(_session(expires_at=int(time.time()) - 10))
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value={"eu-cloud": stale_url}), \
+ patch("cloudinary_cli.auth.flow.refresh", return_value=_token_response()) as refresh, \
+ patch("cloudinary_cli.auth.refresh.update_config") as update_config:
+ result = refresh_url_if_stale("eu-cloud", stale_url)
+ self.assertEqual(_NEW_TOKEN, from_cloudinary_url(result).access_token)
+ refresh.assert_called_once()
+ update_config.assert_called_once()
+
+
+class TestRefreshConfig(unittest.TestCase):
+ def _cfg(self, **extra):
+ cfg = {
+ "stale": to_cloudinary_url(_session(cloud_name="stale", expires_at=int(time.time()) - 10)),
+ "fresh": to_cloudinary_url(_session(cloud_name="fresh")),
+ "key": "cloudinary://k:s@kc",
+ }
+ cfg.update(extra)
+ return cfg
+
+ def test_not_found(self):
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=self._cfg()):
+ self.assertEqual("not_found", refresh_config("ghost"))
+
+ def test_not_oauth(self):
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=self._cfg()):
+ self.assertEqual("not_oauth", refresh_config("key"))
+
+ def test_fresh_skipped(self):
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=self._cfg()), \
+ patch("cloudinary_cli.auth.flow.refresh") as refresh:
+ self.assertEqual("fresh", refresh_config("fresh"))
+ refresh.assert_not_called()
+
+ def test_stale_refreshed(self):
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=self._cfg()), \
+ patch("cloudinary_cli.auth.flow.refresh", return_value=_token_response()), \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ self.assertEqual("refreshed", refresh_config("stale"))
+
+ def test_force_refreshes_fresh(self):
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=self._cfg()), \
+ patch("cloudinary_cli.auth.flow.refresh", return_value=_token_response()) as refresh, \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ self.assertEqual("refreshed", refresh_config("fresh", force=True))
+ refresh.assert_called_once()
+
+ def test_failed_when_no_refresh_token(self):
+ cfg = self._cfg(stale=to_cloudinary_url(_session(
+ cloud_name="stale", expires_at=int(time.time()) - 10, refresh_token=None)))
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=cfg):
+ self.assertEqual("failed", refresh_config("stale"))
+
+ def test_relogin_command_includes_non_default_region(self):
+ from cloudinary_cli.auth import relogin_command
+ cfg = {
+ "global": to_cloudinary_url(_session(cloud_name="global", region="api")),
+ "stg": to_cloudinary_url(_session(cloud_name="stg", region="api-staging")),
+ "key": "cloudinary://k:s@kc",
+ }
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=cfg):
+ self.assertEqual("cld login global", relogin_command("global"))
+ self.assertEqual("cld login stg --region api-staging", relogin_command("stg"))
+ self.assertEqual("cld login key", relogin_command("key")) # non-oauth: no region
+
+ def test_refresh_configs_sweeps_oauth_only(self):
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=self._cfg()), \
+ patch("cloudinary_cli.auth.flow.refresh", return_value=_token_response()), \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ results = refresh_configs()
+ self.assertEqual({"stale": "refreshed", "fresh": "fresh"}, results) # "key" not swept
+
+
+class TestLoginGuards(unittest.TestCase):
+ def test_missing_cloud_name_raises_and_saves_nothing(self):
+ session = _session(cloud_name=None)
+ with patch("cloudinary_cli.auth._run_browser_flow", return_value=session), \
+ patch("cloudinary_cli.auth.update_config") as update_config:
+ with self.assertRaises(RuntimeError):
+ login(region="api-eu")
+ update_config.assert_not_called()
+
+
+class TestBrowserFlowNonInteractive(unittest.TestCase):
+ """No browser + no TTY: _run_browser_flow must fail fast with a headless-usage hint, never block
+ in wait_for_callback until the callback times out."""
+
+ def test_no_browser_no_tty_fails_fast_without_waiting(self):
+ from cloudinary_cli.auth import _run_browser_flow
+ fake_httpd = mock.Mock()
+ with patch("cloudinary_cli.auth.start_callback_server",
+ return_value=(fake_httpd, "http://127.0.0.1:49421/callback")), \
+ patch("cloudinary_cli.auth.webbrowser.open", return_value=False), \
+ patch("cloudinary_cli.auth.is_interactive", return_value=False), \
+ patch("cloudinary_cli.auth.wait_for_callback") as wait:
+ with self.assertRaises(RuntimeError) as ctx:
+ _run_browser_flow("api-eu")
+ wait.assert_not_called() # fails fast: no 5-minute callback wait
+ fake_httpd.server_close.assert_called_once() # releases the bound port
+ self.assertIn("-c", str(ctx.exception)) # points at the headless API-key alternative
+
+ def test_no_browser_but_tty_still_waits(self):
+ # A human at a TTY can paste the printed URL, so we must NOT fail fast here.
+ from cloudinary_cli.auth import _run_browser_flow
+ with patch("cloudinary_cli.auth.start_callback_server",
+ return_value=(mock.Mock(), "http://127.0.0.1:49421/callback")), \
+ patch("cloudinary_cli.auth.webbrowser.open", return_value=False), \
+ patch("cloudinary_cli.auth.is_interactive", return_value=True), \
+ patch("cloudinary_cli.auth.wait_for_callback", return_value=("code", "st")) as wait, \
+ patch("cloudinary_cli.auth.flow.exchange_code",
+ return_value={"access_token": jwt_access_token(cloud_name="c")}):
+ # state mismatch is irrelevant here; we only assert it reached the wait (did not fast-fail)
+ with patch("cloudinary_cli.auth.secrets.token_urlsafe", return_value="st"):
+ _run_browser_flow("api-eu")
+ wait.assert_called_once()
+
+
+class TestDeriveConfigName(unittest.TestCase):
+ def _derive(self, cloud, region, config):
+ with patch("cloudinary_cli.auth.load_config", return_value=config):
+ return _derive_config_name(cloud, region)
+
+ def test_default_region_bare(self):
+ self.assertEqual("my_cloud", self._derive("my_cloud", "api", {}))
+
+ def test_region_suffix(self):
+ self.assertEqual("my_cloud-eu", self._derive("my_cloud", "api-eu", {}))
+
+ def test_relogin_overwrites_same_type(self):
+ existing = {"my_cloud-eu": "cloudinary://my_cloud-eu?oauth_token=x"}
+ self.assertEqual("my_cloud-eu", self._derive("my_cloud", "api-eu", existing))
+
+ def test_cross_type_collision_gets_oauth_suffix(self):
+ existing = {"my_cloud": "cloudinary://key:secret@my_cloud"}
+ self.assertEqual("my_cloud-oauth", self._derive("my_cloud", "api", existing))
+
+ def test_cross_type_collision_with_region(self):
+ existing = {"my_cloud-eu": "cloudinary://key:secret@my_cloud"}
+ self.assertEqual("my_cloud-eu-oauth", self._derive("my_cloud", "api-eu", existing))
diff --git a/test/test_cli.py b/test/test_cli.py
index ca8251d..87e1208 100644
--- a/test/test_cli.py
+++ b/test/test_cli.py
@@ -11,6 +11,8 @@ class TestCLI(unittest.TestCase):
COMMANDS = [
'admin',
'config',
+ 'login',
+ 'logout',
'make',
'migrate',
'provisioning',
diff --git a/test/test_cli_api.py b/test/test_cli_api.py
index d18361e..1ec464c 100644
--- a/test/test_cli_api.py
+++ b/test/test_cli_api.py
@@ -6,7 +6,8 @@
from click.testing import CliRunner
from cloudinary_cli.cli import cli
-from test.helper_test import api_response_mock, uploader_response_mock, URLLIB3_REQUEST
+from test.helper_test import api_response_mock, uploader_response_mock, URLLIB3_REQUEST, \
+ CONFIG_PRESENT, REQUIRES_CONFIG
API_MOCK_RESPONSE = api_response_mock()
UPLOAD_MOCK_RESPONSE = uploader_response_mock()
@@ -17,6 +18,7 @@
class TestCLIApi(unittest.TestCase):
runner = CliRunner()
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
@patch(URLLIB3_REQUEST)
def test_admin(self, mocker):
mocker.return_value = API_MOCK_RESPONSE
@@ -25,6 +27,7 @@ def test_admin(self, mocker):
self.assertEqual(0, result.exit_code, result.output)
self.assertIn('"foo": "bar"', result.output)
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
@patch(URLLIB3_REQUEST)
def test_upload(self, mocker):
mocker.return_value = UPLOAD_MOCK_RESPONSE
@@ -56,6 +59,7 @@ def test_delete_all_resources_decline_skips_call(self, http_mock, confirm_mock):
confirm_mock.assert_called_once()
self.assertFalse(http_mock.called, "SDK should not be called when user declines")
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
@patch(CONFIRM_ACTION_PATCH, return_value=True)
@patch(URLLIB3_REQUEST)
def test_delete_all_resources_accept_calls_sdk(self, http_mock, confirm_mock):
@@ -66,6 +70,7 @@ def test_delete_all_resources_accept_calls_sdk(self, http_mock, confirm_mock):
confirm_mock.assert_called_once()
self.assertTrue(http_mock.called, "SDK should be called when user accepts")
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
@patch(CONFIRM_ACTION_PATCH, return_value=False)
@patch(URLLIB3_REQUEST)
def test_delete_all_resources_force_skips_prompt(self, http_mock, confirm_mock):
@@ -86,6 +91,7 @@ def test_delete_resources_by_tag_decline_skips_call(self, http_mock, confirm_moc
confirm_mock.assert_called_once()
self.assertFalse(http_mock.called, "SDK should not be called when user declines")
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
@patch(CONFIRM_ACTION_PATCH, return_value=False)
@patch(URLLIB3_REQUEST)
def test_delete_resources_explicit_ids_no_prompt(self, http_mock, confirm_mock):
@@ -96,6 +102,7 @@ def test_delete_resources_explicit_ids_no_prompt(self, http_mock, confirm_mock):
self.assertFalse(confirm_mock.called, "Explicit-ID delete must not prompt")
self.assertTrue(http_mock.called, "SDK should be called for explicit-ID delete")
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
@patch(CONFIRM_ACTION_PATCH, return_value=False)
@patch(URLLIB3_REQUEST)
def test_uploader_add_tag_no_prompt(self, http_mock, confirm_mock):
@@ -106,6 +113,7 @@ def test_uploader_add_tag_no_prompt(self, http_mock, confirm_mock):
self.assertFalse(confirm_mock.called, "Non-destructive bulk methods must not prompt")
self.assertTrue(http_mock.called, "SDK should be called for non-destructive bulk methods")
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
@patch(CONFIRM_ACTION_PATCH, return_value=False)
@patch(URLLIB3_REQUEST)
def test_admin_resources_read_no_prompt(self, http_mock, confirm_mock):
@@ -116,6 +124,7 @@ def test_admin_resources_read_no_prompt(self, http_mock, confirm_mock):
self.assertFalse(confirm_mock.called, "Read commands must not prompt")
self.assertTrue(http_mock.called, "SDK should be called for read commands")
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
@patch(CONFIRM_ACTION_PATCH, return_value=False)
@patch(URLLIB3_REQUEST)
def test_admin_resources_read_with_force_no_prompt(self, http_mock, confirm_mock):
diff --git a/test/test_cli_config.py b/test/test_cli_config.py
index fee18bf..fac0964 100644
--- a/test/test_cli_config.py
+++ b/test/test_cli_config.py
@@ -1,4 +1,6 @@
+import os
import unittest
+from unittest.mock import patch
import cloudinary
from click.testing import CliRunner
@@ -6,6 +8,10 @@
from cloudinary_cli.cli import cli
+def _env_without_cloudinary_vars():
+ return {k: v for k, v in os.environ.items() if not k.startswith("CLOUDINARY_")}
+
+
def _get_real_cloudinary_url():
cfg = cloudinary.config()
@@ -88,9 +94,13 @@ def test_cli_config_show(self):
@unittest.skipUnless(cloudinary.config().api_secret, "Requires api_key/api_secret")
def test_cli_config_show_default_no_config(self):
- self.runner.invoke(cli, ['config', '--from_url', self.EMPTY_CLOUDINARY_URL])
+ # This asserts the "nothing configured" path, so it must run with no environment config:
+ # otherwise the resolver legitimately falls back to CLOUDINARY_URL and the config is valid.
+ with patch.dict(os.environ, _env_without_cloudinary_vars(), clear=True):
+ cloudinary.reset_config()
+ self.runner.invoke(cli, ['config', '--from_url', self.EMPTY_CLOUDINARY_URL])
- result = self.runner.invoke(cli, ['config'])
+ result = self.runner.invoke(cli, ['config'])
self.assertEqual(1, result.exit_code)
diff --git a/test/test_cli_config_oauth.py b/test/test_cli_config_oauth.py
new file mode 100644
index 0000000..5573c7f
--- /dev/null
+++ b/test/test_cli_config_oauth.py
@@ -0,0 +1,921 @@
+import json
+import os
+import time
+import unittest
+from unittest.mock import patch
+
+import cloudinary
+from click.testing import CliRunner
+
+from cloudinary_cli.auth.session import Session, to_cloudinary_url
+from test.oauth_helpers import jwt_access_token
+from cloudinary_cli.cli import cli
+from cloudinary_cli.utils.config_resolver import config_to_api_kwargs, get_cloudinary_config
+from cloudinary_cli.utils.config_utils import config_to_dict, show_cloudinary_config
+
+
+def _oauth_url(cloud="eu-cloud", region="api-eu"):
+ return to_cloudinary_url(Session(
+ cloud_name=cloud, access_token="eyJ.secret_access.tok", refresh_token="rt_secret_value",
+ expires_at=int(time.time()) + 300, region=region, issuer="https://oauth.cloudinary.com/"))
+
+
+class _RestoresSdkConfig(unittest.TestCase):
+ def setUp(self):
+ self._env_snapshot = dict(os.environ)
+ # Strip ambient CLOUDINARY_* so a bare cloudinary.Config() built in a test is not polluted by
+ # the developer's env (e.g. a real account_url leaking into masking/display assertions).
+ for key in [k for k in os.environ if k.startswith("CLOUDINARY_")]:
+ del os.environ[key]
+ cloudinary.reset_config()
+ self.addCleanup(self._restore_sdk_config)
+
+ def _restore_sdk_config(self):
+ os.environ.clear()
+ os.environ.update(self._env_snapshot)
+ cloudinary.reset_config()
+
+
+class TestLogoutScope(unittest.TestCase):
+ """logout must only remove OAuth logins, never plain saved configs."""
+
+ def test_removes_oauth_login(self):
+ from cloudinary_cli.auth import logout
+ saved = {"eu-cloud": _oauth_url()}
+ with patch("cloudinary_cli.auth.load_config", return_value=saved), \
+ patch("cloudinary_cli.auth.remove_config_keys") as remove, \
+ patch("cloudinary_cli.auth.flow.revoke") as revoke:
+ self.assertEqual("removed", logout("eu-cloud"))
+ remove.assert_called_once_with("eu-cloud")
+ revoke.assert_called_once_with("rt_secret_value", "api-eu")
+
+ def test_revoke_failure_still_removes_locally(self):
+ import requests
+ from cloudinary_cli.auth import logout
+ saved = {"eu-cloud": _oauth_url()}
+ with patch("cloudinary_cli.auth.load_config", return_value=saved), \
+ patch("cloudinary_cli.auth.remove_config_keys") as remove, \
+ patch("cloudinary_cli.auth.flow.revoke", side_effect=requests.ConnectionError()):
+ self.assertEqual("revoke_failed", logout("eu-cloud"))
+ remove.assert_called_once_with("eu-cloud") # local entry removed despite revoke failure
+
+ def test_refuses_non_oauth_config(self):
+ from cloudinary_cli.auth import logout
+ saved = {"mykey": "cloudinary://key:secret@cloud"}
+ with patch("cloudinary_cli.auth.load_config", return_value=saved), \
+ patch("cloudinary_cli.auth.remove_config_keys") as remove:
+ self.assertEqual("not_oauth", logout("mykey"))
+ remove.assert_not_called()
+
+ def test_missing_name(self):
+ from cloudinary_cli.auth import logout
+ with patch("cloudinary_cli.auth.load_config", return_value={}), \
+ patch("cloudinary_cli.auth.remove_config_keys") as remove:
+ self.assertEqual("not_found", logout("nope"))
+ remove.assert_not_called()
+
+
+class TestLogoutInteractiveSelect(unittest.TestCase):
+ """`cld logout` with no name lists OAuth logins and removes the chosen one."""
+
+ runner = CliRunner()
+
+ def test_lists_only_oauth_and_removes_selected(self):
+ saved = {"mykey": "cloudinary://key:secret@cloud",
+ "cloud-a": _oauth_url("cloud-a"), "cloud-b": _oauth_url("cloud-b")}
+ with patch("cloudinary_cli.auth.load_config", return_value=saved), \
+ patch("cloudinary_cli.auth.refresh.load_config", return_value=saved), \
+ patch("cloudinary_cli.auth.remove_config_keys") as remove, \
+ patch("cloudinary_cli.auth.flow.revoke"):
+ result = self.runner.invoke(cli, ["logout"], input="2\n")
+ self.assertIn("cloud-a", result.output)
+ self.assertIn("cloud-b", result.output)
+ self.assertNotIn("mykey", result.output) # non-oauth not offered
+ remove.assert_called_once_with("cloud-b")
+
+ def test_no_oauth_logins(self):
+ with patch("cloudinary_cli.auth.refresh.load_config",
+ return_value={"mykey": "cloudinary://key:secret@cloud"}), \
+ patch("cloudinary_cli.auth.remove_config_keys") as remove:
+ result = self.runner.invoke(cli, ["logout"], input="\n")
+ self.assertIn("No saved OAuth logins", result.output)
+ remove.assert_not_called()
+
+ def test_cancel_on_empty_input(self):
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value={"cloud-a": _oauth_url("cloud-a")}), \
+ patch("cloudinary_cli.auth.remove_config_keys") as remove:
+ result = self.runner.invoke(cli, ["logout"], input="\n")
+ remove.assert_not_called()
+ self.assertEqual(0, result.exit_code)
+
+ def test_invalid_non_numeric_errors(self):
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value={"cloud-a": _oauth_url("cloud-a")}), \
+ patch("cloudinary_cli.auth.remove_config_keys") as remove:
+ result = self.runner.invoke(cli, ["logout"], input="sdfdsf\n", standalone_mode=False)
+ self.assertIn("Invalid selection", result.output)
+ self.assertFalse(result.return_value) # main() maps falsy -> exit 1
+ remove.assert_not_called()
+
+ def test_out_of_range_errors(self):
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value={"cloud-a": _oauth_url("cloud-a")}), \
+ patch("cloudinary_cli.auth.remove_config_keys") as remove:
+ result = self.runner.invoke(cli, ["logout"], input="5\n", standalone_mode=False)
+ self.assertIn("Invalid selection", result.output)
+ self.assertFalse(result.return_value)
+ remove.assert_not_called()
+
+ def test_noninteractive_stdin_errors_with_hint(self):
+ # Closed stdin (no input at all): the selection cannot be made, so error with the
+ # non-interactive form (`cld logout `) and exit non-zero, not a silent no-op.
+ import builtins
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value={"cloud-a": _oauth_url("cloud-a")}), \
+ patch("cloudinary_cli.auth.remove_config_keys") as remove, \
+ patch.object(builtins, "input", side_effect=EOFError()):
+ result = self.runner.invoke(cli, ["logout"], standalone_mode=False)
+ self.assertIn("cld logout ", result.output)
+ self.assertFalse(result.return_value) # main() maps falsy -> exit 1
+ remove.assert_not_called()
+
+
+class TestLoginSetDefault(unittest.TestCase):
+ """`login` sets the default explicitly with --set-default and auto-defaults a sole login."""
+
+ def _patches(self, saved):
+ session = Session(cloud_name="eu-cloud", access_token="a", refresh_token="r",
+ expires_at=int(time.time()) + 300, region="api-eu",
+ issuer="https://oauth.cloudinary.com/")
+ return patch.multiple(
+ "cloudinary_cli.auth",
+ _run_browser_flow=lambda region: session,
+ load_config=lambda: dict(saved),
+ update_config=lambda *a, **k: None,
+ is_env_configured=lambda: False,
+ )
+
+ def test_set_default_flag_marks_default(self):
+ from cloudinary_cli import auth
+ with self._patches({"eu-cloud": _oauth_url(), "other": _oauth_url("other")}), \
+ patch("cloudinary_cli.auth.set_default_config") as set_default, \
+ patch("cloudinary_cli.auth.get_default_config_name", return_value=None):
+ auth.login(region="eu", name="eu-cloud", set_default=True)
+ set_default.assert_called_once_with("eu-cloud")
+
+ def test_auto_default_when_sole_config_no_env_no_default(self):
+ from cloudinary_cli import auth
+ with self._patches({"eu-cloud": _oauth_url()}), \
+ patch("cloudinary_cli.auth.set_default_config") as set_default, \
+ patch("cloudinary_cli.auth.get_default_config_name", return_value=None):
+ name, default_status = auth.login(region="eu", name="eu-cloud")
+ set_default.assert_called_once_with("eu-cloud")
+ self.assertEqual(("eu-cloud", "made"), (name, default_status))
+
+ def test_returns_not_default_when_other_configs_exist(self):
+ from cloudinary_cli import auth
+ with self._patches({"eu-cloud": _oauth_url(), "other": _oauth_url("other")}), \
+ patch("cloudinary_cli.auth.set_default_config"), \
+ patch("cloudinary_cli.auth.get_default_config_name", return_value=None):
+ name, default_status = auth.login(region="eu", name="eu-cloud")
+ self.assertEqual(("eu-cloud", "no"), (name, default_status))
+
+ def test_relogin_into_existing_default_reports_already(self):
+ """Re-login into a config that is already the stored default must NOT set it again and must
+ report "already" so the CLI doesn't tell the user to make it the default."""
+ from cloudinary_cli import auth
+ with self._patches({"eu-cloud": _oauth_url(), "other": _oauth_url("other")}), \
+ patch("cloudinary_cli.auth.set_default_config") as set_default, \
+ patch("cloudinary_cli.auth.get_default_config_name", return_value="eu-cloud"):
+ name, default_status = auth.login(region="eu", name="eu-cloud")
+ set_default.assert_not_called()
+ self.assertEqual(("eu-cloud", "already"), (name, default_status))
+
+ def test_no_auto_default_when_other_configs_exist(self):
+ from cloudinary_cli import auth
+ with self._patches({"eu-cloud": _oauth_url(), "other": _oauth_url("other")}), \
+ patch("cloudinary_cli.auth.set_default_config") as set_default, \
+ patch("cloudinary_cli.auth.get_default_config_name", return_value=None):
+ auth.login(region="eu", name="eu-cloud")
+ set_default.assert_not_called()
+
+ def test_no_auto_default_when_env_configured(self):
+ from cloudinary_cli import auth
+ with self._patches({"eu-cloud": _oauth_url()}), \
+ patch("cloudinary_cli.auth.is_env_configured", return_value=True), \
+ patch("cloudinary_cli.auth.set_default_config") as set_default, \
+ patch("cloudinary_cli.auth.get_default_config_name", return_value=None):
+ auth.login(region="eu", name="eu-cloud")
+ set_default.assert_not_called()
+
+ def test_no_auto_default_when_default_already_stored(self):
+ from cloudinary_cli import auth
+ with self._patches({"eu-cloud": _oauth_url()}), \
+ patch("cloudinary_cli.auth.set_default_config") as set_default, \
+ patch("cloudinary_cli.auth.get_default_config_name", return_value="something"):
+ auth.login(region="eu", name="eu-cloud")
+ set_default.assert_not_called()
+
+ def test_reserved_name_rejected(self):
+ from cloudinary_cli import auth
+ with patch("cloudinary_cli.auth._run_browser_flow"):
+ with self.assertRaises(RuntimeError):
+ auth.login(region="eu", name="__default__")
+
+ def test_cli_message_when_made_default(self):
+ with patch("cloudinary_cli.core.auth.run_login", return_value=("tttt", "made")):
+ result = CliRunner().invoke(cli, ["login", "tttt"])
+ self.assertIn("now the default configuration", result.output)
+
+ def test_cli_message_when_already_default_does_not_suggest_setting_it(self):
+ with patch("cloudinary_cli.core.auth.run_login", return_value=("tttt", "already")):
+ result = CliRunner().invoke(cli, ["login", "tttt"])
+ self.assertIn("This is the default configuration", result.output)
+ self.assertNotIn("config -d tttt", result.output) # don't suggest a no-op
+
+ def test_cli_message_when_not_default_shows_how_to_default(self):
+ with patch("cloudinary_cli.core.auth.run_login", return_value=("tttt", "no")):
+ result = CliRunner().invoke(cli, ["login", "tttt"])
+ self.assertIn("cld -C tttt", result.output)
+ self.assertIn("config -d tttt", result.output)
+ self.assertIn("cld config -d tttt", result.output) # how to make it default
+
+
+class TestConfigSecretMasking(_RestoresSdkConfig):
+ """show_cloudinary_config must never print a secret in the clear."""
+
+ def test_masks_api_secret(self):
+ config = cloudinary.Config()
+ config.update(cloud_name="c", api_key="k", api_secret="abcdefghIJKLMNOP")
+ with patch("cloudinary_cli.utils.config_utils.echo") as echo:
+ show_cloudinary_config(config)
+ out = echo.call_args[0][0]
+ self.assertNotIn("abcdefghIJKLMNOP", out)
+ self.assertIn("MNOP", out) # last 4 kept
+
+ def test_masks_account_url_password(self):
+ config = cloudinary.Config()
+ config.update(account_url="account://acc_key:SUPERSECRETPASSWORD@account_id")
+ with patch("cloudinary_cli.utils.config_utils.echo") as echo:
+ show_cloudinary_config(config)
+ out = echo.call_args[0][0]
+ self.assertNotIn("SUPERSECRETPASSWORD", out)
+ self.assertIn("acc_key", out) # identifier kept
+ self.assertIn("account_id", out) # host kept
+
+ def test_masks_oauth_and_refresh_tokens(self):
+ config = cloudinary.Config()
+ config.update(cloud_name="c", oauth_token="eyJ.secret_access.tok",
+ refresh_token="rt_secret_value")
+ with patch("cloudinary_cli.utils.config_utils.echo") as echo:
+ show_cloudinary_config(config)
+ out = echo.call_args[0][0]
+ self.assertNotIn("eyJ.secret_access.tok", out)
+ self.assertNotIn("rt_secret_value", out)
+
+ def test_mask_is_fixed_width_for_long_secret(self):
+ config = cloudinary.Config()
+ config.update(cloud_name="c", oauth_token="eyJ" + "A" * 2000 + "N2dQ")
+ with patch("cloudinary_cli.utils.config_utils.echo") as echo:
+ show_cloudinary_config(config)
+ out = echo.call_args[0][0]
+ self.assertIn("****N2dQ", out) # fixed prefix + last 4
+ self.assertNotIn("*" * 8, out) # never a wall of asterisks
+
+ def test_hides_empty_fields(self):
+ config = cloudinary.Config()
+ config.update(cloud_name="c", oauth_token="eyJ.tok", api_key=None, api_secret=None)
+ with patch("cloudinary_cli.utils.config_utils.echo") as echo:
+ show_cloudinary_config(config)
+ out = echo.call_args[0][0]
+ self.assertNotIn("api_key", out)
+ self.assertNotIn("api_secret", out)
+ self.assertNotIn("None", out)
+ self.assertIn("cloud_name", out)
+
+ def test_expires_at_human_readable_and_state(self):
+ future = cloudinary.Config()
+ future.update(cloud_name="c", oauth_token="eyJ.tok", expires_at=int(time.time()) + 3600)
+ with patch("cloudinary_cli.utils.config_utils.echo") as echo:
+ show_cloudinary_config(future)
+ out = echo.call_args[0][0]
+ self.assertIn("UTC", out)
+ self.assertIn("valid", out)
+
+ past = cloudinary.Config()
+ past.update(cloud_name="c", oauth_token="eyJ.tok", expires_at=1782310673)
+ with patch("cloudinary_cli.utils.config_utils.echo") as echo:
+ show_cloudinary_config(past)
+ out = echo.call_args[0][0]
+ self.assertIn("1782310673", out) # raw epoch kept
+ self.assertIn("2026-06-24", out) # human-readable date
+ self.assertIn("expired", out)
+
+ def test_issued_at_human_readable_no_state(self):
+ config = cloudinary.Config()
+ config.update(cloud_name="c", oauth_token="eyJ.tok", issued_at=1782310673)
+ with patch("cloudinary_cli.utils.config_utils.echo") as echo:
+ show_cloudinary_config(config)
+ out = echo.call_args[0][0]
+ self.assertIn("1782310673", out) # raw epoch kept
+ self.assertIn("2026-06-24", out) # human-readable date
+ self.assertIn("UTC", out)
+ self.assertNotIn("valid", out) # issuance has no validity state
+ self.assertNotIn("expired", out)
+
+ def test_issued_at_non_numeric_left_as_is(self):
+ config = cloudinary.Config()
+ config.update(cloud_name="c", oauth_token="eyJ.tok", issued_at="not-an-epoch")
+ with patch("cloudinary_cli.utils.config_utils.echo") as echo:
+ show_cloudinary_config(config)
+ out = echo.call_args[0][0]
+ self.assertIn("not-an-epoch", out)
+
+ def test_account_url_shown_as_structured_section(self):
+ config = cloudinary.Config()
+ config.update(cloud_name="c", api_key="k", api_secret="abcdefghIJKLMNOP",
+ account_url="account://acc_key:SUPERSECRETPASSWORD@account_id")
+ with patch("cloudinary_cli.utils.config_utils.echo") as echo:
+ show_cloudinary_config(config)
+ # two echo calls: the main block, then the account (provisioning) section
+ self.assertEqual(2, echo.call_count)
+ main = echo.call_args_list[0][0][0]
+ account = echo.call_args_list[1][0][0]
+ self.assertNotIn("account://", main)
+ # the account URL is decomposed into labeled fields, secret masked
+ self.assertIn("account_id:", account)
+ self.assertIn("provisioning_api_key:", account)
+ self.assertIn("provisioning_api_secret:", account)
+ self.assertIn("acc_key", account)
+ self.assertIn("account_id", account)
+ self.assertNotIn("SUPERSECRETPASSWORD", account)
+ self.assertNotIn("account://", account) # no raw URL string
+
+ def test_malformed_account_url_falls_back_to_raw_line(self):
+ config = cloudinary.Config()
+ config.update(cloud_name="c", account_url="account://garbage")
+ with patch("cloudinary_cli.utils.config_utils.echo") as echo:
+ show_cloudinary_config(config)
+ account = echo.call_args_list[-1][0][0]
+ self.assertIn("account_url: account://garbage", account)
+
+
+class TestOAuthConfigCoexistence(_RestoresSdkConfig):
+ runner = CliRunner()
+
+ CONFIG = {
+ "prod-account": "cloudinary://key:secret@prod_cloud",
+ "eu-cloud": _oauth_url(),
+ }
+
+ def test_ls_shows_both(self):
+ with patch("cloudinary_cli.utils.config_listing.load_config", return_value=dict(self.CONFIG)):
+ result = self.runner.invoke(cli, ['config', '--ls'])
+ self.assertEqual(0, result.exit_code)
+ self.assertIn("prod-account", result.output)
+ self.assertIn("eu-cloud", result.output)
+
+ def test_show_oauth_masks_token(self):
+ with patch("cloudinary_cli.core.config.load_config", return_value=dict(self.CONFIG)):
+ result = self.runner.invoke(cli, ['config', '--show', 'eu-cloud'])
+ self.assertEqual(0, result.exit_code)
+ self.assertIn("eu-cloud", result.output)
+ self.assertNotIn("eyJ.secret_access.tok", result.output)
+ self.assertNotIn("rt_secret_value", result.output)
+
+ def test_select_oauth_login_configures_sdk(self):
+ with patch("cloudinary_cli.utils.config_resolver.load_config", return_value=dict(self.CONFIG)):
+ result = self.runner.invoke(cli, ['-C', 'eu-cloud', 'url', 'sample'])
+ self.assertEqual(0, result.exit_code, result.output)
+ self.assertIn("eu-cloud", result.output)
+
+ def test_show_header_includes_name_type_and_flags(self):
+ cfg = {"__default__": "eu-cloud", "prod-account": "cloudinary://key:secret@prod_cloud",
+ "eu-cloud": _oauth_url()}
+ with patch("cloudinary_cli.core.config.load_config", return_value=dict(cfg)), \
+ patch("cloudinary_cli.utils.config_resolver.load_config", return_value=dict(cfg)), \
+ patch.dict("os.environ", {}, clear=False):
+ for key in ("CLOUDINARY_URL", "CLOUDINARY_CLOUD_NAME", "CLOUDINARY_API_KEY",
+ "CLOUDINARY_API_SECRET"):
+ os.environ.pop(key, None)
+ cloudinary.reset_config()
+ default_active = self.runner.invoke(cli, ['config', '-s', 'eu-cloud'])
+ plain = self.runner.invoke(cli, ['config', '-s', 'prod-account'])
+ self.assertIn("name: eu-cloud (oauth) [default, active]", default_active.output)
+ self.assertIn("name: prod-account (api_key)\n", plain.output) # no flag bracket
+
+ def test_bare_config_header_matches_active(self):
+ cfg = {"__default__": "eu-cloud", "eu-cloud": _oauth_url()}
+ with patch("cloudinary_cli.core.config.load_config", return_value=dict(cfg)), \
+ patch("cloudinary_cli.utils.config_resolver.load_config", return_value=dict(cfg)), \
+ patch.dict("os.environ", {}, clear=False):
+ for key in ("CLOUDINARY_URL", "CLOUDINARY_CLOUD_NAME", "CLOUDINARY_API_KEY",
+ "CLOUDINARY_API_SECRET"):
+ os.environ.pop(key, None)
+ cloudinary.reset_config()
+ bare = self.runner.invoke(cli, ['config'])
+ shown = self.runner.invoke(cli, ['config', '-s', 'eu-cloud'])
+ # bare `config` identifies the active config the same way `-s ` does
+ self.assertIn("name: eu-cloud (oauth) [default, active]", bare.output)
+ self.assertIn("name: eu-cloud (oauth) [default, active]", shown.output)
+
+ def test_bare_config_header_for_command_line_url(self):
+ cfg = {"__default__": "eu-cloud", "eu-cloud": _oauth_url()}
+ with patch("cloudinary_cli.core.config.load_config", return_value=dict(cfg)), \
+ patch("cloudinary_cli.utils.config_resolver.load_config", return_value=dict(cfg)), \
+ patch.dict("os.environ", {}, clear=False):
+ for key in ("CLOUDINARY_URL", "CLOUDINARY_CLOUD_NAME", "CLOUDINARY_API_KEY",
+ "CLOUDINARY_API_SECRET"):
+ os.environ.pop(key, None)
+ cloudinary.reset_config()
+ result = self.runner.invoke(cli, ['-c', 'cloudinary://a:b@cmdcloud', 'config'])
+ self.assertIn("name: (command-line) (api_key) [active]", result.output)
+
+ def _show_json(self, args, cfg):
+ with patch("cloudinary_cli.core.config.load_config", return_value=dict(cfg)), \
+ patch("cloudinary_cli.utils.config_listing.load_config", return_value=dict(cfg)), \
+ patch("cloudinary_cli.utils.config_resolver.load_config", return_value=dict(cfg)), \
+ patch.dict("os.environ", {}, clear=False):
+ for key in ("CLOUDINARY_URL", "CLOUDINARY_CLOUD_NAME", "CLOUDINARY_API_KEY",
+ "CLOUDINARY_API_SECRET"):
+ os.environ.pop(key, None)
+ cloudinary.reset_config()
+ result = self.runner.invoke(cli, args)
+ self.assertEqual(0, result.exit_code, result.output)
+ return json.loads(result.output[result.output.index("{"):])
+
+ def test_show_json_includes_meta_and_masks_secrets(self):
+ cfg = {"__default__": "eu-cloud", "eu-cloud": _oauth_url()}
+ data = self._show_json(['config', '-s', 'eu-cloud', '--json'], cfg)
+ self.assertEqual("eu-cloud", data["name"])
+ self.assertEqual("saved", data["source"])
+ self.assertEqual("oauth", data["type"])
+ self.assertTrue(data["default"])
+ self.assertTrue(data["active"])
+ self.assertEqual("eu-cloud", data["cloud_name"])
+ # secrets masked, never in the clear
+ self.assertNotIn("eyJ.secret_access.tok", json.dumps(data))
+ self.assertNotIn("rt_secret_value", json.dumps(data))
+ # expires_at expanded into a structured object
+ self.assertIn("epoch", data["expires_at"])
+ self.assertIn("expired", data["expires_at"])
+
+ def test_details_expands_issued_at_without_state(self):
+ from cloudinary_cli.utils.config_utils import cloudinary_config_details
+ config = cloudinary.Config()
+ config.update(cloud_name="c", oauth_token="eyJ.tok", issued_at=1782310673)
+ details = cloudinary_config_details(config)
+ self.assertEqual(1782310673, details["issued_at"]["epoch"])
+ self.assertIn("2026-06-24", details["issued_at"]["utc"])
+ self.assertNotIn("expired", details["issued_at"]) # issuance has no validity state
+
+ def test_details_issued_at_non_numeric_left_as_is(self):
+ from cloudinary_cli.utils.config_utils import cloudinary_config_details
+ config = cloudinary.Config()
+ config.update(cloud_name="c", oauth_token="eyJ.tok", issued_at="bogus")
+ details = cloudinary_config_details(config)
+ self.assertEqual("bogus", details["issued_at"])
+
+ def test_bare_config_json_matches_show_json(self):
+ cfg = {"__default__": "eu-cloud", "eu-cloud": _oauth_url()}
+ bare = self._show_json(['config', '--json'], cfg)
+ shown = self._show_json(['config', '-s', 'eu-cloud', '--json'], cfg)
+ self.assertEqual(shown, bare)
+
+ def test_bare_config_json_env_carries_source(self):
+ # a synthetic active source is disambiguated by `source`, matching the -ls -j rows
+ with patch("cloudinary_cli.core.config.load_config", return_value={}), \
+ patch("cloudinary_cli.utils.config_resolver.load_config", return_value={}), \
+ patch.dict("os.environ", {"CLOUDINARY_URL": "cloudinary://ek:es@env_cloud"}, clear=False):
+ cloudinary.reset_config()
+ result = self.runner.invoke(cli, ['config', '--json'])
+ data = json.loads(result.output[result.output.index("{"):])
+ self.assertEqual("(environment)", data["name"])
+ self.assertEqual("env", data["source"])
+
+ def test_config_details_decomposes_account_url(self):
+ from cloudinary_cli.utils.config_utils import cloudinary_config_details
+ config = cloudinary.Config()
+ config.update(cloud_name="c", account_url="account://pk:SUPERSECRETxyz@acc_id")
+ details = cloudinary_config_details(config)
+ self.assertEqual("acc_id", details["account"]["account_id"])
+ self.assertEqual("pk", details["account"]["provisioning_api_key"])
+ self.assertNotIn("SUPERSECRETxyz", json.dumps(details))
+ self.assertTrue(details["account"]["provisioning_api_secret"].endswith("txyz")
+ or details["account"]["provisioning_api_secret"].startswith("****"))
+ self.assertNotIn("account_url", details) # decomposed, not raw
+
+
+class TestDefaultConfigResolution(_RestoresSdkConfig):
+ """Resolution precedence: -c > -C > stored default > environment > unconfigured."""
+
+ runner = CliRunner()
+
+ def _invoke(self, args, saved, env=None):
+ env = dict(env or {})
+ with patch("cloudinary_cli.utils.config_resolver.load_config", return_value=dict(saved)), \
+ patch.dict("os.environ", env, clear=False):
+ for key in ("CLOUDINARY_URL", "CLOUDINARY_CLOUD_NAME", "CLOUDINARY_API_KEY",
+ "CLOUDINARY_API_SECRET"):
+ if key not in env:
+ os.environ.pop(key, None)
+ cloudinary.reset_config()
+ return self.runner.invoke(cli, args)
+
+ def test_stored_default_applies_when_no_explicit_config(self):
+ saved = {"__default__": "eu-cloud", "eu-cloud": _oauth_url()}
+ result = self._invoke(['url', 'sample'], saved=saved)
+ self.assertEqual(0, result.exit_code, result.output)
+ self.assertEqual("eu-cloud", cloudinary.config().cloud_name)
+
+ def test_no_implicit_sole_login_without_default(self):
+ # A single saved login with no stored default no longer auto-applies.
+ saved = {"eu-cloud": _oauth_url()}
+ result = self._invoke(['url', 'sample'], saved=saved)
+ self.assertIn("No Cloudinary configuration found.", result.output)
+ self.assertIsNone(cloudinary.config().cloud_name)
+
+ def test_stored_default_beats_env(self):
+ saved = {"__default__": "eu-cloud", "eu-cloud": _oauth_url()}
+ self._invoke(['url', 'sample'], saved=saved,
+ env={"CLOUDINARY_URL": "cloudinary://key:secret@env_cloud"})
+ self.assertEqual("eu-cloud", cloudinary.config().cloud_name)
+
+ def test_env_applies_when_no_stored_default(self):
+ saved = {"eu-cloud": _oauth_url()}
+ self._invoke(['url', 'sample'], saved=saved,
+ env={"CLOUDINARY_URL": "cloudinary://key:secret@env_cloud"})
+ self.assertEqual("env_cloud", cloudinary.config().cloud_name)
+ self.assertIsNone(cloudinary.config().oauth_token)
+
+ def test_explicit_minus_C_overrides_default(self):
+ saved = {"__default__": "eu-cloud", "eu-cloud": _oauth_url(),
+ "other": "cloudinary://key:secret@other_cloud"}
+ result = self._invoke(['-C', 'other', 'url', 'sample'], saved=saved)
+ self.assertEqual(0, result.exit_code, result.output)
+ self.assertEqual("other_cloud", cloudinary.config().cloud_name)
+ self.assertIsNone(cloudinary.config().oauth_token)
+
+ def test_default_pointing_at_deleted_config_is_ignored(self):
+ saved = {"__default__": "gone"}
+ result = self._invoke(['url', 'sample'], saved=saved)
+ self.assertIn("No Cloudinary configuration found.", result.output)
+
+ def test_inline_url_and_saved_together_errors(self):
+ # A1: -c and -C are mutually exclusive; passing both must error, not silently drop one.
+ saved = {"eu-cloud": _oauth_url()}
+ result = self._invoke(
+ ['-c', 'cloudinary://a:b@inline', '-C', 'eu-cloud', 'url', 'sample'], saved=saved)
+ self.assertEqual(2, result.exit_code)
+ self.assertIn("mutually exclusive", result.output)
+
+
+class TestResolverNoNetworkIO(_RestoresSdkConfig):
+ """Finding 1 regression: resolution never refreshes a stale OAuth token (no network I/O)."""
+
+ runner = CliRunner()
+
+ def _stale_url(self):
+ return to_cloudinary_url(Session(
+ cloud_name="eu-cloud", access_token="eyJ.old.tok", refresh_token="rt_old",
+ expires_at=int(time.time()) - 10, region="api-eu",
+ issuer="https://oauth.cloudinary.com/"))
+
+ def test_resolve_does_not_call_flow_refresh(self):
+ saved = {"__default__": "eu-cloud", "eu-cloud": self._stale_url()}
+ with patch("cloudinary_cli.utils.config_resolver.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh") as refresh, \
+ patch.dict("os.environ", {}, clear=False):
+ for key in ("CLOUDINARY_URL", "CLOUDINARY_CLOUD_NAME", "CLOUDINARY_API_KEY",
+ "CLOUDINARY_API_SECRET"):
+ os.environ.pop(key, None)
+ cloudinary.reset_config()
+ from cloudinary_cli.utils.config_resolver import resolve_cli_config
+ resolve_cli_config()
+ refresh.assert_not_called()
+ # The stale token is loaded as-is (presence check is refresh-free), awaiting a lazy refresh
+ # only when the SDK reads oauth_token at request time.
+ self.assertTrue(cloudinary.config().has_oauth)
+ self.assertEqual("eyJ.old.tok", cloudinary.config().__dict__.get("oauth_token"))
+
+ def test_help_does_not_reach_phase_b(self):
+ with patch("cloudinary_cli.auth.flow.refresh") as refresh:
+ self.runner.invoke(cli, ['--help'])
+ refresh.assert_not_called()
+
+
+class TestSelfRefreshingOAuthToken(_RestoresSdkConfig):
+ """The active OAuth config refreshes its token lazily when the SDK reads oauth_token at request
+ time; presence/type checks (has_oauth) never trigger a refresh."""
+
+ def _stale_url(self):
+ return to_cloudinary_url(Session(
+ cloud_name="eu-cloud", access_token="eyJ.old.tok", refresh_token="rt_old",
+ expires_at=int(time.time()) - 10, region="api-eu",
+ issuer="https://oauth.cloudinary.com/"))
+
+ def test_reading_oauth_token_refreshes_stale_active_login(self):
+ import cloudinary_cli.utils.config_resolver as resolver
+ saved = {"eu-cloud": self._stale_url()}
+ new_token = jwt_access_token(cloud_name="eu-cloud", tag="resolver-new")
+ token_response = {"access_token": new_token, "refresh_token": "rt_new", "expires_in": 300}
+ with patch("cloudinary_cli.utils.config_resolver.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.refresh.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.utils.config_utils.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh", return_value=token_response), \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ resolver.resolve_cli_config(config_saved="eu-cloud")
+ # The read of oauth_token is what triggers the refresh (as the SDK does per request).
+ self.assertEqual(new_token, cloudinary.config().oauth_token)
+
+ def test_presence_check_does_not_refresh(self):
+ """has_oauth (used by type/validity/-ls) must NOT touch the network on a stale token."""
+ import cloudinary_cli.utils.config_resolver as resolver
+ saved = {"eu-cloud": self._stale_url()}
+ with patch("cloudinary_cli.utils.config_resolver.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.utils.config_utils.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh") as refresh:
+ resolver.resolve_cli_config(config_saved="eu-cloud")
+ self.assertTrue(cloudinary.config().has_oauth)
+ refresh.assert_not_called()
+
+ def test_noop_for_inline_url(self):
+ import cloudinary_cli.utils.config_resolver as resolver
+ with patch("cloudinary_cli.auth.flow.refresh") as refresh:
+ resolver.resolve_cli_config(config="cloudinary://key:secret@cloud")
+ _ = cloudinary.config().oauth_token
+ refresh.assert_not_called()
+
+ def test_noop_for_api_key_config(self):
+ import cloudinary_cli.utils.config_resolver as resolver
+ saved = {"mykey": "cloudinary://key:secret@cloud"}
+ with patch("cloudinary_cli.utils.config_resolver.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.utils.config_utils.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh") as refresh:
+ resolver.resolve_cli_config(config_saved="mykey")
+ _ = cloudinary.config().oauth_token
+ refresh.assert_not_called()
+
+
+class TestConfigDefaultCommands(_RestoresSdkConfig):
+ """`cld config` default management: -d, --set-default, --unset-default, -ls marker, -rm cleanup."""
+
+ runner = CliRunner()
+
+ def test_d_marks_existing(self):
+ saved = {"prod": "cloudinary://k:s@prod", "eu-cloud": _oauth_url()}
+ with patch("cloudinary_cli.core.config.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.core.config.set_default_config") as set_default:
+ result = self.runner.invoke(cli, ['config', '-d', 'prod'])
+ self.assertEqual(0, result.exit_code, result.output)
+ set_default.assert_called_once_with("prod")
+ self.assertIn("Default set to 'prod'", result.output)
+
+ def test_d_nonexistent_errors(self):
+ with patch("cloudinary_cli.core.config.load_config", return_value={"prod": "cloudinary://k:s@prod"}):
+ result = self.runner.invoke(cli, ['config', '-d', 'nope'])
+ self.assertEqual(2, result.exit_code)
+ self.assertIn("does not exist", result.output)
+
+ def test_set_default_without_create_flag_errors(self):
+ result = self.runner.invoke(cli, ['config', '--set-default'])
+ self.assertEqual(2, result.exit_code)
+ self.assertIn("requires -n or --from_url", result.output)
+
+ def test_new_with_set_default(self):
+ with patch("cloudinary_cli.core.config.verify_cloudinary_url", return_value=True), \
+ patch("cloudinary_cli.core.config.update_config"), \
+ patch("cloudinary_cli.core.config.set_default_config") as set_default:
+ result = self.runner.invoke(
+ cli, ['config', '-n', 'prod', 'cloudinary://k:s@prod', '--set-default'])
+ self.assertEqual(0, result.exit_code, result.output)
+ set_default.assert_called_once_with("prod")
+ self.assertIn("Default set to 'prod'", result.output)
+
+ def test_set_default_on_failing_url_neither_saves_nor_defaults(self):
+ with patch("cloudinary_cli.core.config.verify_cloudinary_url", return_value=False), \
+ patch("cloudinary_cli.core.config.update_config") as update, \
+ patch("cloudinary_cli.core.config.set_default_config") as set_default:
+ self.runner.invoke(
+ cli, ['config', '-n', 'prod', 'cloudinary://bad', '--set-default'])
+ update.assert_not_called()
+ set_default.assert_not_called()
+
+ def test_unset_default(self):
+ with patch("cloudinary_cli.core.config.load_config", return_value={}), \
+ patch("cloudinary_cli.core.config.clear_default_config") as clear:
+ result = self.runner.invoke(cli, ['config', '--unset-default'])
+ self.assertEqual(0, result.exit_code, result.output)
+ clear.assert_called_once()
+ self.assertIn("cleared", result.output)
+
+ def _run_ls(self, args, saved, env=None):
+ # Both the resolver (Phase A, which records the active config) and the config command read
+ # the same saved dict; env is controlled via os.environ so is_env_configured() is genuine.
+ env = dict(env or {})
+ with patch("cloudinary_cli.core.config.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.utils.config_listing.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.utils.config_resolver.load_config", return_value=dict(saved)), \
+ patch.dict("os.environ", env, clear=False):
+ for key in ("CLOUDINARY_URL", "CLOUDINARY_CLOUD_NAME", "CLOUDINARY_API_KEY",
+ "CLOUDINARY_API_SECRET"):
+ if key not in env:
+ os.environ.pop(key, None)
+ cloudinary.reset_config()
+ return self.runner.invoke(cli, args)
+
+ def _ls_json(self, saved, env=None):
+ result = self._run_ls(['config', '-ls', '--json'], saved, env)
+ self.assertEqual(0, result.exit_code, result.output)
+ return {r["name"]: r for r in json.loads(result.output[result.output.index("["):])}
+
+ def test_ls_table_marks_default(self):
+ saved = {"__default__": "eu-cloud", "prod": "cloudinary://k:s@prod", "eu-cloud": _oauth_url()}
+ result = self._run_ls(['config', '-ls'], saved)
+ self.assertEqual(0, result.exit_code, result.output)
+ for header in ("NAME", "CLOUD", "TYPE", "DEFAULT", "ACTIVE"):
+ self.assertIn(header, result.output)
+ self.assertIn("prod", result.output)
+ self.assertIn("oauth", result.output)
+ self.assertIn("api_key", result.output)
+ # with no env, the stored default is both default and active (two markers)
+ rows = {line.split()[0]: line for line in result.output.splitlines() if line.startswith(("prod", "eu-cloud"))}
+ self.assertEqual(2, rows["eu-cloud"].count("*"))
+ self.assertEqual(0, rows["prod"].count("*"))
+ self.assertNotIn("__default__", result.output)
+
+ def test_ls_json(self):
+ saved = {"__default__": "eu-cloud", "prod": "cloudinary://k:s@prodcloud", "eu-cloud": _oauth_url()}
+ by_name = self._ls_json(saved)
+ self.assertNotIn("__default__", by_name)
+ self.assertEqual("oauth", by_name["eu-cloud"]["type"])
+ self.assertEqual("saved", by_name["eu-cloud"]["source"])
+ self.assertTrue(by_name["eu-cloud"]["default"])
+ self.assertTrue(by_name["eu-cloud"]["active"]) # no env -> stored default is active
+ self.assertEqual("api_key", by_name["prod"]["type"])
+ self.assertEqual("prodcloud", by_name["prod"]["cloud_name"])
+ self.assertFalse(by_name["prod"]["default"])
+ self.assertFalse(by_name["prod"]["active"])
+
+ def test_ls_minus_C_marks_selected_active(self):
+ saved = {"__default__": "eu-cloud", "eu-cloud": _oauth_url(),
+ "test": "cloudinary://k:s@test_cloud"}
+ # an explicit -C selects the active config for this invocation, overriding the default
+ result = self._run_ls(['-C', 'test', 'config', '-ls', '--json'], saved)
+ self.assertEqual(0, result.exit_code, result.output)
+ by_name = {r["name"]: r for r in json.loads(result.output[result.output.index("["):])}
+ self.assertTrue(by_name["test"]["active"])
+ self.assertFalse(by_name["eu-cloud"]["active"]) # not active, but still the stored default
+ self.assertTrue(by_name["eu-cloud"]["default"])
+
+ def test_ls_inline_url_shown_as_active_command_line_row(self):
+ saved = {"__default__": "eu-cloud", "eu-cloud": _oauth_url()}
+ # an inline -c URL is not a saved config, but it is what's active for this invocation
+ result = self._run_ls(['-c', 'cloudinary://a:b@inline_cloud', 'config', '-ls', '--json'], saved)
+ self.assertEqual(0, result.exit_code, result.output)
+ by_name = {r["name"]: r for r in json.loads(result.output[result.output.index("["):])}
+ cmd = by_name["(command-line)"] # synthetic source: parenthesized in both table and JSON
+ self.assertEqual("url", cmd["source"])
+ self.assertEqual("inline_cloud", cmd["cloud_name"])
+ self.assertTrue(cmd["active"])
+ self.assertFalse(cmd["default"])
+ # the stored default is still recorded, but it is not active while -c wins
+ self.assertTrue(by_name["eu-cloud"]["default"])
+ self.assertFalse(by_name["eu-cloud"]["active"])
+
+ _ENV = {"CLOUDINARY_URL": "cloudinary://k:s@env_cloud"}
+
+ def test_ls_env_row_active_when_no_default(self):
+ by_name = self._ls_json({"eu-cloud": _oauth_url()}, env=self._ENV)
+ env = by_name["(environment)"]
+ self.assertEqual("env", env["source"])
+ self.assertEqual("env_cloud", env["cloud_name"])
+ self.assertFalse(env["default"]) # the environment is never the *stored* default
+ self.assertTrue(env["active"]) # active because nothing outranks it
+ self.assertFalse(by_name["eu-cloud"]["active"])
+
+ def test_ls_stored_default_outranks_env_row(self):
+ by_name = self._ls_json({"__default__": "eu-cloud", "eu-cloud": _oauth_url()}, env=self._ENV)
+ env = by_name["(environment)"]
+ self.assertFalse(env["active"]) # stored default outranks the environment
+ # the stored default is both recorded and active
+ self.assertTrue(by_name["eu-cloud"]["default"])
+ self.assertTrue(by_name["eu-cloud"]["active"])
+
+ def test_synthetic_row_name_parenthesized_in_table_and_json(self):
+ # synthetic (environment / command-line) configs read as parenthesized in both views
+ result = self._run_ls(['config', '-ls'], {"eu-cloud": _oauth_url()}, env=dict(self._ENV))
+ self.assertIn("(environment)", result.output)
+ by_name = self._ls_json({"eu-cloud": _oauth_url()}, env=dict(self._ENV))
+ self.assertIn("(environment)", by_name)
+
+ def test_rm_of_default_clears_it(self):
+ with patch("cloudinary_cli.core.config.remove_config_keys", return_value=[]), \
+ patch("cloudinary_cli.core.config.get_default_config_name", return_value="prod"), \
+ patch("cloudinary_cli.core.config.clear_default_config") as clear:
+ result = self.runner.invoke(cli, ['config', '-rm', 'prod'])
+ self.assertEqual(0, result.exit_code, result.output)
+ clear.assert_called_once()
+
+ def test_reserved_name_rejected_on_new(self):
+ result = self.runner.invoke(
+ cli, ['config', '-n', '__default__', 'cloudinary://k:s@c'])
+ self.assertEqual(2, result.exit_code)
+ self.assertIn("reserved", result.output)
+
+ def test_refresh_named_delegates_to_refresh_config(self):
+ with patch("cloudinary_cli.core.config.refresh_config", return_value="refreshed") as rc:
+ result = self.runner.invoke(cli, ['config', '--refresh', 'eu-cloud'])
+ self.assertEqual(0, result.exit_code, result.output)
+ rc.assert_called_once_with("eu-cloud", force=False)
+ self.assertIn("Refreshed 'eu-cloud'", result.output)
+
+ def test_refresh_force_passes_flag(self):
+ with patch("cloudinary_cli.core.config.refresh_config", return_value="refreshed") as rc:
+ self.runner.invoke(cli, ['config', '--refresh', 'eu-cloud', '--force'])
+ rc.assert_called_once_with("eu-cloud", force=True)
+
+ def test_refresh_no_name_uses_active_config(self):
+ with patch("cloudinary_cli.core.config.active_config_name", return_value="active-one"), \
+ patch("cloudinary_cli.core.config.refresh_config", return_value="refreshed") as rc:
+ self.runner.invoke(cli, ['config', '--refresh'])
+ rc.assert_called_once_with("active-one", force=False)
+
+ def test_refresh_unknown_name_errors(self):
+ with patch("cloudinary_cli.core.config.refresh_config", return_value="not_found"):
+ result = self.runner.invoke(cli, ['config', '--refresh', 'ghost'])
+ self.assertEqual(2, result.exit_code)
+ self.assertIn("does not exist", result.output)
+
+ def test_refresh_failed_reports_relogin_with_region(self):
+ with patch("cloudinary_cli.core.config.refresh_config", return_value="failed"), \
+ patch("cloudinary_cli.core.config.relogin_command",
+ return_value="cld login eu-cloud --region api-eu") as relogin:
+ result = self.runner.invoke(cli, ['config', '--refresh', 'eu-cloud'], standalone_mode=False)
+ self.assertFalse(result.return_value) # main() maps falsy -> exit 1
+ relogin.assert_called_once_with("eu-cloud")
+ self.assertIn("cld login eu-cloud --region api-eu", result.output)
+
+ def test_refresh_all_reports_each(self):
+ with patch("cloudinary_cli.core.config.refresh_configs",
+ return_value={"a": "refreshed", "b": "fresh"}) as rc:
+ result = self.runner.invoke(cli, ['config', '--refresh-all'])
+ self.assertEqual(0, result.exit_code, result.output)
+ rc.assert_called_once_with(force=False)
+ self.assertIn("Refreshed 'a'", result.output)
+ self.assertIn("'b' token is still fresh", result.output)
+
+ def test_force_without_refresh_errors(self):
+ result = self.runner.invoke(cli, ['config', '--force'])
+ self.assertEqual(2, result.exit_code)
+ self.assertIn("--force only applies", result.output)
+
+
+class TestConfigToApiKwargs(unittest.TestCase):
+ def _oauth_config(self):
+ config = cloudinary.Config()
+ config._setup_from_parsed_url(config._parse_cloudinary_url(_oauth_url()))
+ return config
+
+ def test_drops_oauth_bookkeeping(self):
+ config = self._oauth_config()
+ kwargs = config_to_api_kwargs(config)
+ for leaked in ("refresh_token", "expires_at", "region", "issuer"):
+ self.assertNotIn(leaked, kwargs)
+ self.assertEqual("eyJ.secret_access.tok", kwargs["oauth_token"])
+ self.assertEqual("eu-cloud", kwargs["cloud_name"])
+
+ def test_config_to_dict_still_faithful(self):
+ full = config_to_dict(self._oauth_config())
+ self.assertIn("refresh_token", full)
+ self.assertIn("region", full)
+
+
+class TestGetCloudinaryConfigOAuth(_RestoresSdkConfig):
+ def _stale_url(self):
+ return to_cloudinary_url(Session(
+ cloud_name="eu-cloud", access_token="eyJ.old.tok", refresh_token="rt_old",
+ expires_at=int(time.time()) - 10, region="api-eu",
+ issuer="https://oauth.cloudinary.com/"))
+
+ def test_refreshes_stale_target_before_use(self):
+ config = {"eu-cloud": self._stale_url()}
+ new_token = jwt_access_token(cloud_name="eu-cloud", tag="target-new")
+ token_response = {"access_token": new_token, "refresh_token": "rt_new", "expires_in": 300}
+ with patch("cloudinary_cli.utils.config_resolver.load_config", return_value=config), \
+ patch("cloudinary_cli.auth.refresh.load_config", return_value=config), \
+ patch("cloudinary_cli.auth.flow.refresh", return_value=token_response), \
+ patch("cloudinary_cli.auth.refresh.update_config"), \
+ patch("cloudinary_cli.utils.config_resolver.ping_cloudinary", return_value=True):
+ target_config = get_cloudinary_config("eu-cloud")
+ self.assertTrue(target_config)
+ self.assertEqual(new_token, target_config.oauth_token)
+
+ def test_ping_receives_sanitized_config(self):
+ config = {"eu-cloud": _oauth_url()}
+ with patch("cloudinary_cli.utils.config_resolver.load_config", return_value=config), \
+ patch("cloudinary_cli.auth.load_config", return_value=config), \
+ patch("cloudinary_cli.utils.config_resolver.ping_cloudinary", return_value=True) as ping:
+ get_cloudinary_config("eu-cloud")
+ ping_kwargs = ping.call_args.kwargs
+ for leaked in ("refresh_token", "expires_at", "region", "issuer"):
+ self.assertNotIn(leaked, ping_kwargs)
+ self.assertEqual("eyJ.secret_access.tok", ping_kwargs["oauth_token"])
diff --git a/test/test_cli_search_api.py b/test/test_cli_search_api.py
index c48e775..a1e282d 100644
--- a/test/test_cli_search_api.py
+++ b/test/test_cli_search_api.py
@@ -4,7 +4,8 @@
from click.testing import CliRunner
from cloudinary_cli.cli import cli
-from test.helper_test import api_response_mock, uploader_response_mock, URLLIB3_REQUEST
+from test.helper_test import api_response_mock, uploader_response_mock, URLLIB3_REQUEST, \
+ CONFIG_PRESENT, REQUIRES_CONFIG
API_MOCK_RESPONSE = api_response_mock()
UPLOAD_MOCK_RESPONSE = uploader_response_mock()
@@ -13,6 +14,7 @@
class TestCLISearchApi(unittest.TestCase):
runner = CliRunner()
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
@patch(URLLIB3_REQUEST)
def test_search(self, mocker):
mocker.return_value = API_MOCK_RESPONSE
@@ -27,6 +29,7 @@ def test_search_fields(self):
self.assertEqual(0, result.exit_code)
self.assertIn('"fields": [\n "url",\n "tags",\n "context"\n ]', result.output)
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
def test_search_url(self):
result = self.runner.invoke(cli, ['search', 'cat', '-c', 'NEXT_CURSOR', '--ttl', '1000', '--url'])
@@ -37,6 +40,7 @@ def test_search_url(self):
self.assertIn('1000', result.output)
self.assertIn('NEXT_CURSOR', result.output)
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
@patch(URLLIB3_REQUEST)
def test_search_folders(self, mocker):
mocker.return_value = API_MOCK_RESPONSE
diff --git a/test/test_cli_url.py b/test/test_cli_url.py
index 08bff9f..8d6511a 100644
--- a/test/test_cli_url.py
+++ b/test/test_cli_url.py
@@ -3,6 +3,7 @@
from click.testing import CliRunner
from cloudinary_cli.cli import cli
+from test.helper_test import CONFIG_PRESENT, REQUIRES_CONFIG
class TestCLIURL(unittest.TestCase):
@@ -14,18 +15,21 @@ def test_url_no_public_id(self):
self.assertEqual(2, result.exit_code)
self.assertIn("Error: Missing argument 'PUBLIC_ID'", result.output)
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
def test_url(self):
result = self.runner.invoke(cli, ['url', 'sample'])
self.assertEqual(0, result.exit_code)
self.assertIn('image/upload/sample', result.output)
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
def test_url_list(self):
result = self.runner.invoke(cli, ['url', 'sample', '--type', 'list'])
self.assertEqual(0, result.exit_code)
self.assertIn('image/list/sample.json', result.output)
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
def test_url_authenticated(self):
result = self.runner.invoke(cli, ['url', 'sample', '--type', 'authenticated'])
diff --git a/test/test_cli_utils.py b/test/test_cli_utils.py
index 77dcfac..5db4e5b 100644
--- a/test/test_cli_utils.py
+++ b/test/test_cli_utils.py
@@ -3,6 +3,7 @@
from click.testing import CliRunner
from cloudinary_cli.cli import cli
+from test.helper_test import CONFIG_PRESENT, REQUIRES_CONFIG
class TestCLIUtils(unittest.TestCase):
@@ -26,6 +27,7 @@ def test_list_utils(self):
for util in self.UTILS:
self.assertIn(util, result.output)
+ @unittest.skipUnless(CONFIG_PRESENT, REQUIRES_CONFIG)
def test_utils_cloudinary_url(self):
result = self.runner.invoke(cli, ['utils', 'cloudinary_url', 'sample'])
diff --git a/test/test_config_cache.py b/test/test_config_cache.py
new file mode 100644
index 0000000..b411875
--- /dev/null
+++ b/test/test_config_cache.py
@@ -0,0 +1,62 @@
+"""The parsed-config cache in load_config(): it skips the re-read+parse when the file is unchanged
+on disk, but must return a fresh copy each call (callers mutate in place), must invalidate on our
+own save, and must reload when a peer rewrites the file (os.replace stamps a new mtime)."""
+import os
+import tempfile
+import unittest
+from unittest.mock import patch
+
+import cloudinary_cli.utils.config_utils as cu
+
+
+class TestLoadConfigCache(unittest.TestCase):
+ def setUp(self):
+ self._dir = tempfile.mkdtemp()
+ self._path = os.path.join(self._dir, "config.json")
+ self._patch = patch.object(cu, "CLOUDINARY_CLI_CONFIG_FILE", self._path)
+ self._patch.start()
+ cu._invalidate_config_cache()
+ self.addCleanup(self._patch.stop)
+ self.addCleanup(cu._invalidate_config_cache)
+
+ def _write(self, text):
+ with open(self._path, "w") as f:
+ f.write(text)
+
+ def test_returns_fresh_copy_so_caller_mutation_does_not_leak(self):
+ self._write('{"a": "cloudinary://k:s@a"}')
+ first = cu.load_config()
+ first["injected"] = "mutated" # callers do cfg.update(...) on the result
+ second = cu.load_config()
+ self.assertNotIn("injected", second) # the cache was not poisoned by the caller's mutation
+
+ def test_cache_hit_skips_reparse_when_unchanged(self):
+ self._write('{"a": "cloudinary://k:s@a"}')
+ cu.load_config() # populates the cache
+ with patch.object(cu, "read_json_from_file") as read:
+ cu.load_config()
+ read.assert_not_called() # served from cache: no second read+parse
+
+ def test_reloads_when_file_changes_on_disk(self):
+ self._write('{"a": "cloudinary://k:s@a"}')
+ self.assertIn("a", cu.load_config())
+ # A peer rewrite changes mtime/size; os.utime forces a distinct mtime even on a fast disk.
+ self._write('{"a": "cloudinary://k:s@a", "b": "cloudinary://k:s@b"}')
+ os.utime(self._path, (1, 1))
+ self.assertIn("b", cu.load_config())
+
+ def test_save_config_invalidates_cache(self):
+ self._write('{"a": "cloudinary://k:s@a"}')
+ cu.load_config() # warm the cache
+ cu.save_config({"a": "cloudinary://k:s@a", "c": "cloudinary://k:s@c"})
+ self.assertIn("c", cu.load_config()) # invalidated -> reloaded our own write
+
+ def test_missing_file_caches_empty_without_error(self):
+ self.assertEqual({}, cu.load_config()) # no file: empty dict, no exception
+ self._write('{"a": "cloudinary://k:s@a"}')
+ os.utime(self._path, (2, 2))
+ self.assertIn("a", cu.load_config()) # appears once the file is created
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_config_concurrency.py b/test/test_config_concurrency.py
new file mode 100644
index 0000000..12bce31
--- /dev/null
+++ b/test/test_config_concurrency.py
@@ -0,0 +1,46 @@
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+# Each worker refreshes config under the cross-process lock; with the lock the read-modify-write
+# is serialized, so disjoint keys from concurrent writers must all survive (no last-writer-wins).
+_WORKER = """
+import os, sys, time
+sys.path.insert(0, {repo!r})
+import cloudinary, cloudinary.api # noqa
+from cloudinary_cli.utils.config_utils import update_config
+key = sys.argv[1]
+update_config({{key: "cloudinary://k:s@" + key}})
+"""
+
+
+class TestConfigConcurrency(unittest.TestCase):
+ def test_concurrent_writers_lose_no_keys(self):
+ tmp = tempfile.mkdtemp()
+ env = dict(os.environ, CLOUDINARY_HOME=tmp)
+ worker = _WORKER.format(repo=REPO_ROOT)
+
+ n = 12
+ procs = [
+ subprocess.Popen([sys.executable, "-c", worker, f"cloud{i}"],
+ env=env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
+ for i in range(n)
+ ]
+ for p in procs:
+ _, err = p.communicate(timeout=60)
+ self.assertEqual(0, p.returncode, err.decode())
+
+ with open(os.path.join(tmp, "config.json")) as f:
+ config = json.load(f) # must be valid JSON (never half-written)
+
+ for i in range(n):
+ self.assertIn(f"cloud{i}", config) # every concurrent write survived
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_config_permissions.py b/test/test_config_permissions.py
new file mode 100644
index 0000000..f1df01a
--- /dev/null
+++ b/test/test_config_permissions.py
@@ -0,0 +1,45 @@
+import json
+import os
+import stat
+import subprocess
+import sys
+import tempfile
+import unittest
+
+REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+# save_config resolves CLOUDINARY_CLI_CONFIG_FILE from CLOUDINARY_HOME at import time, so the
+# write must happen in a subprocess with CLOUDINARY_HOME pointed at a temp dir.
+_WRITER = """
+import sys
+sys.path.insert(0, {repo!r})
+import cloudinary, cloudinary.api # noqa
+from cloudinary_cli.utils.config_utils import save_config
+save_config({{"cloud": "cloudinary://key:secret@cloud?oauth_token=tok&refresh_token=r"}})
+"""
+
+
+@unittest.skipIf(sys.platform == "win32", "POSIX file modes not applicable on Windows")
+class TestConfigPermissions(unittest.TestCase):
+ def test_saved_config_is_owner_only(self):
+ tmp = tempfile.mkdtemp()
+ env = dict(os.environ, CLOUDINARY_HOME=tmp)
+
+ proc = subprocess.run(
+ [sys.executable, "-c", _WRITER.format(repo=REPO_ROOT)],
+ env=env, capture_output=True,
+ )
+ self.assertEqual(0, proc.returncode, proc.stderr.decode())
+
+ config_file = os.path.join(tmp, "config.json")
+ # The file holds api_secret + OAuth tokens, so it must not be group/world readable.
+ mode = stat.S_IMODE(os.stat(config_file).st_mode)
+ self.assertEqual(0o600, mode, f"expected 0600, got {oct(mode)}")
+
+ # Sanity: it's still valid JSON carrying the secret-bearing value.
+ with open(config_file) as f:
+ self.assertIn("oauth_token", json.load(f)["cloud"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_default_config.py b/test/test_default_config.py
new file mode 100644
index 0000000..5cafc89
--- /dev/null
+++ b/test/test_default_config.py
@@ -0,0 +1,57 @@
+import unittest
+from contextlib import contextmanager
+from unittest.mock import patch
+
+import cloudinary_cli.utils.config_utils as config_utils
+
+
+@contextmanager
+def _in_memory_config(initial=None):
+ """Back load_config/save_config with an in-memory dict (no real config.json or lock)."""
+ store = {"cfg": dict(initial or {})}
+
+ def _load():
+ return dict(store["cfg"])
+
+ def _save(cfg):
+ store["cfg"] = dict(cfg)
+
+ @contextmanager
+ def _noop_lock():
+ yield
+
+ with patch("cloudinary_cli.utils.config_utils.load_config", side_effect=_load), \
+ patch("cloudinary_cli.utils.config_utils.save_config", side_effect=_save), \
+ patch("cloudinary_cli.utils.config_utils.config_lock", _noop_lock):
+ yield store
+
+
+class TestDefaultConfigStorage(unittest.TestCase):
+ def test_get_set_clear_round_trip(self):
+ with _in_memory_config({"prod": "cloudinary://k:s@prod"}):
+ self.assertIsNone(config_utils.get_default_config_name())
+ config_utils.set_default_config("prod")
+ self.assertEqual("prod", config_utils.get_default_config_name())
+ config_utils.clear_default_config()
+ self.assertIsNone(config_utils.get_default_config_name())
+
+ def test_user_config_names_filters_reserved_key(self):
+ with _in_memory_config({"prod": "cloudinary://k:s@prod"}):
+ config_utils.set_default_config("prod")
+ self.assertEqual(["prod"], config_utils.user_config_names())
+
+ def test_default_key_present_in_raw_dict_only(self):
+ with _in_memory_config({"a": "cloudinary://k:s@a", "b": "cloudinary://k:s@b"}):
+ config_utils.set_default_config("b")
+ self.assertIn("__default__", config_utils.load_config())
+ self.assertNotIn("__default__", config_utils.user_config_names())
+
+ def test_is_reserved_config_name(self):
+ self.assertTrue(config_utils.is_reserved_config_name("__default__"))
+ self.assertTrue(config_utils.is_reserved_config_name("__foo__"))
+ self.assertFalse(config_utils.is_reserved_config_name("prod"))
+ self.assertFalse(config_utils.is_reserved_config_name("__prod"))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_file_utils.py b/test/test_file_utils.py
index 7dd8f60..d663005 100644
--- a/test/test_file_utils.py
+++ b/test/test_file_utils.py
@@ -1,7 +1,17 @@
+import os
+import stat
+import sys
+import tempfile
import unittest
from pathlib import Path
+from unittest.mock import patch
-from cloudinary_cli.utils.file_utils import get_destination_folder, walk_dir, normalize_file_extension
+from cloudinary_cli.utils.file_utils import (
+ get_destination_folder,
+ walk_dir,
+ normalize_file_extension,
+ atomic_write,
+)
from test.helper_test import RESOURCES_DIR
@@ -33,3 +43,142 @@ def test_normalize_file_extension(self):
"SAMPLE.JPEG": "SAMPLE.jpg",
}.items():
self.assertEqual(expected, normalize_file_extension(value))
+
+
+class AtomicWriteTest(unittest.TestCase):
+ def setUp(self):
+ self.dir = tempfile.mkdtemp()
+ self.path = os.path.join(self.dir, "out.txt")
+
+ def _leftover(self):
+ return [f for f in os.listdir(self.dir) if f != os.path.basename(self.path)]
+
+ def test_writes_content(self):
+ atomic_write(self.path, lambda f: f.write("hello"))
+ with open(self.path) as f:
+ self.assertEqual("hello", f.read())
+
+ def test_overwrite_replaces_contents(self):
+ atomic_write(self.path, lambda f: f.write("old"))
+ atomic_write(self.path, lambda f: f.write("new"))
+ with open(self.path) as f:
+ self.assertEqual("new", f.read())
+
+ def test_leaves_no_temp_files(self):
+ atomic_write(self.path, lambda f: f.write("x"))
+ self.assertEqual([], self._leftover())
+
+ def test_failed_write_removes_temp_and_keeps_original(self):
+ atomic_write(self.path, lambda f: f.write("keep"))
+
+ def boom(f):
+ f.write("partial")
+ raise ValueError("write failed")
+
+ with self.assertRaises(ValueError):
+ atomic_write(self.path, boom)
+
+ with open(self.path) as f:
+ self.assertEqual("keep", f.read())
+ self.assertEqual([], self._leftover())
+
+ def test_missing_target_is_not_created_on_failure(self):
+ with self.assertRaises(ValueError):
+ atomic_write(self.path, lambda f: (_ for _ in ()).throw(ValueError()))
+ self.assertFalse(os.path.exists(self.path))
+ self.assertEqual([], os.listdir(self.dir))
+
+ @unittest.skipIf(sys.platform == "win32", "POSIX file modes not applicable on Windows")
+ def test_normalizes_to_umask_mode(self):
+ # mkstemp creates the temp as 0600; atomic_write must relax it to the umask default
+ # so output files are not silently owner-only.
+ old_umask = os.umask(0o022)
+ try:
+ atomic_write(self.path, lambda f: f.write("x"))
+ finally:
+ os.umask(old_umask)
+ mode = stat.S_IMODE(os.stat(self.path).st_mode)
+ self.assertEqual(0o644, mode)
+
+ @unittest.skipIf(sys.platform == "win32", "POSIX file modes not applicable on Windows")
+ def test_respects_restrictive_umask(self):
+ old_umask = os.umask(0o077)
+ try:
+ atomic_write(self.path, lambda f: f.write("x"))
+ finally:
+ os.umask(old_umask)
+ mode = stat.S_IMODE(os.stat(self.path).st_mode)
+ self.assertEqual(0o600, mode)
+
+ @unittest.skipIf(sys.platform == "win32", "POSIX permission bits")
+ def test_explicit_mode_overrides_umask(self):
+ # A4: with an explicit mode the result is that mode regardless of a permissive umask, so the
+ # config file is never widened to the umask default.
+ old_umask = os.umask(0o000)
+ try:
+ atomic_write(self.path, lambda f: f.write("x"), mode=0o600)
+ finally:
+ os.umask(old_umask)
+ self.assertEqual(0o600, stat.S_IMODE(os.stat(self.path).st_mode))
+
+ @unittest.skipIf(sys.platform == "win32", "POSIX permission bits")
+ def test_explicit_mode_temp_file_never_wider_during_write(self):
+ # The temp file must already carry the final mode before the replace, so there is no instant
+ # at which the destination is world-readable. Capture the temp file's mode at replace time.
+ seen = {}
+ real_replace = os.replace
+
+ def capturing_replace(src, dst):
+ seen["mode"] = stat.S_IMODE(os.stat(src).st_mode)
+ return real_replace(src, dst)
+
+ old_umask = os.umask(0o000)
+ try:
+ with patch("cloudinary_cli.utils.file_utils.os.replace", side_effect=capturing_replace):
+ atomic_write(self.path, lambda f: f.write("x"), mode=0o600)
+ finally:
+ os.umask(old_umask)
+ self.assertEqual(0o600, seen["mode"]) # 0600 on the temp file, before it becomes the target
+
+ def test_writes_to_filename_in_cwd_without_dir(self):
+ # path.dirname("") is "" -> must fall back to "." rather than failing.
+ old_cwd = os.getcwd()
+ os.chdir(self.dir)
+ try:
+ atomic_write("bare.txt", lambda f: f.write("x"))
+ with open("bare.txt") as f:
+ self.assertEqual("x", f.read())
+ finally:
+ os.chdir(old_cwd)
+
+ @unittest.skipIf(sys.platform == "win32", "POSIX directory modes not applicable on Windows")
+ @unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0, "root bypasses permission bits")
+ def test_readonly_directory_raises_and_leaves_nothing(self):
+ # mkstemp needs to create the temp inside the directory, so a read-only directory must
+ # fail loudly rather than silently writing nothing, and must not leave a temp file behind.
+ os.chmod(self.dir, 0o500)
+ try:
+ with self.assertRaises(OSError):
+ atomic_write(self.path, lambda f: f.write("x"))
+ self.assertFalse(os.path.exists(self.path))
+ self.assertEqual([], os.listdir(self.dir))
+ finally:
+ os.chmod(self.dir, 0o700)
+
+ @unittest.skipIf(sys.platform == "win32", "POSIX file modes not applicable on Windows")
+ @unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0, "root bypasses permission bits")
+ def test_overwrites_readonly_target_in_writable_dir(self):
+ # os.replace only needs write permission on the directory, not the target, so atomic_write
+ # can replace a read-only file (where a plain open(file, 'w') would fail) and normalizes
+ # the result to the umask default.
+ old_umask = os.umask(0o022)
+ try:
+ atomic_write(self.path, lambda f: f.write("old"))
+ os.chmod(self.path, 0o400)
+ atomic_write(self.path, lambda f: f.write("new"))
+ finally:
+ os.umask(old_umask)
+ with open(self.path) as f:
+ self.assertEqual("new", f.read())
+ self.assertEqual(0o644, stat.S_IMODE(os.stat(self.path).st_mode))
+ self.assertEqual([], self._leftover())
diff --git a/test/test_json_utils.py b/test/test_json_utils.py
new file mode 100644
index 0000000..381a126
--- /dev/null
+++ b/test/test_json_utils.py
@@ -0,0 +1,145 @@
+import json
+import os
+import sys
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from cloudinary_cli.utils.json_utils import (
+ write_json_to_file,
+ read_json_from_file,
+ update_json_file,
+ print_json,
+)
+
+
+class WriteJsonToFileTest(unittest.TestCase):
+ def setUp(self):
+ self.dir = tempfile.mkdtemp()
+ self.path = os.path.join(self.dir, "config.json")
+
+ def _leftover(self):
+ return [f for f in os.listdir(self.dir) if f != "config.json"]
+
+ @unittest.skipIf(sys.platform == "win32", "POSIX directory modes not applicable on Windows")
+ @unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0, "root bypasses permission bits")
+ def test_readonly_directory_raises_in_both_modes(self):
+ os.chmod(self.dir, 0o500)
+ try:
+ with self.assertRaises(OSError):
+ write_json_to_file({"a": 1}, self.path, atomic=True)
+ with self.assertRaises(OSError):
+ write_json_to_file({"a": 1}, self.path, atomic=False)
+ self.assertFalse(os.path.exists(self.path))
+ self.assertEqual([], os.listdir(self.dir))
+ finally:
+ os.chmod(self.dir, 0o700)
+
+ def test_writes_valid_json(self):
+ write_json_to_file({"a": 1, "b": "two"}, self.path)
+ self.assertEqual({"a": 1, "b": "two"}, read_json_from_file(self.path))
+
+ def test_overwrite_replaces_contents(self):
+ write_json_to_file({"old": True}, self.path)
+ write_json_to_file({"new": True}, self.path)
+ self.assertEqual({"new": True}, read_json_from_file(self.path))
+
+ def test_respects_indent_and_sort_keys(self):
+ write_json_to_file({"b": 1, "a": 2}, self.path, indent=2, sort_keys=True)
+ with open(self.path) as f:
+ content = f.read()
+ self.assertEqual('{\n "a": 2,\n "b": 1\n}', content)
+
+ def test_atomic_writes_valid_json(self):
+ write_json_to_file({"a": 1}, self.path, atomic=True)
+ self.assertEqual({"a": 1}, read_json_from_file(self.path))
+
+ def test_atomic_leaves_no_temp_files(self):
+ write_json_to_file({"a": 1}, self.path, atomic=True)
+ self.assertEqual([], self._leftover())
+
+ def test_atomic_failed_write_removes_temp_and_keeps_original(self):
+ write_json_to_file({"keep": True}, self.path, atomic=True)
+ # An unserializable object makes json.dump raise mid-write.
+ with self.assertRaises(TypeError):
+ write_json_to_file({"bad": object()}, self.path, atomic=True)
+ self.assertEqual({"keep": True}, read_json_from_file(self.path))
+ self.assertEqual([], self._leftover())
+
+ def test_non_atomic_is_default(self):
+ # The non-atomic path writes in place: open('w') truncates the target up front, so a
+ # mid-write failure leaves a corrupted file. This documents why atomic=True exists and
+ # must be opted into for files that matter (config, sync meta).
+ write_json_to_file({"keep": True}, self.path)
+ with self.assertRaises(TypeError):
+ write_json_to_file({"bad": object()}, self.path)
+ with self.assertRaises(ValueError): # JSONDecodeError on the partial write
+ read_json_from_file(self.path)
+
+
+class UpdateJsonFileTest(unittest.TestCase):
+ def setUp(self):
+ self.dir = tempfile.mkdtemp()
+ self.path = os.path.join(self.dir, "data.json")
+
+ def test_creates_file_when_missing(self):
+ update_json_file({"a": 1}, self.path)
+ self.assertEqual({"a": 1}, read_json_from_file(self.path))
+
+ def test_merges_into_existing(self):
+ write_json_to_file({"a": 1, "b": 2}, self.path)
+ update_json_file({"b": 20, "c": 3}, self.path)
+ self.assertEqual({"a": 1, "b": 20, "c": 3}, read_json_from_file(self.path))
+
+ def test_atomic_flag_merges_and_leaves_no_temp(self):
+ write_json_to_file({"a": 1}, self.path)
+ update_json_file({"b": 2}, self.path, atomic=True)
+ self.assertEqual({"a": 1, "b": 2}, read_json_from_file(self.path))
+ leftover = [f for f in os.listdir(self.dir) if f != "data.json"]
+ self.assertEqual([], leftover)
+
+
+class PrintJsonColorTest(unittest.TestCase):
+ """print_json must emit plain (parseable) JSON when stdout is not a terminal, so piped/captured
+ output (LLM agents, `| jq`, redirects) is never corrupted by ANSI color escapes."""
+
+ DATA = {"a": 1, "b": "two", "nested": {"c": True}}
+
+ def _captured(self):
+ with patch("cloudinary_cli.utils.json_utils.click.echo") as echo:
+ print_json(self.DATA)
+ return echo.call_args[0][0]
+
+ def test_non_tty_is_plain_parseable_json(self):
+ with patch("cloudinary_cli.utils.json_utils.sys.stdout.isatty", return_value=False):
+ out = self._captured()
+ self.assertNotIn("\x1b[", out) # no ANSI escapes
+ self.assertEqual(self.DATA, json.loads(out)) # round-trips cleanly
+
+ def test_tty_is_colorized(self):
+ with patch("cloudinary_cli.utils.json_utils.sys.stdout.isatty", return_value=True):
+ out = self._captured()
+ self.assertIn("\x1b[", out) # colorized for an interactive terminal
+
+ def test_non_tty_is_plain_on_windows_too(self):
+ # The automation guarantee must hold on every OS: a non-terminal stdout yields plain JSON
+ # regardless of platform (the old code special-cased Windows; the branch is OS-independent now).
+ with patch("cloudinary_cli.utils.json_utils.sys.platform", "win32"), \
+ patch("cloudinary_cli.utils.json_utils.sys.stdout.isatty", return_value=False):
+ out = self._captured()
+ self.assertNotIn("\x1b[", out)
+ self.assertEqual(self.DATA, json.loads(out))
+
+ def test_colorization_is_decided_by_isatty_not_os(self):
+ # Same isatty() value -> same colorization decision under any reported platform, so Windows
+ # interactive consoles get color (click.echo translates ANSI) and Windows pipes stay plain.
+ for plat in ("win32", "darwin", "linux"):
+ with patch("cloudinary_cli.utils.json_utils.sys.platform", plat):
+ with patch("cloudinary_cli.utils.json_utils.sys.stdout.isatty", return_value=True):
+ self.assertIn("\x1b[", self._captured(), f"expected color on tty ({plat})")
+ with patch("cloudinary_cli.utils.json_utils.sys.stdout.isatty", return_value=False):
+ self.assertNotIn("\x1b[", self._captured(), f"expected plain off tty ({plat})")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_modules/test_cli_clone.py b/test/test_modules/test_cli_clone.py
index a4843b1..7abe383 100644
--- a/test/test_modules/test_cli_clone.py
+++ b/test/test_modules/test_cli_clone.py
@@ -313,5 +313,38 @@ def test_process_metadata_restricted_raw_asset_with_auth_token(self, mock_cloudi
self.assertEqual(url, ('https://res.cloudinary.com/demo/raw/upload/s--XyZaBcDeF--/sample_document', {}))
+class TestCloneOAuthTarget(unittest.TestCase):
+ def _oauth_target_config(self):
+ import cloudinary
+ from cloudinary_cli.auth.session import Session, to_cloudinary_url
+ import time
+ session = Session(
+ cloud_name="target_cloud", access_token="eyJ.access.tok",
+ refresh_token="rt", expires_at=int(time.time()) + 3600,
+ region="api-eu", issuer="https://oauth.cloudinary.com/")
+ config = cloudinary.Config()
+ config._setup_from_parsed_url(config._parse_cloudinary_url(to_cloudinary_url(session)))
+ return config
+
+ def test_upload_list_drops_oauth_bookkeeping(self):
+ source_assets = {
+ 'resources': [{
+ 'public_id': 'sample', 'type': 'upload', 'resource_type': 'image',
+ 'format': 'jpg',
+ 'secure_url': 'https://res.cloudinary.com/demo/image/upload/v1/sample.jpg',
+ }]
+ }
+ upload_list = clone_module._prepare_upload_list(
+ source_assets, self._oauth_target_config(), overwrite=False,
+ async_=False, notification_url=None, auth_token=None,
+ url_expiry=3600, fields=())
+
+ _, options = upload_list[0]
+ for leaked in ("refresh_token", "expires_at", "region", "issuer"):
+ self.assertNotIn(leaked, options)
+ self.assertEqual("eyJ.access.tok", options["oauth_token"])
+ self.assertEqual("target_cloud", options["cloud_name"])
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/test/test_modules/test_cli_sync.py b/test/test_modules/test_cli_sync.py
index 9d785ba..a3f7a25 100644
--- a/test/test_modules/test_cli_sync.py
+++ b/test/test_modules/test_cli_sync.py
@@ -43,6 +43,9 @@ def test_cli_sync_push(self):
self.assertEqual(0, result.exit_code)
self.assertIn("Synced | 12", result.output)
self.assertIn("Done!", result.output)
+ # the upload banner names both the destination folder and the active cloud
+ self.assertIn(f"to Cloudinary folder '{self.CLD_SYNC_DIR}'", result.output)
+ self.assertIn("in cloud '", result.output)
def test_cli_sync_push_non_existing_folder(self):
non_existing_dir = self.LOCAL_SYNC_PULL_DIR + "non_existing"
diff --git a/test/test_oauth_multiprocess.py b/test/test_oauth_multiprocess.py
new file mode 100644
index 0000000..3da224a
--- /dev/null
+++ b/test/test_oauth_multiprocess.py
@@ -0,0 +1,211 @@
+"""Cross-process OAuth refresh safety: N processes sharing one config.json rotate a single-use
+refresh token at most once, mediated by the FileLock and adopt-on-disk check in refresh_url_if_stale.
+Workers read oauth_token against a shared token server that counts refresh-token consumption."""
+import json
+import multiprocessing
+import os
+import tempfile
+import time
+import unittest
+from http.server import BaseHTTPRequestHandler, HTTPServer
+from threading import Thread
+from urllib.parse import parse_qs
+
+from test.oauth_helpers import jwt_access_token
+
+
+def _make_token(tag):
+ # Pinned iat/exp so the same tag yields a byte-identical token for cross-process comparison.
+ return jwt_access_token(cloud_name="proc-cloud", iat=1_700_000_000, exp=2_000_000_000, tag=tag)
+
+
+class _RotatingTokenServer(BaseHTTPRequestHandler):
+ """Stand-in auth server: each refresh consumes the presented (single-use) token and mints a new
+ pair; an already-consumed token yields 400. State in class attrs (one server, one process)."""
+
+ valid_refresh = None
+ generation = 0
+ refresh_calls = 0
+ rejected_calls = 0
+
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ length = int(self.headers.get("Content-Length", 0))
+ body = self.rfile.read(length).decode()
+ params = {k: v[0] for k, v in parse_qs(body).items()}
+ cls = type(self)
+ presented = params.get("refresh_token")
+ cls.refresh_calls += 1
+ if presented != cls.valid_refresh:
+ cls.rejected_calls += 1
+ self.send_response(400)
+ self.end_headers()
+ self.wfile.write(b'{"error":"invalid_grant"}')
+ return
+ cls.generation += 1
+ cls.valid_refresh = f"rt_gen{cls.generation}"
+ resp = {
+ "access_token": _make_token(f"gen{cls.generation}"),
+ "refresh_token": cls.valid_refresh,
+ "expires_in": 300,
+ }
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.end_headers()
+ self.wfile.write(json.dumps(resp).encode())
+
+
+def _worker(home, token_url, barrier_path, idx, out):
+ # Point the CLI at the shared config + fake token endpoint, wait at the barrier, then read oauth_token.
+ os.environ["CLOUDINARY_HOME"] = home
+ for k in list(os.environ):
+ if k.startswith("CLOUDINARY_") and k != "CLOUDINARY_HOME":
+ del os.environ[k]
+
+ import cloudinary
+ from cloudinary_cli.utils.config_utils import load_config
+ from cloudinary_cli.auth.oauth_config import install_oauth_config
+ import cloudinary_cli.auth.flow as flow_mod
+
+ # flow.py imported the URL helper into its own namespace; redirect that binding to the fake server.
+ flow_mod.oauth_token_url_for_region = lambda region: token_url
+
+ url = load_config()["proc-cloud"]
+ install_oauth_config(url, saved_name="proc-cloud")
+
+ # cross-process barrier: drop a file, then spin until all are present
+ open(os.path.join(barrier_path, f"ready-{idx}"), "w").close()
+ deadline = time.time() + 10
+ while len(os.listdir(barrier_path)) < out["n"] and time.time() < deadline:
+ time.sleep(0.005)
+
+ try:
+ token = cloudinary.config().oauth_token
+ out[idx] = token
+ except Exception as e: # noqa: BLE001
+ out[idx] = f"ERROR:{e}"
+
+
+def _worker_401(home, token_url, barrier_path, idx, out):
+ # Like _worker, but starts clock-fresh and gets one 401: call_api's retry must converge on one rotation.
+ os.environ["CLOUDINARY_HOME"] = home
+ for k in list(os.environ):
+ if k.startswith("CLOUDINARY_") and k != "CLOUDINARY_HOME":
+ del os.environ[k]
+
+ import cloudinary
+ from cloudinary.exceptions import AuthorizationRequired
+ from cloudinary_cli.utils.config_utils import load_config
+ from cloudinary_cli.utils.api_utils import call_api
+ from cloudinary_cli.auth.oauth_config import install_oauth_config
+ import cloudinary_cli.auth.flow as flow_mod
+
+ flow_mod.oauth_token_url_for_region = lambda region: token_url
+
+ url = load_config()["proc-cloud"]
+ install_oauth_config(url, saved_name="proc-cloud")
+
+ open(os.path.join(barrier_path, f"ready-{idx}"), "w").close()
+ deadline = time.time() + 10
+ while len(os.listdir(barrier_path)) < out["n"] and time.time() < deadline:
+ time.sleep(0.005)
+
+ state = {"n": 0}
+
+ def api_call(*a, **k):
+ # Reject the original token once, accept any other.
+ state["n"] += 1
+ token = cloudinary.config().oauth_token
+ if state["n"] == 1 and token == _make_token("gen0"):
+ raise AuthorizationRequired("Invalid token [expired]")
+ return {"ok": token}
+
+ try:
+ out[idx] = call_api(api_call)["ok"]
+ except Exception as e: # noqa: BLE001
+ out[idx] = f"ERROR:{e}"
+
+
+class TestCrossProcessSingleFlight(unittest.TestCase):
+ def setUp(self):
+ self.home = tempfile.mkdtemp(prefix="cld-mp-home-")
+ self.barrier = tempfile.mkdtemp(prefix="cld-mp-barrier-")
+ _RotatingTokenServer.valid_refresh = "rt_gen0"
+ _RotatingTokenServer.generation = 0
+ _RotatingTokenServer.refresh_calls = 0
+ _RotatingTokenServer.rejected_calls = 0
+ self.server = HTTPServer(("127.0.0.1", 0), _RotatingTokenServer)
+ self.port = self.server.server_address[1]
+ self.thread = Thread(target=self.server.serve_forever, daemon=True)
+ self.thread.start()
+
+ def tearDown(self):
+ self.server.shutdown()
+ import shutil
+ shutil.rmtree(self.home, ignore_errors=True)
+ shutil.rmtree(self.barrier, ignore_errors=True)
+
+ def _write_config(self, expires_delta):
+ from cloudinary_cli.auth.session import Session, to_cloudinary_url
+ os.makedirs(self.home, exist_ok=True)
+ sess = Session(
+ cloud_name="proc-cloud", access_token=_make_token("gen0"),
+ refresh_token="rt_gen0", expires_at=int(time.time()) + expires_delta, region="api",
+ issuer="https://oauth.cloudinary.com/")
+ with open(os.path.join(self.home, "config.json"), "w") as f:
+ json.dump({"proc-cloud": to_cloudinary_url(sess)}, f)
+
+ def _write_stale_config(self):
+ self._write_config(expires_delta=-10)
+
+ def _run_workers(self, worker, n=6):
+ # Spawn so workers re-import and honor CLOUDINARY_HOME; fork would inherit the parent's
+ # frozen config path and miss "proc-cloud".
+ ctx = multiprocessing.get_context("spawn")
+ token_url = f"http://127.0.0.1:{self.port}/oauth2/token"
+ mgr = ctx.Manager()
+ out = mgr.dict()
+ out["n"] = n
+ procs = [ctx.Process(target=worker,
+ args=(self.home, token_url, self.barrier, i, out))
+ for i in range(n)]
+ for p in procs:
+ p.start()
+ for p in procs:
+ p.join(30)
+ results = {i: out[i] for i in range(n)}
+ errors = {i: v for i, v in results.items() if isinstance(v, str) and v.startswith("ERROR")}
+ self.assertEqual({}, errors, f"workers errored: {errors}")
+ return results
+
+ def test_n_processes_consume_single_use_refresh_token_once(self):
+ # Proactive path: all processes start on the same stale token and read oauth_token together.
+ self._write_stale_config()
+ results = self._run_workers(_worker)
+
+ self.assertEqual(0, _RotatingTokenServer.rejected_calls,
+ "a process presented an already-consumed refresh token (rotation cascade)")
+ self.assertEqual(1, _RotatingTokenServer.generation,
+ "the single-use refresh token rotated more than once across processes")
+ distinct = set(results.values())
+ self.assertEqual(1, len(distinct), f"workers disagreed on the token: {distinct}")
+ self.assertEqual(_make_token("gen1"), next(iter(distinct)))
+
+ def test_n_processes_recover_from_401_with_single_rotation(self):
+ # Reactive path: all processes start clock-fresh, each gets one 401; retry converges on one rotation.
+ self._write_config(expires_delta=300)
+ results = self._run_workers(_worker_401)
+
+ self.assertEqual(0, _RotatingTokenServer.rejected_calls,
+ "a process presented an already-consumed refresh token (rotation cascade)")
+ self.assertEqual(1, _RotatingTokenServer.generation,
+ "a 401 on a clock-fresh token rotated more than once across processes")
+ distinct = set(results.values())
+ self.assertEqual({_make_token("gen1")}, distinct,
+ f"workers did not all recover onto the rotated token: {distinct}")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_oauth_retry.py b/test/test_oauth_retry.py
new file mode 100644
index 0000000..7f00b4b
--- /dev/null
+++ b/test/test_oauth_retry.py
@@ -0,0 +1,295 @@
+"""In-process single-flight refresh and 401 retry/adopt behavior for the OAuth token seam."""
+import threading
+import time
+import unittest
+from unittest.mock import patch, MagicMock
+
+import cloudinary
+from cloudinary.exceptions import AuthorizationRequired
+
+from cloudinary_cli.auth.oauth_config import OAuthConfig, install_oauth_config, install_env_config
+from cloudinary_cli.auth.session import Session, to_cloudinary_url, from_cloudinary_url
+from cloudinary_cli.utils.api_utils import call_api
+
+from test.oauth_helpers import jwt_access_token
+
+
+def _url(cloud="eu-cloud", token="eyJ.tok", refresh="rt", region="api-eu", expires_delta=300):
+ return to_cloudinary_url(Session(
+ cloud_name=cloud, access_token=token, refresh_token=refresh,
+ expires_at=int(time.time()) + expires_delta, region=region,
+ issuer="https://oauth.cloudinary.com/"))
+
+
+class _RestoresSdkConfig(unittest.TestCase):
+ def setUp(self):
+ import os
+ self._env_snapshot = dict(os.environ)
+ for key in [k for k in os.environ if k.startswith("CLOUDINARY_")]:
+ del os.environ[key]
+ cloudinary.reset_config()
+ self.addCleanup(self._restore)
+
+ def _restore(self):
+ import os
+ os.environ.clear()
+ os.environ.update(self._env_snapshot)
+ cloudinary.reset_config()
+
+
+class TestSingleFlightRefresh(_RestoresSdkConfig):
+ """Concurrent stale reads refresh once under the in-process lock; losers adopt the result."""
+
+ def test_concurrent_stale_reads_refresh_once(self):
+ saved = {"eu-cloud": _url(token="eyJ.old", refresh="rt_old", expires_delta=-10)}
+ new_token = jwt_access_token(cloud_name="eu-cloud", tag="single-flight-new")
+ token_response = {"access_token": new_token, "refresh_token": "rt_new", "expires_in": 300}
+
+ def slow_refresh(refresh_token, region):
+ time.sleep(0.02) # widen the window so threads pile on the lock
+ return dict(token_response)
+
+ with patch("cloudinary_cli.utils.config_utils.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.refresh.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh", side_effect=slow_refresh) as refresh, \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ install_oauth_config(saved["eu-cloud"], saved_name="eu-cloud")
+ config = cloudinary.config()
+
+ results = []
+
+ def read_token():
+ results.append(config.oauth_token)
+
+ threads = [threading.Thread(target=read_token) for _ in range(20)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ refresh.assert_called_once() # one rotation, not 20
+ self.assertEqual([new_token] * 20, results)
+
+
+class TestRetryOn401(_RestoresSdkConfig):
+ """call_api marks the token invalid on AuthorizationRequired and retries via the refresh seam."""
+
+ def test_retries_after_peer_rotation_and_succeeds(self):
+ # Config holds the rejected token but a peer wrote a new one: retry adopts it, no rotation.
+ install_oauth_config(_url(token="eyJ.old", refresh="rt_old", expires_delta=300),
+ saved_name="eu-cloud")
+ saved = {"eu-cloud": _url(token="eyJ.new", refresh="rt_new", expires_delta=300)}
+ calls = {"n": 0}
+
+ def func(*a, **k):
+ calls["n"] += 1
+ if calls["n"] == 1:
+ raise AuthorizationRequired("Invalid token [expired]")
+ return {"public_id": "ok"}
+
+ with patch("cloudinary_cli.utils.config_utils.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.refresh.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh") as refresh, \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ result = call_api(func, "file.mp4")
+
+ self.assertEqual({"public_id": "ok"}, result)
+ self.assertEqual(2, calls["n"]) # original + one retry
+ refresh.assert_not_called() # adopted peer's token, no rotation
+ self.assertEqual("eyJ.new", cloudinary.config().oauth_token)
+
+ def test_401_on_clock_fresh_token_forces_one_refresh_then_succeeds(self):
+ # No peer rotated: the rejected token is still clock-fresh on disk, so the retry forces one rotation.
+ install_oauth_config(_url(token="eyJ.old", refresh="rt_old", expires_delta=300),
+ saved_name="eu-cloud")
+ saved = {"eu-cloud": _url(token="eyJ.old", refresh="rt_old", expires_delta=300)}
+ new_token = jwt_access_token(cloud_name="eu-cloud", tag="clock-fresh-new")
+ calls = {"n": 0}
+
+ def func(*a, **k):
+ calls["n"] += 1
+ if calls["n"] == 1:
+ raise AuthorizationRequired("Invalid token [expired]")
+ return {"public_id": "ok"}
+
+ with patch("cloudinary_cli.utils.config_utils.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.refresh.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh",
+ return_value={"access_token": new_token, "refresh_token": "rt_new",
+ "expires_in": 300}) as refresh, \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ result = call_api(func, "file.mp4")
+
+ self.assertEqual({"public_id": "ok"}, result)
+ self.assertEqual(2, calls["n"])
+ refresh.assert_called_once() # forced past the clock by the 401
+ self.assertEqual(new_token, cloudinary.config().oauth_token)
+
+ def test_revoked_token_fails_fast(self):
+ # flow.refresh fails too: no new token to adopt, so propagate after the first attempt.
+ import requests
+ install_oauth_config(_url(token="eyJ.old", refresh="rt_old", expires_delta=300),
+ saved_name="eu-cloud")
+ saved = {"eu-cloud": _url(token="eyJ.old", refresh="rt_old", expires_delta=300)}
+ calls = {"n": 0}
+
+ def func(*a, **k):
+ calls["n"] += 1
+ raise AuthorizationRequired("Invalid token [expired]")
+
+ with patch("cloudinary_cli.utils.config_utils.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.refresh.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh",
+ side_effect=requests.RequestException("refresh token revoked")), \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ with self.assertRaises(AuthorizationRequired):
+ call_api(func, "file.mp4")
+
+ self.assertEqual(1, calls["n"]) # nothing to adopt -> fail fast
+
+ def test_one_refresh_and_retry_then_propagates(self):
+ # Refresh succeeds (new token) but the server rejects it too: one retry, then propagate.
+ install_oauth_config(_url(token="eyJ.t0", refresh="rt0", expires_delta=300),
+ saved_name="eu-cloud")
+ saved = {"eu-cloud": _url(token="eyJ.t0", refresh="rt0", expires_delta=300)}
+ calls = {"n": 0}
+
+ def func(*a, **k):
+ calls["n"] += 1
+ raise AuthorizationRequired("Invalid token [expired]")
+
+ def ever_new_token(refresh_token, region):
+ return {"access_token": jwt_access_token(cloud_name="eu-cloud", tag=f"t{calls['n']}"),
+ "refresh_token": f"rt{calls['n']}", "expires_in": 300}
+
+ with patch("cloudinary_cli.utils.config_utils.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.refresh.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh", side_effect=ever_new_token), \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ with self.assertRaises(AuthorizationRequired):
+ call_api(func, "file.mp4")
+
+ self.assertEqual(2, calls["n"]) # original + one retry, no unbounded rotation
+
+ def test_non_oauth_config_propagates_immediately(self):
+ install_oauth_config("cloudinary://key:secret@cloud", saved_name=None) # api-key: has_oauth False
+ calls = {"n": 0}
+
+ def func(*a, **k):
+ calls["n"] += 1
+ raise AuthorizationRequired("nope")
+
+ with self.assertRaises(AuthorizationRequired):
+ call_api(func, "x")
+ self.assertEqual(1, calls["n"]) # no adopt attempt on a non-OAuth config
+
+ def test_env_config_propagates_immediately(self):
+ import os
+ os.environ["CLOUDINARY_URL"] = (
+ "cloudinary://env_cloud?oauth_token=eyJ.env&refresh_token=rt&"
+ f"expires_at={int(time.time()) - 10}®ion=api")
+ cloudinary.reset_config()
+ install_env_config() # static: _saved_name is None -> invalidate_token returns False
+ calls = {"n": 0}
+
+ def func(*a, **k):
+ calls["n"] += 1
+ raise AuthorizationRequired("expired")
+
+ with self.assertRaises(AuthorizationRequired):
+ call_api(func, "x")
+ self.assertEqual(1, calls["n"])
+
+ def test_success_passes_through_without_refresh(self):
+ install_oauth_config(_url(), saved_name="eu-cloud")
+ sentinel = MagicMock(return_value={"public_id": "p"})
+ result = call_api(sentinel, "file", folder="f")
+ self.assertEqual({"public_id": "p"}, result)
+ # no retry; args forwarded verbatim with the active token pinned so the wire token == rejected
+ sentinel.assert_called_once_with("file", folder="f", oauth_token="eyJ.tok")
+
+ def test_token_pinned_so_wire_token_equals_invalidate_arg(self):
+ # The token sent to the SDK and the token handed to invalidate_token must be identical, even if
+ # a peer rotates the config between our read and the SDK's own read. Pinning closes that gap.
+ config = install_oauth_config(_url(token="eyJ.pin", refresh="rt", expires_delta=300),
+ saved_name="eu-cloud")
+ sent = {}
+
+ def func(*a, **k):
+ sent["oauth_token"] = k.get("oauth_token")
+ raise AuthorizationRequired("Invalid token [expired]")
+
+ seen = {}
+
+ def spy(rejected):
+ seen["rejected"] = rejected
+ return False # stop after one attempt; we only care about the pinned value
+
+ with patch.object(config, "invalidate_token", side_effect=spy):
+ with self.assertRaises(AuthorizationRequired):
+ call_api(func, "file.mp4")
+
+ self.assertEqual("eyJ.pin", sent["oauth_token"]) # the value the SDK would send
+ self.assertEqual(sent["oauth_token"], seen["rejected"]) # == what invalidate_token is told
+
+
+class TestRefreshDecision(_RestoresSdkConfig):
+ """refresh_url_if_stale's rotate-vs-adopt rule for `force`, `expected`, and the proactive sweep."""
+
+ def _refresh(self, **kwargs):
+ from cloudinary_cli.auth import refresh_url_if_stale
+ return refresh_url_if_stale("eu-cloud", self.url, **kwargs)
+
+ def test_expected_matches_disk_rotates_even_when_clock_fresh(self):
+ # 401 path: token clock-fresh but rejected; disk still holds it -> rotate once.
+ self.url = _url(token="eyJ.cur", refresh="rt", expires_delta=300)
+ saved = {"eu-cloud": self.url}
+ new_token = jwt_access_token(cloud_name="eu-cloud", tag="expected-matches-new")
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh",
+ return_value={"access_token": new_token, "refresh_token": "rt2",
+ "expires_in": 300}) as refresh, \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ new_url = self._refresh(expected="eyJ.cur")
+ refresh.assert_called_once()
+ self.assertEqual(new_token, from_cloudinary_url(new_url).access_token)
+
+ def test_expected_differs_from_disk_adopts_without_refresh(self):
+ # Peer already rotated: disk token != expected -> adopt, no network.
+ self.url = _url(token="eyJ.new", refresh="rt2", expires_delta=300) # what disk now holds
+ saved = {"eu-cloud": self.url}
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh") as refresh, \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ new_url = self._refresh(expected="eyJ.old") # we were sent the OLD token
+ refresh.assert_not_called()
+ self.assertEqual(self.url, new_url)
+
+ def test_force_refreshes_a_clock_fresh_token_user_path(self):
+ # `config --refresh --force`: rotate even a perfectly fresh token.
+ self.url = _url(token="eyJ.cur", refresh="rt", expires_delta=300)
+ saved = {"eu-cloud": self.url}
+ forced_token = jwt_access_token(cloud_name="eu-cloud", tag="forced-new")
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh",
+ return_value={"access_token": forced_token, "refresh_token": "rt2",
+ "expires_in": 300}) as refresh, \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ new_url = self._refresh(force=True)
+ refresh.assert_called_once()
+ self.assertEqual(forced_token, from_cloudinary_url(new_url).access_token)
+
+ def test_no_expected_no_force_uses_clock_freshness(self):
+ # The proactive sweep with no specific token: a fresh token is left untouched.
+ self.url = _url(token="eyJ.cur", refresh="rt", expires_delta=300)
+ saved = {"eu-cloud": self.url}
+ with patch("cloudinary_cli.auth.refresh.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh") as refresh, \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ new_url = self._refresh()
+ refresh.assert_not_called()
+ self.assertEqual(self.url, new_url)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_oauth_token_seam.py b/test/test_oauth_token_seam.py
new file mode 100644
index 0000000..2ae6ae6
--- /dev/null
+++ b/test/test_oauth_token_seam.py
@@ -0,0 +1,149 @@
+"""The redesign's central premise: the active OAuth config refreshes its token through the single
+SDK seam, `cloudinary.config().oauth_token`, read at request build time. These tests exercise that
+seam the way the SDK does (call_api / uploader) rather than reading oauth_token directly, and pin
+the post-resolve invariant that the active config is always an OAuthConfig."""
+import time
+import unittest
+from unittest.mock import patch
+
+import cloudinary
+
+from cloudinary_cli.auth.oauth_config import OAuthConfig, install_oauth_config, install_env_config
+from cloudinary_cli.auth.session import Session, to_cloudinary_url
+
+from test.oauth_helpers import jwt_access_token
+
+
+def _url(cloud="eu-cloud", token="eyJ.tok", refresh="rt", region="api-eu", expires_delta=300):
+ return to_cloudinary_url(Session(
+ cloud_name=cloud, access_token=token, refresh_token=refresh,
+ expires_at=int(time.time()) + expires_delta, region=region,
+ issuer="https://oauth.cloudinary.com/"))
+
+
+class _RestoresSdkConfig(unittest.TestCase):
+ def setUp(self):
+ import os
+ self._env_snapshot = dict(os.environ)
+ for key in [k for k in os.environ if k.startswith("CLOUDINARY_")]:
+ del os.environ[key]
+ cloudinary.reset_config()
+ self.addCleanup(self._restore)
+
+ def _restore(self):
+ import os
+ os.environ.clear()
+ os.environ.update(self._env_snapshot)
+ cloudinary.reset_config()
+
+
+class TestSdkSeamTriggersRefresh(_RestoresSdkConfig):
+ """The SDK reads cloudinary.config().oauth_token to build the Authorization header; that read
+ (not any CLI-side probe) is what refreshes a stale token."""
+
+ def _saved_stale(self):
+ return {"eu-cloud": _url(token="eyJ.old.tok", refresh="rt_old", expires_delta=-10)}
+
+ def test_call_api_authorize_path_refreshes_stale_token(self):
+ saved = self._saved_stale()
+ new_token = jwt_access_token(cloud_name="eu-cloud", tag="call-api-new")
+ token_response = {"access_token": new_token, "refresh_token": "rt_new", "expires_in": 300}
+ with patch("cloudinary_cli.utils.config_utils.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.refresh.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh", return_value=token_response), \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ install_oauth_config(saved["eu-cloud"], saved_name="eu-cloud")
+ # Reproduce verbatim the read cloudinary.api_client.call_api performs at request build
+ # time (call_api.py:63): options.pop("oauth_token", cloudinary.config().oauth_token).
+ options = {}
+ oauth_token = options.pop("oauth_token", cloudinary.config().oauth_token)
+ self.assertEqual(new_token, oauth_token)
+
+ def test_uploader_header_path_refreshes_stale_token(self):
+ saved = self._saved_stale()
+ fresh_token = jwt_access_token(cloud_name="eu-cloud", tag="uploader-fresh")
+ token_response = {"access_token": fresh_token, "refresh_token": "rt_new", "expires_in": 300}
+ with patch("cloudinary_cli.utils.config_utils.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.refresh.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh", return_value=token_response), \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ install_oauth_config(saved["eu-cloud"], saved_name="eu-cloud")
+ # The uploader reads the same attribute to set the Bearer header (uploader.py:877):
+ # oauth_token = options.get("oauth_token", cloudinary.config().oauth_token).
+ import cloudinary.uploader # noqa: F401 (ensures the seam module is importable)
+ options = {}
+ token = options.get("oauth_token", cloudinary.config().oauth_token)
+ self.assertEqual(fresh_token, token)
+
+ def test_seam_read_refreshes_only_once_then_serves_cached(self):
+ saved = self._saved_stale()
+ new_token = jwt_access_token(cloud_name="eu-cloud", tag="cached-new")
+ token_response = {"access_token": new_token, "refresh_token": "rt_new", "expires_in": 300}
+ with patch("cloudinary_cli.utils.config_utils.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.refresh.load_config", return_value=dict(saved)), \
+ patch("cloudinary_cli.auth.flow.refresh", return_value=token_response) as refresh, \
+ patch("cloudinary_cli.auth.refresh.update_config"):
+ install_oauth_config(saved["eu-cloud"], saved_name="eu-cloud")
+ first = cloudinary.config().oauth_token
+ second = cloudinary.config().oauth_token
+ self.assertEqual(new_token, first)
+ self.assertEqual(new_token, second)
+ refresh.assert_called_once() # the now-fresh _session short-circuits the second read
+
+
+class TestPostResolveInvariant(_RestoresSdkConfig):
+ """Not-done item #5 / Caveat B: every install seam leaves an OAuthConfig as the active global, so
+ has_oauth is universal and self-refresh is never silently disabled by a plain Config swap."""
+
+ runner = None
+
+ def test_saved_oauth_install_is_oauthconfig(self):
+ install_oauth_config(_url(), saved_name="eu-cloud")
+ self.assertIsInstance(cloudinary.config(), OAuthConfig)
+
+ def test_inline_url_install_is_oauthconfig(self):
+ install_oauth_config("cloudinary://key:secret@cloud", saved_name=None)
+ self.assertIsInstance(cloudinary.config(), OAuthConfig)
+
+ def test_env_install_is_oauthconfig(self):
+ import os
+ os.environ["CLOUDINARY_URL"] = "cloudinary://k:s@env_cloud"
+ cloudinary.reset_config()
+ install_env_config()
+ self.assertIsInstance(cloudinary.config(), OAuthConfig)
+
+ def test_resolver_leaves_oauthconfig_for_every_branch(self):
+ from click.testing import CliRunner
+ from cloudinary_cli.cli import cli
+ import os
+ saved = {"__default__": "eu-cloud", "eu-cloud": _url()}
+ with patch("cloudinary_cli.utils.config_resolver.load_config", return_value=dict(saved)):
+ for key in ("CLOUDINARY_URL", "CLOUDINARY_CLOUD_NAME", "CLOUDINARY_API_KEY",
+ "CLOUDINARY_API_SECRET"):
+ os.environ.pop(key, None)
+ cloudinary.reset_config()
+ # default branch
+ CliRunner().invoke(cli, ['url', 'sample'])
+ self.assertIsInstance(cloudinary.config(), OAuthConfig)
+
+
+class TestEnvConfigStatic(_RestoresSdkConfig):
+ """An env-installed OAuthConfig is static: it has no saved name, so reading oauth_token never
+ refreshes even if the token is expired (it cannot rotate an env-supplied token)."""
+
+ def test_env_oauth_token_never_refreshes_even_when_stale(self):
+ import os
+ os.environ["CLOUDINARY_URL"] = (
+ "cloudinary://env_cloud?oauth_token=eyJ.env.tok&refresh_token=rt&"
+ f"expires_at={int(time.time()) - 10}®ion=api")
+ cloudinary.reset_config()
+ with patch("cloudinary_cli.auth.flow.refresh") as refresh:
+ cfg = install_env_config()
+ token = cfg.oauth_token
+ self.assertEqual("eyJ.env.tok", token)
+ refresh.assert_not_called()
+ self.assertIsNone(getattr(cfg, "_session")) # static: no parsed session to drive a refresh
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_utils.py b/test/test_utils.py
index b77b3fe..d186d2d 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -1,7 +1,57 @@
+import builtins
import unittest
+from unittest.mock import patch
from cloudinary_cli.utils.utils import parse_option_value, parse_args_kwargs, whitelist_keys, merge_responses, \
- normalize_list_params, chunker, group_params
+ normalize_list_params, chunker, group_params, confirm_action, get_user_action, prompt_user, is_interactive
+
+
+class NonInteractiveInputTest(unittest.TestCase):
+ """A confirmation/selection prompt on closed (non-interactive) stdin must apply the default and
+ surface a hint, not raise EOFError up to a blank 'Command execution failed' with exit 0."""
+
+ def _eof(self, *args):
+ raise EOFError("EOF when reading a line")
+
+ def test_confirm_action_defaults_to_no_on_eof(self):
+ with patch.object(builtins, "input", self._eof), \
+ patch("cloudinary_cli.utils.utils.logger.warning") as warn:
+ self.assertFalse(confirm_action())
+ warn.assert_called_once()
+ self.assertIn("--force", warn.call_args[0][0])
+
+ def test_get_user_action_returns_default_on_eof(self):
+ with patch.object(builtins, "input", self._eof):
+ self.assertEqual("fallback",
+ get_user_action("pick: ", {"y": True, "default": "fallback"}))
+
+ def test_get_user_action_no_hint_when_not_provided(self):
+ with patch.object(builtins, "input", self._eof), \
+ patch("cloudinary_cli.utils.utils.logger.warning") as warn:
+ self.assertIsNone(get_user_action("pick: ", {"y": True}))
+ warn.assert_not_called()
+
+ def test_empty_line_still_uses_default(self):
+ # An empty line (piped) is distinct from EOF and already used the default; keep that intact.
+ with patch.object(builtins, "input", lambda *a: ""):
+ self.assertFalse(confirm_action())
+
+ def test_prompt_user_returns_line_when_available(self):
+ with patch.object(builtins, "input", lambda *a: " 2 "):
+ self.assertEqual(" 2 ", prompt_user("pick: "))
+
+ def test_prompt_user_returns_none_and_hints_on_eof(self):
+ with patch.object(builtins, "input", self._eof), \
+ patch("cloudinary_cli.utils.utils.logger.warning") as warn:
+ self.assertIsNone(prompt_user("pick: ", noninteractive_hint="do X instead"))
+ warn.assert_called_once()
+ self.assertIn("do X instead", warn.call_args[0][0])
+
+ def test_is_interactive_reflects_stdin_isatty(self):
+ with patch("cloudinary_cli.utils.utils.sys.stdin.isatty", return_value=True):
+ self.assertTrue(is_interactive())
+ with patch("cloudinary_cli.utils.utils.sys.stdin.isatty", return_value=False):
+ self.assertFalse(is_interactive())
class UtilsTest(unittest.TestCase):