feat: sync config UUID with LANGSMITH_PROJECT env var

Store project name alongside UUID in config to detect staleness.
When LANGSMITH_PROJECT changes, automatically re-fetch UUID and update config.

Changes:
- Add _update_project_config() helper for atomic config updates
- Rewrite get_project_uuid() with sync detection logic
- Clear in-memory cache when project_uuid manually set
- Update priority: LANGSMITH_PROJECT_UUID > sync check > config fallback

Benefits:
- Automatic UUID sync when project name changes
- No API call when project unchanged (string comparison only)
- Backwards compatible with legacy configs
- Explicit override via LANGSMITH_PROJECT_UUID still works

Tests:
- Add 10 comprehensive test cases for sync detection
- Fix existing tests to clear env vars for config fallback
- All 71 tests passing
This commit is contained in:
Lance Martin
2025-12-11 11:39:48 -08:00
parent 2076ffc0a9
commit 4df37da2f3
3 changed files with 367 additions and 59 deletions
+78 -44
View File
@@ -77,6 +77,26 @@ def set_config_value(key: str, value: str):
config[key] = value
save_config(config)
# If manually setting project_uuid, clear in-memory cache
# to force re-validation on next lookup
if key in ("project-uuid", "project_uuid"):
_project_uuid_cache.clear()
def _update_project_config(project_name: str, project_uuid: str):
"""
Update config file with both project name and UUID atomically.
Args:
project_name: Project name to store
project_uuid: Project UUID to store
"""
# Load, update both fields, and save atomically
config = load_config()
config["project-name"] = project_name
config["project-uuid"] = project_uuid
save_config(config)
def _lookup_project_uuid_by_name(
project_name: str,
@@ -144,73 +164,87 @@ def get_base_url() -> str | None:
def get_project_uuid() -> str | None:
"""
Get project UUID from config, env var, or by looking up LANGSMITH_PROJECT.
Get project UUID with automatic sync detection.
Priority order:
1. Config file (project_uuid)
2. LANGSMITH_PROJECT_UUID env var (explicit UUID)
3. LANGSMITH_PROJECT env var → API lookup → cached
1. LANGSMITH_PROJECT_UUID env var (explicit UUID override)
2. LANGSMITH_PROJECT env var → check if config matches → fetch if stale
3. Config file as fallback (when no env var set)
Returns:
Project UUID from config file, env var, or looked up by name, or None
Project UUID or None
"""
# Priority 1: Config file
if config_uuid := get_config_value("project_uuid"):
return config_uuid
import sys
# Priority 2: Direct UUID from env var
if env_uuid := os.environ.get("LANGSMITH_PROJECT_UUID"):
# Priority 1: Explicit UUID override (bypasses all config logic)
env_uuid = os.environ.get("LANGSMITH_PROJECT_UUID")
if env_uuid:
return env_uuid
# Priority 3: Project name from env var → lookup
project_name = os.environ.get("LANGSMITH_PROJECT")
if not project_name:
# Get current project name from env var
env_project_name = os.environ.get("LANGSMITH_PROJECT")
# Load config values
config_project_uuid = get_config_value("project_uuid")
config_project_name = get_config_value("project_name")
# Case 1: No env var set - use config as default
if not env_project_name:
if config_project_uuid:
return config_project_uuid
return None
# Check cache first
if project_name in _project_uuid_cache:
return _project_uuid_cache[project_name]
# Case 2: Env var IS set - check if it matches config
# Lookup via API
# Check in-memory cache first (keyed by project name)
if env_project_name in _project_uuid_cache:
cached_uuid = _project_uuid_cache[env_project_name]
# If config is out of sync, update it
if cached_uuid and config_project_name != env_project_name:
_update_project_config(env_project_name, cached_uuid)
return cached_uuid
# Config matches env var - use cached UUID
if config_project_name == env_project_name and config_project_uuid:
# Add to in-memory cache
_project_uuid_cache[env_project_name] = config_project_uuid
return config_project_uuid
# Config doesn't match (or doesn't exist) - need to fetch
print(f"Project name changed to '{env_project_name}', fetching UUID...", file=sys.stderr)
# Validate we have API key before attempting lookup
api_key = get_api_key()
if not api_key:
print(
"Warning: LANGSMITH_PROJECT set but no API key found. "
"Set LANGSMITH_API_KEY to enable project lookup.",
file=sys.stderr
)
return None
base_url = get_base_url()
# Fetch UUID via API
try:
# Need API key for lookup
api_key = get_api_key()
if not api_key:
import sys
print(
"Warning: LANGSMITH_PROJECT set but no API key found. "
"Set LANGSMITH_API_KEY to enable project lookup.",
file=sys.stderr
)
return None
uuid = _lookup_project_uuid_by_name(env_project_name, api_key, base_url)
base_url = get_base_url()
# Update in-memory cache
_project_uuid_cache[env_project_name] = uuid
# Inform user about lookup
import sys
print(f"Looking up project '{project_name}'...", file=sys.stderr)
# Update config with BOTH name and UUID
_update_project_config(env_project_name, uuid)
uuid = _lookup_project_uuid_by_name(project_name, api_key, base_url)
# Cache result in-memory
_project_uuid_cache[project_name] = uuid
# Persist to config file for future use
set_config_value("project_uuid", uuid)
print(f"Found project '{project_name}' (UUID: {uuid})", file=sys.stderr)
print(f"Saved project UUID to config file", file=sys.stderr)
print(f"Found project '{env_project_name}' (UUID: {uuid})", file=sys.stderr)
return uuid
except ValueError as e:
import sys
print(f"Error: {e}", file=sys.stderr)
return None
except Exception as e:
import sys
print(
f"Warning: Failed to lookup project '{project_name}': {e}",
f"Warning: Failed to lookup project '{env_project_name}': {e}",
file=sys.stderr
)
return None
+35 -7
View File
@@ -120,9 +120,13 @@ class TestThreadCommand:
@responses.activate
def test_thread_default_format_with_config(
self, sample_thread_response, mock_env_api_key, temp_config_dir
self, sample_thread_response, mock_env_api_key, temp_config_dir, monkeypatch
):
"""Test thread command with default format and config."""
# Clear env vars to test config fallback
monkeypatch.delenv("LANGSMITH_PROJECT", raising=False)
monkeypatch.delenv("LANGSMITH_PROJECT_UUID", raising=False)
# Set up config
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch(
@@ -147,9 +151,13 @@ class TestThreadCommand:
@responses.activate
def test_thread_pretty_format(
self, sample_thread_response, mock_env_api_key, temp_config_dir
self, sample_thread_response, mock_env_api_key, temp_config_dir, monkeypatch
):
"""Test thread command with explicit pretty format."""
# Clear env vars to test config fallback
monkeypatch.delenv("LANGSMITH_PROJECT", raising=False)
monkeypatch.delenv("LANGSMITH_PROJECT_UUID", raising=False)
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch(
"langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"
@@ -175,9 +183,13 @@ class TestThreadCommand:
@responses.activate
def test_thread_json_format(
self, sample_thread_response, mock_env_api_key, temp_config_dir
self, sample_thread_response, mock_env_api_key, temp_config_dir, monkeypatch
):
"""Test thread command with json format."""
# Clear env vars to test config fallback
monkeypatch.delenv("LANGSMITH_PROJECT", raising=False)
monkeypatch.delenv("LANGSMITH_PROJECT_UUID", raising=False)
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch(
"langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"
@@ -203,9 +215,13 @@ class TestThreadCommand:
@responses.activate
def test_thread_raw_format(
self, sample_thread_response, mock_env_api_key, temp_config_dir
self, sample_thread_response, mock_env_api_key, temp_config_dir, monkeypatch
):
"""Test thread command with raw format."""
# Clear env vars to test config fallback
monkeypatch.delenv("LANGSMITH_PROJECT", raising=False)
monkeypatch.delenv("LANGSMITH_PROJECT_UUID", raising=False)
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch(
"langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"
@@ -287,9 +303,13 @@ class TestThreadsCommand:
@responses.activate
def test_threads_default_limit(
self, sample_thread_response, mock_env_api_key, temp_config_dir, tmp_path
self, sample_thread_response, mock_env_api_key, temp_config_dir, tmp_path, monkeypatch
):
"""Test threads command with default limit (1)."""
# Clear env vars to test config fallback
monkeypatch.delenv("LANGSMITH_PROJECT", raising=False)
monkeypatch.delenv("LANGSMITH_PROJECT_UUID", raising=False)
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch(
"langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"
@@ -341,9 +361,13 @@ class TestThreadsCommand:
@responses.activate
def test_threads_custom_limit(
self, sample_thread_response, mock_env_api_key, temp_config_dir, tmp_path
self, sample_thread_response, mock_env_api_key, temp_config_dir, tmp_path, monkeypatch
):
"""Test threads command with custom limit."""
# Clear env vars to test config fallback
monkeypatch.delenv("LANGSMITH_PROJECT", raising=False)
monkeypatch.delenv("LANGSMITH_PROJECT_UUID", raising=False)
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch(
"langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"
@@ -400,9 +424,13 @@ class TestThreadsCommand:
@responses.activate
def test_threads_custom_filename_pattern(
self, sample_thread_response, mock_env_api_key, temp_config_dir, tmp_path
self, sample_thread_response, mock_env_api_key, temp_config_dir, tmp_path, monkeypatch
):
"""Test threads command with custom filename pattern."""
# Clear env vars to test config fallback
monkeypatch.delenv("LANGSMITH_PROJECT", raising=False)
monkeypatch.delenv("LANGSMITH_PROJECT_UUID", raising=False)
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch(
"langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"
+254 -8
View File
@@ -206,8 +206,12 @@ class TestConfigFunctions:
# Env var should take precedence over config
assert get_api_key() == "env_api_key"
def test_get_project_uuid(self, temp_config_dir):
"""Test getting project UUID from config."""
def test_get_project_uuid(self, temp_config_dir, monkeypatch):
"""Test getting project UUID from config when no env var set."""
# Clear env vars to test config fallback behavior
monkeypatch.delenv("LANGSMITH_PROJECT", raising=False)
monkeypatch.delenv("LANGSMITH_PROJECT_UUID", raising=False)
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch(
"langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"
@@ -253,8 +257,8 @@ class TestConfigFunctions:
class TestProjectLookup:
"""Tests for automatic project UUID lookup from LANGSMITH_PROJECT."""
def test_get_project_uuid_priority_config_first(self, temp_config_dir, monkeypatch):
"""Test that config file takes priority over env var lookup."""
def test_get_project_uuid_priority_explicit_uuid_wins(self, temp_config_dir, monkeypatch):
"""Test that LANGSMITH_PROJECT_UUID env var takes highest priority."""
monkeypatch.setenv("LANGSMITH_PROJECT", "my-project")
monkeypatch.setenv("LANGSMITH_PROJECT_UUID", "env-uuid")
@@ -263,12 +267,13 @@ class TestProjectLookup:
from langsmith_cli.config import get_project_uuid, set_config_value
set_config_value("project-uuid", "config-uuid")
set_config_value("project-name", "old-project")
# Config file should win
assert get_project_uuid() == "config-uuid"
# LANGSMITH_PROJECT_UUID should always win (highest priority)
assert get_project_uuid() == "env-uuid"
def test_get_project_uuid_priority_env_uuid_second(self, temp_config_dir, monkeypatch):
"""Test that LANGSMITH_PROJECT_UUID env var is second priority."""
def test_get_project_uuid_priority_env_uuid_no_lookup(self, temp_config_dir, monkeypatch):
"""Test that LANGSMITH_PROJECT_UUID env var bypasses API lookup."""
monkeypatch.setenv("LANGSMITH_PROJECT", "my-project")
monkeypatch.setenv("LANGSMITH_PROJECT_UUID", "env-uuid")
@@ -366,3 +371,244 @@ class TestProjectLookup:
# Should return None with warning
result = get_project_uuid()
assert result is None
def test_project_name_change_triggers_refetch(self, temp_config_dir, monkeypatch):
"""Test that changing project name triggers UUID re-fetch."""
from unittest.mock import Mock, MagicMock
monkeypatch.setenv("LANGSMITH_PROJECT", "new-project")
monkeypatch.setenv("LANGSMITH_API_KEY", TEST_API_KEY)
mock_project = Mock()
mock_project.id = "new-uuid"
mock_project.name = "new-project"
mock_client = MagicMock()
mock_client.read_project.return_value = mock_project
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch("langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"):
with patch("langsmith.Client", return_value=mock_client):
from langsmith_cli.config import get_project_uuid, set_config_value, get_config_value, _project_uuid_cache
# Clear cache
_project_uuid_cache.clear()
# Set old config
set_config_value("project-name", "old-project")
set_config_value("project-uuid", "old-uuid")
# Should detect mismatch and fetch new UUID
result = get_project_uuid()
assert result == "new-uuid"
assert mock_client.read_project.call_count == 1
# Verify config was updated with both fields
assert get_config_value("project-name") == "new-project"
assert get_config_value("project-uuid") == "new-uuid"
def test_project_name_match_uses_cache(self, temp_config_dir, monkeypatch):
"""Test that matching project name uses cached UUID without API call."""
from unittest.mock import MagicMock
monkeypatch.setenv("LANGSMITH_PROJECT", "test-project")
monkeypatch.setenv("LANGSMITH_API_KEY", TEST_API_KEY)
mock_client = MagicMock()
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch("langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"):
with patch("langsmith.Client", return_value=mock_client):
from langsmith_cli.config import get_project_uuid, set_config_value, _project_uuid_cache
# Clear cache
_project_uuid_cache.clear()
# Set matching config
set_config_value("project-name", "test-project")
set_config_value("project-uuid", "test-uuid")
# Should use cached UUID without API call
result = get_project_uuid()
assert result == "test-uuid"
assert mock_client.read_project.call_count == 0
def test_legacy_config_migration(self, temp_config_dir, monkeypatch):
"""Test that legacy config (only project_uuid) triggers re-fetch and migration."""
from unittest.mock import Mock, MagicMock
monkeypatch.setenv("LANGSMITH_PROJECT", "test-project")
monkeypatch.setenv("LANGSMITH_API_KEY", TEST_API_KEY)
mock_project = Mock()
mock_project.id = "fetched-uuid"
mock_project.name = "test-project"
mock_client = MagicMock()
mock_client.read_project.return_value = mock_project
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch("langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"):
with patch("langsmith.Client", return_value=mock_client):
from langsmith_cli.config import get_project_uuid, set_config_value, get_config_value, _project_uuid_cache
# Clear cache
_project_uuid_cache.clear()
# Set legacy config (only UUID, no name)
set_config_value("project-uuid", "old-uuid")
# Should detect missing project_name and fetch new UUID
result = get_project_uuid()
assert result == "fetched-uuid"
# Verify config was updated with both fields
assert get_config_value("project-name") == "test-project"
assert get_config_value("project-uuid") == "fetched-uuid"
def test_no_env_var_uses_config_default(self, temp_config_dir, monkeypatch):
"""Test that no env var uses config as default."""
monkeypatch.delenv("LANGSMITH_PROJECT", raising=False)
monkeypatch.delenv("LANGSMITH_PROJECT_UUID", raising=False)
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch("langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"):
from langsmith_cli.config import get_project_uuid, set_config_value
# Set config
set_config_value("project-name", "default-project")
set_config_value("project-uuid", "default-uuid")
# Should use config UUID without env var
result = get_project_uuid()
assert result == "default-uuid"
def test_explicit_uuid_override(self, temp_config_dir, monkeypatch):
"""Test that LANGSMITH_PROJECT_UUID overrides everything."""
monkeypatch.setenv("LANGSMITH_PROJECT", "test-project")
monkeypatch.setenv("LANGSMITH_PROJECT_UUID", "override-uuid")
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch("langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"):
from langsmith_cli.config import get_project_uuid, set_config_value
# Set config
set_config_value("project-name", "config-project")
set_config_value("project-uuid", "config-uuid")
# LANGSMITH_PROJECT_UUID should override everything
result = get_project_uuid()
assert result == "override-uuid"
def test_api_failure_handling(self, temp_config_dir, monkeypatch):
"""Test that API failure is handled gracefully."""
from unittest.mock import MagicMock
monkeypatch.setenv("LANGSMITH_PROJECT", "nonexistent")
monkeypatch.setenv("LANGSMITH_API_KEY", TEST_API_KEY)
mock_client = MagicMock()
mock_client.read_project.side_effect = Exception("Project not found")
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch("langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"):
with patch("langsmith.Client", return_value=mock_client):
from langsmith_cli.config import get_project_uuid, get_config_value, set_config_value, _project_uuid_cache
# Clear cache
_project_uuid_cache.clear()
# Set old config
set_config_value("project-name", "old-project")
set_config_value("project-uuid", "old-uuid")
# Should return None on API failure
result = get_project_uuid()
assert result is None
# Verify config was NOT updated (preserves last known good state)
assert get_config_value("project-name") == "old-project"
assert get_config_value("project-uuid") == "old-uuid"
def test_cache_clears_on_manual_update(self, temp_config_dir):
"""Test that in-memory cache clears when project_uuid is manually set."""
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch("langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"):
from langsmith_cli.config import set_config_value, _project_uuid_cache
# Populate cache
_project_uuid_cache["test-project"] = "cached-uuid"
# Manually set project_uuid
set_config_value("project-uuid", "new-uuid")
# Cache should be cleared
assert len(_project_uuid_cache) == 0
def test_in_memory_cache_updates_config(self, temp_config_dir, monkeypatch):
"""Test that in-memory cache updates config when out of sync."""
monkeypatch.setenv("LANGSMITH_PROJECT", "cached-project")
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch("langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"):
from langsmith_cli.config import get_project_uuid, set_config_value, get_config_value, _project_uuid_cache
# Set old config
set_config_value("project-name", "old-project")
set_config_value("project-uuid", "old-uuid")
# Populate in-memory cache with different project
_project_uuid_cache["cached-project"] = "cached-uuid"
# Should use cache and update config
result = get_project_uuid()
assert result == "cached-uuid"
# Verify config was updated
assert get_config_value("project-name") == "cached-project"
assert get_config_value("project-uuid") == "cached-uuid"
def test_empty_project_name_handling(self, temp_config_dir, monkeypatch):
"""Test graceful handling of empty project name."""
monkeypatch.setenv("LANGSMITH_PROJECT", "")
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch("langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"):
from langsmith_cli.config import get_project_uuid, set_config_value
# Set config
set_config_value("project-uuid", "config-uuid")
# Empty string should be treated as no env var
result = get_project_uuid()
assert result == "config-uuid"
def test_project_uuid_persists_after_lookup(self, temp_config_dir, monkeypatch):
"""Test that both project_name and project_uuid persist after lookup."""
from unittest.mock import Mock, MagicMock
monkeypatch.setenv("LANGSMITH_PROJECT", "persist-project")
monkeypatch.setenv("LANGSMITH_API_KEY", TEST_API_KEY)
mock_project = Mock()
mock_project.id = "persist-uuid"
mock_project.name = "persist-project"
mock_client = MagicMock()
mock_client.read_project.return_value = mock_project
with patch("langsmith_cli.config.CONFIG_DIR", temp_config_dir):
with patch("langsmith_cli.config.CONFIG_FILE", temp_config_dir / "config.yaml"):
with patch("langsmith.Client", return_value=mock_client):
from langsmith_cli.config import get_project_uuid, get_config_value, _project_uuid_cache
# Clear cache
_project_uuid_cache.clear()
# First call should fetch and persist
result = get_project_uuid()
assert result == "persist-uuid"
# Verify both fields were persisted
assert get_config_value("project-name") == "persist-project"
assert get_config_value("project-uuid") == "persist-uuid"