This is an automated email from the ASF dual-hosted git repository.

lidavidm pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-adbc.git


The following commit(s) were added to refs/heads/main by this push:
     new 3115969be fix(c/driver_manager): test and fix bugs in profiles (#4080)
3115969be is described below

commit 3115969be9e4adad10badf59abb715f08b0d378b
Author: David Li <[email protected]>
AuthorDate: Sat Mar 14 15:47:35 2026 +0900

    fix(c/driver_manager): test and fix bugs in profiles (#4080)
    
    - Fix substitution of undefined env var erasing the entire value instead
    of substituting in a blank string.
    - Improve error messages.
    - Add end-to-end tests with Python.
    - Add end-to-end tests in Conda/virtualenv.
    - Add end-to-end tests with the user path (`~/.config`, etc.)
    
    This is not complete; see #4082, #4085, #4086, #4087 for things that
    also need to be fixed, but this establishes a baseline of tests.
    
    Closes #4024.
---
 .github/workflows/native-unix.yml                 |   3 +
 .github/workflows/native-windows.yml              |  10 +-
 c/driver/framework/connection.h                   |   3 +-
 c/driver/sqlite/sqlite.cc                         |  57 ++-
 c/driver_manager/adbc_driver_manager.cc           |   2 +-
 c/driver_manager/adbc_driver_manager_internal.h   |   4 +-
 c/driver_manager/adbc_driver_manager_profiles.cc  | 169 +++----
 c/driver_manager/adbc_driver_manager_test.cc      |  14 +-
 ci/scripts/python_venv_test.sh                    |  22 +
 go/adbc/drivermgr/adbc_driver_manager.cc          |   2 +-
 go/adbc/drivermgr/adbc_driver_manager_internal.h  |   4 +-
 go/adbc/drivermgr/adbc_driver_manager_profiles.cc | 169 +++----
 python/adbc_driver_manager/pyproject.toml         |   3 +-
 python/adbc_driver_manager/tests/conftest.py      |  26 ++
 python/adbc_driver_manager/tests/test_profile.py  | 543 ++++++++++++++++++++++
 15 files changed, 837 insertions(+), 194 deletions(-)

diff --git a/.github/workflows/native-unix.yml 
b/.github/workflows/native-unix.yml
index 848b72cd0..2cf25cde7 100644
--- a/.github/workflows/native-unix.yml
+++ b/.github/workflows/native-unix.yml
@@ -589,6 +589,7 @@ jobs:
           ADBC_USE_UBSAN: "0"
         run: |
           export PATH=$RUNNER_TOOL_CACHE/go/${GO_VERSION}/x64/bin:$PATH
+          export _ADBC_IS_CONDA=1
           ./ci/scripts/python_build.sh "$(pwd)" "$(pwd)/build" "$HOME/local"
       - name: Build Panic Dummy
         run: |
@@ -603,6 +604,8 @@ jobs:
         run: |
           cargo build -padbc_dummy
       - name: Test Python Driver Manager
+        env:
+          PYTEST_ADDOPTS: "--run-system"
         run: |
           if [[ $(uname) = "Darwin" ]]; then
             export 
PANICDUMMY_LIBRARY_PATH=$(pwd)/go/adbc/pkg/libadbc_driver_panicdummy.dylib
diff --git a/.github/workflows/native-windows.yml 
b/.github/workflows/native-windows.yml
index 19691dd51..4fdf8bb07 100644
--- a/.github/workflows/native-windows.yml
+++ b/.github/workflows/native-windows.yml
@@ -337,21 +337,15 @@ jobs:
         env:
           BUILD_ALL: "0"
           BUILD_DRIVER_MANAGER: "1"
-        run: .\ci\scripts\python_build.ps1 $pwd $pwd\build
-      - name: Build Python Driver PostgreSQL
-        env:
-          BUILD_ALL: "0"
           BUILD_DRIVER_POSTGRESQL: "1"
-        run: .\ci\scripts\python_build.ps1 $pwd $pwd\build
-      - name: Build Python Driver SQLite
-        env:
-          BUILD_ALL: "0"
           BUILD_DRIVER_SQLITE: "1"
+          _ADBC_IS_CONDA: "1"
         run: .\ci\scripts\python_build.ps1 $pwd $pwd\build
       - name: Test Python Driver Manager
         env:
           BUILD_ALL: "0"
           BUILD_DRIVER_MANAGER: "1"
+          PYTEST_ADDOPTS: "--run-system"
         run: .\ci\scripts\python_test.ps1 $pwd $pwd\build
       - name: Test Python Driver PostgreSQL
         env:
diff --git a/c/driver/framework/connection.h b/c/driver/framework/connection.h
index 783dc8046..46259eb07 100644
--- a/c/driver/framework/connection.h
+++ b/c/driver/framework/connection.h
@@ -165,7 +165,8 @@ class Connection : public ObjectBase {
       }
       return driver::Option();
     }
-    return Base::GetOption(key);
+    return status::NotImplemented(Derived::kErrorPrefix, " Unknown connection 
option ",
+                                  key);
   }
 
   /// \internal
diff --git a/c/driver/sqlite/sqlite.cc b/c/driver/sqlite/sqlite.cc
index c2a1ae798..e91263b18 100644
--- a/c/driver/sqlite/sqlite.cc
+++ b/c/driver/sqlite/sqlite.cc
@@ -52,6 +52,7 @@ constexpr std::string_view 
kConnectionOptionLoadExtensionEntrypoint =
 /// The batch size for query results (and for initial type inference)
 constexpr std::string_view kStatementOptionBatchRows = 
"adbc.sqlite.query.batch_rows";
 constexpr std::string_view kStatementOptionBindByName = 
"adbc.statement.bind_by_name";
+constexpr int kDefaultBatchSize = 1024;
 
 std::string_view GetColumnText(sqlite3_stmt* stmt, int index) {
   return {
@@ -547,6 +548,15 @@ class SqliteDatabase : public 
driver::Database<SqliteDatabase> {
     return Base::ReleaseImpl();
   }
 
+  Result<driver::Option> GetOption(std::string_view key) override {
+    if (key == "uri") {
+      return driver::Option(uri_);
+    } else if (key == kStatementOptionBatchRows) {
+      return driver::Option(static_cast<int64_t>(batch_size_));
+    }
+    return Base::GetOption(key);
+  }
+
   Status SetOptionImpl(std::string_view key, driver::Option value) override {
     if (key == "uri") {
       if (lifecycle_state_ != driver::LifecycleState::kUninitialized) {
@@ -556,13 +566,32 @@ class SqliteDatabase : public 
driver::Database<SqliteDatabase> {
       UNWRAP_RESULT(uri, value.AsString());
       uri_ = std::move(uri);
       return status::Ok();
+    } else if (key == kStatementOptionBatchRows) {
+      if (lifecycle_state_ != driver::LifecycleState::kUninitialized) {
+        return status::fmt::InvalidState(
+            "{} cannot set {} after AdbcDatabaseInit, set it directly on the 
statement "
+            "instead",
+            kErrorPrefix, key);
+      }
+      int64_t batch_size;
+      UNWRAP_RESULT(batch_size, value.AsInt());
+      if (batch_size <= 0 || batch_size > std::numeric_limits<int>::max()) {
+        return status::fmt::InvalidArgument(
+            "{} Invalid statement option value {}={} (value is non-positive or 
out of "
+            "range of int)",
+            kErrorPrefix, key, value.Format());
+      }
+      batch_size_ = static_cast<int>(batch_size);
+      return status::Ok();
     }
     return Base::SetOptionImpl(key, value);
   }
 
  private:
+  friend class SqliteConnection;
   std::string uri_{kDefaultUri};
   sqlite3* conn_ = nullptr;
+  int batch_size_ = kDefaultBatchSize;
 };
 
 class SqliteConnection : public driver::Connection<SqliteConnection> {
@@ -672,6 +701,7 @@ class SqliteConnection : public 
driver::Connection<SqliteConnection> {
   Status InitImpl(void* parent) {
     auto& db = *reinterpret_cast<SqliteDatabase*>(parent);
     UNWRAP_RESULT(conn_, db.OpenConnection());
+    batch_size_ = db.batch_size_;
     return status::Ok();
   }
 
@@ -693,6 +723,13 @@ class SqliteConnection : public 
driver::Connection<SqliteConnection> {
     return SqliteQuery::Execute(conn_, "BEGIN");
   }
 
+  Result<driver::Option> GetOption(std::string_view key) override {
+    if (key == kStatementOptionBatchRows) {
+      return driver::Option(static_cast<int64_t>(batch_size_));
+    }
+    return Base::GetOption(key);
+  }
+
   Status SetOptionImpl(std::string_view key, driver::Option value) {
     if (key == kConnectionOptionEnableLoadExtension) {
       if (!conn_ || lifecycle_state_ != driver::LifecycleState::kInitialized) {
@@ -761,6 +798,8 @@ class SqliteConnection : public 
driver::Connection<SqliteConnection> {
   }
 
  private:
+  friend class SqliteStatement;
+
   Status CheckOpen() const {
     if (!conn_) {
       return status::InvalidState("connection is not open");
@@ -772,6 +811,7 @@ class SqliteConnection : public 
driver::Connection<SqliteConnection> {
   // Temporarily hold the extension path (since the path and entrypoint need
   // to be set separately)
   std::string extension_path_;
+  int batch_size_ = kDefaultBatchSize;
 };
 
 class SqliteStatement : public driver::Statement<SqliteStatement> {
@@ -1111,7 +1151,9 @@ class SqliteStatement : public 
driver::Statement<SqliteStatement> {
   }
 
   Status InitImpl(void* parent) {
-    conn_ = reinterpret_cast<SqliteConnection*>(parent)->conn();
+    auto& conn = *reinterpret_cast<SqliteConnection*>(parent);
+    conn_ = conn.conn();
+    batch_size_ = conn.batch_size_;
     return Statement::InitImpl(parent);
   }
 
@@ -1151,7 +1193,16 @@ class SqliteStatement : public 
driver::Statement<SqliteStatement> {
     return Statement::ReleaseImpl();
   }
 
-  Status SetOptionImpl(std::string_view key, driver::Option value) {
+  Result<driver::Option> GetOption(std::string_view key) override {
+    if (key == kStatementOptionBatchRows) {
+      return driver::Option(static_cast<int64_t>(batch_size_));
+    } else if (key == kStatementOptionBindByName) {
+      return driver::Option(bind_by_name_ ? "true" : "false");
+    }
+    return Base::GetOption(key);
+  }
+
+  Status SetOptionImpl(std::string_view key, driver::Option value) override {
     if (key == kStatementOptionBatchRows) {
       int64_t batch_size;
       UNWRAP_RESULT(batch_size, value.AsInt());
@@ -1170,7 +1221,7 @@ class SqliteStatement : public 
driver::Statement<SqliteStatement> {
     return Base::SetOptionImpl(key, std::move(value));
   }
 
-  int batch_size_ = 1024;
+  int batch_size_ = kDefaultBatchSize;
   bool bind_by_name_ = false;
   AdbcSqliteBinder binder_;
   sqlite3* conn_ = nullptr;
diff --git a/c/driver_manager/adbc_driver_manager.cc 
b/c/driver_manager/adbc_driver_manager.cc
index 2923c35d7..42e904205 100644
--- a/c/driver_manager/adbc_driver_manager.cc
+++ b/c/driver_manager/adbc_driver_manager.cc
@@ -471,7 +471,7 @@ AdbcStatusCode InternalInitializeProfile(TempDatabase* args,
     // use try_emplace so we only add the option if there isn't
     // already an option with the same name
     std::string processed;
-    CHECK_STATUS(ProcessProfileValue(values[i], processed, error));
+    CHECK_STATUS(ProcessProfileValue(keys[i], values[i], processed, error));
     args->options.try_emplace(keys[i], processed);
   }
 
diff --git a/c/driver_manager/adbc_driver_manager_internal.h 
b/c/driver_manager/adbc_driver_manager_internal.h
index 7524b3117..dd0aef128 100644
--- a/c/driver_manager/adbc_driver_manager_internal.h
+++ b/c/driver_manager/adbc_driver_manager_internal.h
@@ -189,8 +189,8 @@ AdbcStatusCode LoadDriverFromRegistry(HKEY root, const 
std::wstring& driver_name
 #endif
 
 // Profile loading
-AdbcStatusCode ProcessProfileValue(std::string_view value, std::string& out,
-                                   struct AdbcError* error);
+AdbcStatusCode ProcessProfileValue(std::string_view key, std::string_view 
value,
+                                   std::string& out, struct AdbcError* error);
 
 // Initialization
 /// Temporary state while the database is being configured.
diff --git a/c/driver_manager/adbc_driver_manager_profiles.cc 
b/c/driver_manager/adbc_driver_manager_profiles.cc
index 3e7a8518b..ff390f5fd 100644
--- a/c/driver_manager/adbc_driver_manager_profiles.cc
+++ b/c/driver_manager/adbc_driver_manager_profiles.cc
@@ -20,10 +20,7 @@
 #include <windows.h>  // Must come first
 #endif                // defined(_WIN32)
 
-#include <toml++/toml.hpp>
 #include "adbc_driver_manager_internal.h"
-#include "arrow-adbc/adbc.h"
-#include "arrow-adbc/adbc_driver_manager.h"
 
 #include <filesystem>
 #include <regex>
@@ -32,6 +29,11 @@
 #include <utility>
 #include <vector>
 
+#include <toml++/toml.hpp>
+
+#include "arrow-adbc/adbc.h"
+#include "arrow-adbc/adbc_driver_manager.h"
+
 using namespace std::string_literals;  // NOLINT [build/namespaces]
 
 namespace {
@@ -42,82 +44,6 @@ static const wchar_t* kAdbcProfilePath = 
L"ADBC_PROFILE_PATH";
 static const char* kAdbcProfilePath = "ADBC_PROFILE_PATH";
 #endif  // _WIN32
 
-static AdbcStatusCode ProcessProfileValueInternal(std::string_view value,
-                                                  std::string& out,
-                                                  struct AdbcError* error) {
-  if (value.empty()) {
-    SetError(error, "Profile value is null");
-    return ADBC_STATUS_INVALID_ARGUMENT;
-  }
-
-  static const std::regex pattern(R"(\{\{\s*([^{}]*?)\s*\}\})");
-  auto end_of_last_match = value.begin();
-  auto begin = std::regex_iterator(value.begin(), value.end(), pattern);
-  auto end = decltype(begin){};
-  std::match_results<std::string_view::iterator>::difference_type 
pos_last_match = 0;
-
-  out.resize(0);
-  for (auto itr = begin; itr != end; ++itr) {
-    auto match = *itr;
-    auto pos_match = match.position();
-    auto diff = pos_match - pos_last_match;
-    auto start_match = end_of_last_match;
-    std::advance(start_match, diff);
-    out.append(end_of_last_match, start_match);
-
-    const auto content = match[1].str();
-    if (content.rfind("env_var(", 0) != 0) {
-      SetError(error, "Unsupported interpolation type in profile value: " + 
content);
-      return ADBC_STATUS_INVALID_ARGUMENT;
-    }
-
-    if (content[content.size() - 1] != ')') {
-      SetError(error, "Malformed env_var() profile value: missing closing 
parenthesis");
-      return ADBC_STATUS_INVALID_ARGUMENT;
-    }
-
-    const auto env_var_name = content.substr(8, content.size() - 9);
-    if (env_var_name.empty()) {
-      SetError(error,
-               "Malformed env_var() profile value: missing environment 
variable name");
-      return ADBC_STATUS_INVALID_ARGUMENT;
-    }
-
-#ifdef _WIN32
-    auto local_env_var = Utf8Decode(std::string(env_var_name));
-    DWORD required_size = GetEnvironmentVariableW(local_env_var.c_str(), NULL, 
0);
-    if (required_size == 0) {
-      out = "";
-      return ADBC_STATUS_OK;
-    }
-
-    std::wstring wvalue;
-    wvalue.resize(required_size);
-    DWORD actual_size =
-        GetEnvironmentVariableW(local_env_var.c_str(), wvalue.data(), 
required_size);
-    // remove null terminator
-    wvalue.resize(actual_size);
-    const auto env_var_value = Utf8Encode(wvalue);
-#else
-    const char* env_value = std::getenv(env_var_name.c_str());
-    if (!env_value) {
-      out = "";
-      return ADBC_STATUS_OK;
-    }
-    const auto env_var_value = std::string(env_value);
-#endif
-    out.append(env_var_value);
-
-    auto length_match = match.length();
-    pos_last_match = pos_match + length_match;
-    end_of_last_match = start_match;
-    std::advance(end_of_last_match, length_match);
-  }
-
-  out.append(end_of_last_match, value.end());
-  return ADBC_STATUS_OK;
-}
-
 }  // namespace
 
 // FilesystemProfile needs external linkage for use in internal header
@@ -278,9 +204,80 @@ struct ProfileVisitor {
 };
 
 // Public implementations (non-static for use across translation units)
-AdbcStatusCode ProcessProfileValue(std::string_view value, std::string& out,
-                                   struct AdbcError* error) {
-  return ProcessProfileValueInternal(value, out, error);
+AdbcStatusCode ProcessProfileValue(std::string_view key, std::string_view 
value,
+                                   std::string& out, struct AdbcError* error) {
+  if (value.empty()) {
+    out = "";
+    return ADBC_STATUS_OK;
+  }
+
+  static const std::regex pattern(R"(\{\{\s*([^{}]*?)\s*\}\})");
+  auto end_of_last_match = value.begin();
+  auto begin = std::regex_iterator(value.begin(), value.end(), pattern);
+  auto end = decltype(begin){};
+  std::match_results<std::string_view::iterator>::difference_type 
pos_last_match = 0;
+
+  out.resize(0);
+  for (auto itr = begin; itr != end; ++itr) {
+    auto match = *itr;
+    auto pos_match = match.position();
+    auto diff = pos_match - pos_last_match;
+    auto start_match = end_of_last_match;
+    std::advance(start_match, diff);
+    out.append(end_of_last_match, start_match);
+
+    const auto content = match[1].str();
+    if (content.rfind("env_var(", 0) != 0) {
+      std::string message = "In profile: unsupported interpolation type in key 
`" +
+                            std::string(key) + "`: `" + content + "`";
+      SetError(error, message);
+      return ADBC_STATUS_INVALID_ARGUMENT;
+    }
+
+    if (content[content.size() - 1] != ')') {
+      std::string message = "In profile: malformed env_var() in key `" +
+                            std::string(key) + "`: missing closing 
parenthesis";
+      SetError(error, message);
+      return ADBC_STATUS_INVALID_ARGUMENT;
+    }
+
+    const auto env_var_name = content.substr(8, content.size() - 9);
+    if (env_var_name.empty()) {
+      std::string message = "In profile: malformed env_var() in key `" +
+                            std::string(key) + "`: missing environment 
variable name";
+      SetError(error, message);
+      return ADBC_STATUS_INVALID_ARGUMENT;
+    }
+
+    std::string env_var_value;
+#ifdef _WIN32
+    auto local_env_var = Utf8Decode(std::string(env_var_name));
+    DWORD required_size = GetEnvironmentVariableW(local_env_var.c_str(), NULL, 
0);
+    if (required_size != 0) {
+      std::wstring wvalue;
+      wvalue.resize(required_size);
+      DWORD actual_size =
+          GetEnvironmentVariableW(local_env_var.c_str(), wvalue.data(), 
required_size);
+      // remove null terminator
+      wvalue.resize(actual_size);
+      env_var_value = Utf8Encode(wvalue);
+    }
+#else
+    const char* env_value = std::getenv(env_var_name.c_str());
+    if (env_value) {
+      env_var_value = std::string(env_value);
+    }
+#endif
+    out.append(env_var_value);
+
+    auto length_match = match.length();
+    pos_last_match = pos_match + length_match;
+    end_of_last_match = start_match;
+    std::advance(end_of_last_match, length_match);
+  }
+
+  out.append(end_of_last_match, value.end());
+  return ADBC_STATUS_OK;
 }
 
 AdbcStatusCode LoadProfileFile(const std::filesystem::path& profile_path,
@@ -453,8 +450,10 @@ AdbcStatusCode AdbcProfileProviderFilesystem(const char* 
profile_name,
                             extra_debug_info.end());
         if (intermediate_error.error.message) {
           std::string error_message = intermediate_error.error.message;
+          // Remove [Driver Manager] prefix so it doesn't get repeated
+          error_message = error_message.substr(17);
           AddSearchPathsToError(search_paths, SearchPathType::kProfile, 
error_message);
-          SetError(error, std::move(error_message));
+          SetError(error, error_message);
         }
         return status;
       }
@@ -463,7 +462,9 @@ AdbcStatusCode AdbcProfileProviderFilesystem(const char* 
profile_name,
       message += full_path.string();
       message += " but: ";
       if (intermediate_error.error.message) {
-        message += intermediate_error.error.message;
+        std::string m = intermediate_error.error.message;
+        // Remove [Driver Manager] prefix so it doesn't get repeated
+        message += m.substr(17);
       } else {
         message += "could not load the profile";
       }
diff --git a/c/driver_manager/adbc_driver_manager_test.cc 
b/c/driver_manager/adbc_driver_manager_test.cc
index fcd3a8d28..f917d9543 100644
--- a/c/driver_manager/adbc_driver_manager_test.cc
+++ b/c/driver_manager/adbc_driver_manager_test.cc
@@ -1811,9 +1811,10 @@ TEST_F(ConnectionProfiles, UseEnvVarMalformed) {
               IsOkStatus(&error));
   ASSERT_THAT(AdbcDatabaseInit(&database.value, &error),
               IsStatus(ADBC_STATUS_INVALID_ARGUMENT, &error));
-  ASSERT_THAT(error.message,
-              ::testing::HasSubstr(
-                  "Malformed env_var() profile value: missing closing 
parenthesis"));
+  ASSERT_THAT(
+      error.message,
+      ::testing::HasSubstr(
+          "In profile: malformed env_var() in key `foo`: missing closing 
parenthesis"));
   UnsetConfigPath();
 }
 
@@ -1840,10 +1841,9 @@ TEST_F(ConnectionProfiles, UseEnvVarMissingArg) {
               IsOkStatus(&error));
   ASSERT_THAT(AdbcDatabaseInit(&database.value, &error),
               IsStatus(ADBC_STATUS_INVALID_ARGUMENT, &error));
-  ASSERT_THAT(
-      error.message,
-      ::testing::HasSubstr(
-          "Malformed env_var() profile value: missing environment variable 
name"));
+  ASSERT_THAT(error.message,
+              ::testing::HasSubstr("In profile: malformed env_var() in key 
`foo`: "
+                                   "missing environment variable name"));
   UnsetConfigPath();
 }
 
diff --git a/ci/scripts/python_venv_test.sh b/ci/scripts/python_venv_test.sh
index ac9507c05..bbee58ba8 100755
--- a/ci/scripts/python_venv_test.sh
+++ b/ci/scripts/python_venv_test.sh
@@ -36,6 +36,14 @@ main() {
 name = "SQLite"
 [Driver]
 shared = "${sqlite_driver}"
+EOF
+
+    mkdir -p "${scratch}/.venv/etc/adbc/profiles/sqlite/"
+    cat >"${scratch}/.venv/etc/adbc/profiles/sqlite/dev.toml" <<EOF
+version = 1
+driver = "sqlite"
+[options]
+uri = "file:///tmp/test.db"
 EOF
 
     cat >"${scratch}/test.py" <<EOF
@@ -65,6 +73,20 @@ EOF
 
     "${scratch}"/.venv/bin/python "${scratch}/test2.py"
     echo "PASSED: failed manifest contains the proper path in the exception"
+
+    # TODO(https://github.com/apache/arrow-adbc/issues/4087)
+#     cat >"${scratch}/test3.py" <<EOF
+# import adbc_driver_manager.dbapi
+
+# with adbc_driver_manager.dbapi.connect(profile="sqlite/dev") as con:
+#     with con.cursor() as cur:
+#         cur.execute("SELECT 1")
+#         assert cur.fetchall() == [(1,)]
+# EOF
+
+#     "${scratch}"/.venv/bin/python "${scratch}/test3.py"
+#     test -f /tmp/test.db
+#     echo "PASSED: find profile"
 }
 
 main "$@"
diff --git a/go/adbc/drivermgr/adbc_driver_manager.cc 
b/go/adbc/drivermgr/adbc_driver_manager.cc
index 2923c35d7..42e904205 100644
--- a/go/adbc/drivermgr/adbc_driver_manager.cc
+++ b/go/adbc/drivermgr/adbc_driver_manager.cc
@@ -471,7 +471,7 @@ AdbcStatusCode InternalInitializeProfile(TempDatabase* args,
     // use try_emplace so we only add the option if there isn't
     // already an option with the same name
     std::string processed;
-    CHECK_STATUS(ProcessProfileValue(values[i], processed, error));
+    CHECK_STATUS(ProcessProfileValue(keys[i], values[i], processed, error));
     args->options.try_emplace(keys[i], processed);
   }
 
diff --git a/go/adbc/drivermgr/adbc_driver_manager_internal.h 
b/go/adbc/drivermgr/adbc_driver_manager_internal.h
index 7524b3117..dd0aef128 100644
--- a/go/adbc/drivermgr/adbc_driver_manager_internal.h
+++ b/go/adbc/drivermgr/adbc_driver_manager_internal.h
@@ -189,8 +189,8 @@ AdbcStatusCode LoadDriverFromRegistry(HKEY root, const 
std::wstring& driver_name
 #endif
 
 // Profile loading
-AdbcStatusCode ProcessProfileValue(std::string_view value, std::string& out,
-                                   struct AdbcError* error);
+AdbcStatusCode ProcessProfileValue(std::string_view key, std::string_view 
value,
+                                   std::string& out, struct AdbcError* error);
 
 // Initialization
 /// Temporary state while the database is being configured.
diff --git a/go/adbc/drivermgr/adbc_driver_manager_profiles.cc 
b/go/adbc/drivermgr/adbc_driver_manager_profiles.cc
index 3e7a8518b..ff390f5fd 100644
--- a/go/adbc/drivermgr/adbc_driver_manager_profiles.cc
+++ b/go/adbc/drivermgr/adbc_driver_manager_profiles.cc
@@ -20,10 +20,7 @@
 #include <windows.h>  // Must come first
 #endif                // defined(_WIN32)
 
-#include <toml++/toml.hpp>
 #include "adbc_driver_manager_internal.h"
-#include "arrow-adbc/adbc.h"
-#include "arrow-adbc/adbc_driver_manager.h"
 
 #include <filesystem>
 #include <regex>
@@ -32,6 +29,11 @@
 #include <utility>
 #include <vector>
 
+#include <toml++/toml.hpp>
+
+#include "arrow-adbc/adbc.h"
+#include "arrow-adbc/adbc_driver_manager.h"
+
 using namespace std::string_literals;  // NOLINT [build/namespaces]
 
 namespace {
@@ -42,82 +44,6 @@ static const wchar_t* kAdbcProfilePath = 
L"ADBC_PROFILE_PATH";
 static const char* kAdbcProfilePath = "ADBC_PROFILE_PATH";
 #endif  // _WIN32
 
-static AdbcStatusCode ProcessProfileValueInternal(std::string_view value,
-                                                  std::string& out,
-                                                  struct AdbcError* error) {
-  if (value.empty()) {
-    SetError(error, "Profile value is null");
-    return ADBC_STATUS_INVALID_ARGUMENT;
-  }
-
-  static const std::regex pattern(R"(\{\{\s*([^{}]*?)\s*\}\})");
-  auto end_of_last_match = value.begin();
-  auto begin = std::regex_iterator(value.begin(), value.end(), pattern);
-  auto end = decltype(begin){};
-  std::match_results<std::string_view::iterator>::difference_type 
pos_last_match = 0;
-
-  out.resize(0);
-  for (auto itr = begin; itr != end; ++itr) {
-    auto match = *itr;
-    auto pos_match = match.position();
-    auto diff = pos_match - pos_last_match;
-    auto start_match = end_of_last_match;
-    std::advance(start_match, diff);
-    out.append(end_of_last_match, start_match);
-
-    const auto content = match[1].str();
-    if (content.rfind("env_var(", 0) != 0) {
-      SetError(error, "Unsupported interpolation type in profile value: " + 
content);
-      return ADBC_STATUS_INVALID_ARGUMENT;
-    }
-
-    if (content[content.size() - 1] != ')') {
-      SetError(error, "Malformed env_var() profile value: missing closing 
parenthesis");
-      return ADBC_STATUS_INVALID_ARGUMENT;
-    }
-
-    const auto env_var_name = content.substr(8, content.size() - 9);
-    if (env_var_name.empty()) {
-      SetError(error,
-               "Malformed env_var() profile value: missing environment 
variable name");
-      return ADBC_STATUS_INVALID_ARGUMENT;
-    }
-
-#ifdef _WIN32
-    auto local_env_var = Utf8Decode(std::string(env_var_name));
-    DWORD required_size = GetEnvironmentVariableW(local_env_var.c_str(), NULL, 
0);
-    if (required_size == 0) {
-      out = "";
-      return ADBC_STATUS_OK;
-    }
-
-    std::wstring wvalue;
-    wvalue.resize(required_size);
-    DWORD actual_size =
-        GetEnvironmentVariableW(local_env_var.c_str(), wvalue.data(), 
required_size);
-    // remove null terminator
-    wvalue.resize(actual_size);
-    const auto env_var_value = Utf8Encode(wvalue);
-#else
-    const char* env_value = std::getenv(env_var_name.c_str());
-    if (!env_value) {
-      out = "";
-      return ADBC_STATUS_OK;
-    }
-    const auto env_var_value = std::string(env_value);
-#endif
-    out.append(env_var_value);
-
-    auto length_match = match.length();
-    pos_last_match = pos_match + length_match;
-    end_of_last_match = start_match;
-    std::advance(end_of_last_match, length_match);
-  }
-
-  out.append(end_of_last_match, value.end());
-  return ADBC_STATUS_OK;
-}
-
 }  // namespace
 
 // FilesystemProfile needs external linkage for use in internal header
@@ -278,9 +204,80 @@ struct ProfileVisitor {
 };
 
 // Public implementations (non-static for use across translation units)
-AdbcStatusCode ProcessProfileValue(std::string_view value, std::string& out,
-                                   struct AdbcError* error) {
-  return ProcessProfileValueInternal(value, out, error);
+AdbcStatusCode ProcessProfileValue(std::string_view key, std::string_view 
value,
+                                   std::string& out, struct AdbcError* error) {
+  if (value.empty()) {
+    out = "";
+    return ADBC_STATUS_OK;
+  }
+
+  static const std::regex pattern(R"(\{\{\s*([^{}]*?)\s*\}\})");
+  auto end_of_last_match = value.begin();
+  auto begin = std::regex_iterator(value.begin(), value.end(), pattern);
+  auto end = decltype(begin){};
+  std::match_results<std::string_view::iterator>::difference_type 
pos_last_match = 0;
+
+  out.resize(0);
+  for (auto itr = begin; itr != end; ++itr) {
+    auto match = *itr;
+    auto pos_match = match.position();
+    auto diff = pos_match - pos_last_match;
+    auto start_match = end_of_last_match;
+    std::advance(start_match, diff);
+    out.append(end_of_last_match, start_match);
+
+    const auto content = match[1].str();
+    if (content.rfind("env_var(", 0) != 0) {
+      std::string message = "In profile: unsupported interpolation type in key 
`" +
+                            std::string(key) + "`: `" + content + "`";
+      SetError(error, message);
+      return ADBC_STATUS_INVALID_ARGUMENT;
+    }
+
+    if (content[content.size() - 1] != ')') {
+      std::string message = "In profile: malformed env_var() in key `" +
+                            std::string(key) + "`: missing closing 
parenthesis";
+      SetError(error, message);
+      return ADBC_STATUS_INVALID_ARGUMENT;
+    }
+
+    const auto env_var_name = content.substr(8, content.size() - 9);
+    if (env_var_name.empty()) {
+      std::string message = "In profile: malformed env_var() in key `" +
+                            std::string(key) + "`: missing environment 
variable name";
+      SetError(error, message);
+      return ADBC_STATUS_INVALID_ARGUMENT;
+    }
+
+    std::string env_var_value;
+#ifdef _WIN32
+    auto local_env_var = Utf8Decode(std::string(env_var_name));
+    DWORD required_size = GetEnvironmentVariableW(local_env_var.c_str(), NULL, 
0);
+    if (required_size != 0) {
+      std::wstring wvalue;
+      wvalue.resize(required_size);
+      DWORD actual_size =
+          GetEnvironmentVariableW(local_env_var.c_str(), wvalue.data(), 
required_size);
+      // remove null terminator
+      wvalue.resize(actual_size);
+      env_var_value = Utf8Encode(wvalue);
+    }
+#else
+    const char* env_value = std::getenv(env_var_name.c_str());
+    if (env_value) {
+      env_var_value = std::string(env_value);
+    }
+#endif
+    out.append(env_var_value);
+
+    auto length_match = match.length();
+    pos_last_match = pos_match + length_match;
+    end_of_last_match = start_match;
+    std::advance(end_of_last_match, length_match);
+  }
+
+  out.append(end_of_last_match, value.end());
+  return ADBC_STATUS_OK;
 }
 
 AdbcStatusCode LoadProfileFile(const std::filesystem::path& profile_path,
@@ -453,8 +450,10 @@ AdbcStatusCode AdbcProfileProviderFilesystem(const char* 
profile_name,
                             extra_debug_info.end());
         if (intermediate_error.error.message) {
           std::string error_message = intermediate_error.error.message;
+          // Remove [Driver Manager] prefix so it doesn't get repeated
+          error_message = error_message.substr(17);
           AddSearchPathsToError(search_paths, SearchPathType::kProfile, 
error_message);
-          SetError(error, std::move(error_message));
+          SetError(error, error_message);
         }
         return status;
       }
@@ -463,7 +462,9 @@ AdbcStatusCode AdbcProfileProviderFilesystem(const char* 
profile_name,
       message += full_path.string();
       message += " but: ";
       if (intermediate_error.error.message) {
-        message += intermediate_error.error.message;
+        std::string m = intermediate_error.error.message;
+        // Remove [Driver Manager] prefix so it doesn't get repeated
+        message += m.substr(17);
       } else {
         message += "could not load the profile";
       }
diff --git a/python/adbc_driver_manager/pyproject.toml 
b/python/adbc_driver_manager/pyproject.toml
index bad5ad046..7d1aa3092 100644
--- a/python/adbc_driver_manager/pyproject.toml
+++ b/python/adbc_driver_manager/pyproject.toml
@@ -28,7 +28,7 @@ dependencies = ["typing-extensions"]
 
 [project.optional-dependencies]
 dbapi = ["pandas", "pyarrow>=14.0.1"]
-test = ["duckdb", "pandas", "polars", "pyarrow>=14.0.1", "pytest"]
+test = ["duckdb", "pandas", "polars", "pyarrow>=14.0.1", "pytest>=9"]
 
 [project.urls]
 homepage = "https://arrow.apache.org/adbc/";
@@ -48,6 +48,7 @@ markers = [
     "panicdummy: tests that require the testing-only panicdummy driver",
     "pyarrowless: tests of functionality when PyArrow is NOT installed",
     "sqlite: tests that require the SQLite driver",
+    "system: tests that touch user data directories",
 ]
 xfail_strict = true
 
diff --git a/python/adbc_driver_manager/tests/conftest.py 
b/python/adbc_driver_manager/tests/conftest.py
index e08ad3134..cbf35d2a0 100644
--- a/python/adbc_driver_manager/tests/conftest.py
+++ b/python/adbc_driver_manager/tests/conftest.py
@@ -15,6 +15,8 @@
 # specific language governing permissions and limitations
 # under the License.
 
+import os
+import pathlib
 import typing
 
 import pytest
@@ -22,7 +24,31 @@ import pytest
 from adbc_driver_manager import dbapi
 
 
+def pytest_addoption(parser):
+    parser.addoption(
+        "--run-system",
+        action="store_true",
+        default=False,
+        help="Run tests that may modify global filesystem paths",
+    )
+
+
+def pytest_collection_modifyitems(config, items):
+    if not config.getoption("--run-system"):
+        mark = pytest.mark.skip(reason="Needs --run-system")
+        for item in items:
+            if "system" in item.keywords:
+                item.add_marker(mark)
+
+
 @pytest.fixture
 def sqlite() -> typing.Generator[dbapi.Connection, None, None]:
     with dbapi.connect(driver="adbc_driver_sqlite") as conn:
         yield conn
+
+
[email protected](scope="session")
+def conda_prefix() -> pathlib.Path:
+    if "CONDA_PREFIX" not in os.environ:
+        pytest.skip("only runs in Conda environment")
+    return pathlib.Path(os.environ["CONDA_PREFIX"])
diff --git a/python/adbc_driver_manager/tests/test_profile.py 
b/python/adbc_driver_manager/tests/test_profile.py
new file mode 100644
index 000000000..2aea0718a
--- /dev/null
+++ b/python/adbc_driver_manager/tests/test_profile.py
@@ -0,0 +1,543 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import os
+import pathlib
+import platform
+import re
+import typing
+import uuid
+
+import pytest
+
+import adbc_driver_manager.dbapi as dbapi
+
+pytestmark = [pytest.mark.sqlite]
+
+
[email protected](scope="module", autouse=True)
+def profile_dir(tmp_path_factory) -> typing.Generator[pathlib.Path, None, 
None]:
+    path = tmp_path_factory.mktemp("profile_dir")
+    with pytest.MonkeyPatch().context() as mp:
+        mp.setenv("ADBC_PROFILE_PATH", str(path))
+        yield path
+
+
[email protected](scope="module")
+def sqlitedev(profile_dir) -> str:
+    with (profile_dir / "sqlitedev.toml").open("w") as sink:
+        sink.write("""
+version = 1
+driver = "adbc_driver_sqlite"
+
+[options]
+""")
+    return "sqlitedev"
+
+
+def test_profile_option(sqlitedev) -> None:
+    # Test loading via "profile" option
+    with dbapi.connect(profile=sqlitedev) as conn:
+        with conn.cursor() as cursor:
+            cursor.execute("SELECT sqlite_version()")
+            assert cursor.fetchone() is not None
+
+
+def test_option_env_var(subtests, tmp_path, monkeypatch) -> None:
+    # Test a profile that uses env var substitution for option values
+    monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
+
+    # ruff: disable[E501]
+    # fmt: off
+    cases = [
+        # env vars, raw string, rendered value
+
+        # Make sure manager doesn't misinterpret empty string as null (there
+        # is no null in TOML)
+        ({}, "", ""),
+        # No actual substitution
+        ({}, "{{ env_var(NONEXISTENT)", "{{ env_var(NONEXISTENT)"),
+        ({}, "{{ env_var(NONEXISTENT) }", "{{ env_var(NONEXISTENT) }"),
+        ({}, "{ env_var(NONEXISTENT) }", "{ env_var(NONEXISTENT) }"),
+        # Env var does not exist
+        ({}, "{{ env_var(NONEXISTENT) }}", ""),
+        ({}, "{{ env_var(NONEXISTENT) }}bar", "bar"),
+        ({}, "foo{{ env_var(NONEXISTENT) }}", "foo"),
+        ({}, "foo{{ env_var(NONEXISTENT) }}bar", "foobar"),
+        # Multiple env vars do not exist
+        ({}, "foo{{ env_var(NONEXISTENT) }}bar{{ env_var(NONEXISTENT2) }}baz", 
"foobarbaz"),
+        # Multiple env vars do not exist in different positions
+        ({}, "{{ env_var(NONEXISTENT) }}foobarbaz{{ env_var(NONEXISTENT2) }}", 
"foobarbaz"),
+
+        # Check whitespace sensitivity
+        ({"TESTVALUE": "a"}, "{{env_var(TESTVALUE)}},{{ env_var(TESTVALUE) 
}},{{env_var(TESTVALUE) }},{{ env_var(TESTVALUE)}},{{ env_var(TESTVALUE)     
}}", "a,a,a,a,a"),
+
+        # Multiple vars
+        ({"TESTVALUE": "a", "TESTVALUE2": "b"}, 
"{{env_var(TESTVALUE)}}{{env_var(TESTVALUE2)}}", "ab"),
+        ({"TESTVALUE": "a", "TESTVALUE2": "b"}, 
"foo{{env_var(TESTVALUE)}}bar{{env_var(TESTVALUE2)}}baz", "fooabarbbaz"),
+    ]
+    # fmt: on
+    # ruff: enable[E501]
+
+    for i, (env_vars, raw_value, rendered_value) in enumerate(cases):
+        with subtests.test(i=i, msg=raw_value):
+            with (tmp_path / "subst.toml").open("w") as sink:
+                sink.write(f"""
+version = 1
+driver = "adbc_driver_sqlite"
+[options]
+adbc.foo.bar = "{raw_value}"
+""")
+
+            with monkeypatch.context() as mp:
+                for k, v in env_vars.items():
+                    mp.setenv(k, v)
+
+                expected = re.escape(
+                    f"Unknown database option adbc.foo.bar='{rendered_value}'"
+                )
+                with pytest.raises(dbapi.Error, match=expected):
+                    with dbapi.connect("profile://subst"):
+                        pass
+
+
+def test_option_env_var_multiple(tmp_path, monkeypatch) -> None:
+    # Test that we can set multiple options via env var substitution
+    monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
+    with (tmp_path / "subst.toml").open("w") as sink:
+        # On Windows, ensure we get file:///c:/ not file://c:/
+        windows = "/" if platform.system() == "Windows" else ""
+        rest = "{{ env_var(TEST_DIR) }}/{{ env_var(TEST_NUM) }}"
+        batch = "{{ env_var(BATCH_SIZE) }}"
+        sink.write(f"""
+version = 1
+driver = "adbc_driver_sqlite"
+[options]
+uri = "file://{windows}{rest}.db"
+adbc.sqlite.query.batch_rows = "{batch}"
+""")
+
+    monkeypatch.setenv("TEST_DIR", str(tmp_path.as_posix()))
+    monkeypatch.setenv("TEST_NUM", "42")
+    monkeypatch.setenv("BATCH_SIZE", "1")
+
+    key = "adbc.sqlite.query.batch_rows"
+    with dbapi.connect("profile://subst") as conn:
+        assert conn.adbc_database.get_option_int(key) == 1
+        assert conn.adbc_connection.get_option_int(key) == 1
+
+        assert conn.adbc_database.get_option("uri") == (tmp_path / 
"42.db").as_uri()
+
+        with conn.cursor() as cursor:
+            assert cursor.adbc_statement.get_option_int(key) == 1
+            cursor.execute("CREATE TABLE foo (id INTEGER)")
+            cursor.execute("INSERT INTO foo VALUES (1), (2)")
+            cursor.execute("SELECT * FROM foo")
+            with cursor.fetch_record_batch() as reader:
+                assert len(next(reader)) == 1
+                assert len(next(reader)) == 1
+
+
+def test_option_env_var_invalid(subtests, tmp_path, monkeypatch) -> None:
+    # Test that various invalid syntaxes fail
+    monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
+
+    for contents, error in [
+        ("{{ }}", "unsupported interpolation type in key `uri`: ``"),
+        ("{{ bar_baz }}", "unsupported interpolation type in key `uri`: 
`bar_baz`"),
+        ("{{ rand() }}", "unsupported interpolation type in key `uri`: 
`rand()`"),
+        (
+            "{{ env_var(TEST_DIR }}",
+            "malformed env_var() in key `uri`: missing closing parenthesis",
+        ),
+        (
+            "{{ env_var() }}",
+            "malformed env_var() in key `uri`: missing environment variable 
name",
+        ),
+    ]:
+        with (tmp_path / "subst.toml").open("w") as sink:
+            sink.write(f"""
+version = 1
+driver = "adbc_driver_sqlite"
+[options]
+uri = "{contents}"
+    """)
+
+        with subtests.test(msg=contents):
+            with pytest.raises(
+                dbapi.ProgrammingError, match=re.escape(f"In profile: {error}")
+            ):
+                with dbapi.connect("profile://subst"):
+                    pass
+
+
[email protected](reason="https://github.com/apache/arrow-adbc/issues/4086";)
+def test_option_override(tmp_path, monkeypatch) -> None:
+    # Test that the driver is optional
+    monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
+
+    with (tmp_path / "dev.toml").open("w") as sink:
+        sink.write("""
+version = 1
+driver = "adbc_driver_sqlite"
+[options]
+adbc.sqlite.query.batch_rows = 7
+""")
+
+    key = "adbc.sqlite.query.batch_rows"
+    with dbapi.connect("profile://dev") as conn:
+        assert conn.adbc_database.get_option_int(key) == 7
+
+    with dbapi.connect("profile://dev", db_kwargs={key: "42"}) as conn:
+        assert conn.adbc_database.get_option_int(key) == 42
+
+
+def test_uri(sqlitedev) -> None:
+    # Test loading via profile:// URI
+    with dbapi.connect(f"profile://{sqlitedev}") as conn:
+        with conn.cursor() as cursor:
+            cursor.execute("SELECT sqlite_version()")
+            assert cursor.fetchone() is not None
+
+
+def test_driver_optional(subtests, tmp_path, monkeypatch) -> None:
+    # Test that the driver is optional
+    monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
+
+    with (tmp_path / "nodriver.toml").open("w") as sink:
+        sink.write("""
+version = 1
+[options]
+""")
+
+    with subtests.test(msg="missing driver"):
+        with pytest.raises(dbapi.ProgrammingError, match="Must set 'driver' 
option"):
+            with dbapi.connect("profile://nodriver"):
+                pass
+
+    # TODO(https://github.com/apache/arrow-adbc/issues/4085): do we want to 
allow this?
+    # with subtests.test(msg="uri"):
+    #     with dbapi.connect("adbc_driver_sqlite", uri="profile://nodriver") 
as conn:
+    #         with conn.cursor() as cursor:
+    #             cursor.execute("SELECT sqlite_version()")
+    #             assert cursor.fetchone() is not None
+
+    with subtests.test(msg="profile"):
+        with dbapi.connect("adbc_driver_sqlite", profile="nodriver") as conn:
+            with conn.cursor() as cursor:
+                cursor.execute("SELECT sqlite_version()")
+                assert cursor.fetchone() is not None
+
+
+# TODO(https://github.com/apache/arrow-adbc/issues/4085): do we want to allow 
this?
+
+# @pytest.mark.xfail
+# def test_driver_override(subtests, tmp_path, monkeypatch) -> None:
+#     # Test that the driver can be overridden by an option
+#     monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
+#     with subtests.test(msg="with override (URI)"):
+#         with dbapi.connect("adbc_driver_sqlite", "profile://nonexistent") as 
conn:
+#             with conn.cursor() as cursor:
+#                 cursor.execute("SELECT sqlite_version()")
+#                 assert cursor.fetchone() is not None
+
+#     with subtests.test(msg="with override (profile)"):
+#         with dbapi.connect("adbc_driver_sqlite", profile="nonexistent") as 
conn:
+#             with conn.cursor() as cursor:
+#                 cursor.execute("SELECT sqlite_version()")
+#                 assert cursor.fetchone() is not None
+
+
+def test_driver_invalid(subtests, tmp_path, monkeypatch) -> None:
+    # Test invalid values for the driver
+    # TODO(lidavidm): give a more specific error
+    monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
+
+    with (tmp_path / "nodriver.toml").open("w") as sink:
+        sink.write("""
+version = 1
+driver = 2
+[options]
+""")
+
+    with subtests.test(msg="numeric driver"):
+        with pytest.raises(dbapi.ProgrammingError, match="Must set 'driver' 
option"):
+            with dbapi.connect("profile://nodriver"):
+                pass
+
+    with (tmp_path / "nodriver.toml").open("w") as sink:
+        sink.write("""
+version = 1
+[driver]
+foo = "bar"
+[options]
+""")
+
+    with subtests.test(msg="table driver"):
+        with pytest.raises(dbapi.ProgrammingError, match="Must set 'driver' 
option"):
+            with dbapi.connect("profile://nodriver"):
+                pass
+
+
+def test_version_invalid(tmp_path, monkeypatch) -> None:
+    # Test that invalid versions are rejected
+    monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
+
+    with (tmp_path / "badversion.toml").open("w") as sink:
+        sink.write("""
+driver = "adbc_driver_sqlite"
+[options]
+""")
+    with pytest.raises(
+        dbapi.ProgrammingError, match="Profile version is not an integer"
+    ):
+        with dbapi.connect("profile://badversion"):
+            pass
+
+    with (tmp_path / "badversion.toml").open("w") as sink:
+        sink.write("""
+driver = "adbc_driver_sqlite"
+version = "1"
+[options]
+""")
+    with pytest.raises(
+        dbapi.ProgrammingError, match="Profile version is not an integer"
+    ):
+        with dbapi.connect("profile://badversion"):
+            pass
+
+    with (tmp_path / "badversion.toml").open("w") as sink:
+        sink.write("""
+driver = "adbc_driver_sqlite"
+version = 9001
+[options]
+""")
+    with pytest.raises(
+        dbapi.ProgrammingError, match="Profile version '9001' is not supported"
+    ):
+        with dbapi.connect("profile://badversion"):
+            pass
+
+
+def test_reject_malformed(tmp_path, monkeypatch) -> None:
+    # Test that invalid profiles are rejected
+    monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
+
+    with (tmp_path / "nodriver.toml").open("w") as sink:
+        sink.write("""
+version = 1
+[options]
+""")
+    with pytest.raises(dbapi.ProgrammingError, match="Must set 'driver' 
option"):
+        with dbapi.connect("profile://nodriver"):
+            pass
+
+    with (tmp_path / "nooptions.toml").open("w") as sink:
+        sink.write("""
+version = 1
+driver = "adbc_driver_sqlite"
+""")
+    with pytest.raises(dbapi.ProgrammingError, match="Profile options is not a 
table"):
+        with dbapi.connect("profile://nooptions"):
+            pass
+
+    with (tmp_path / "unknownkeys.toml").open("w") as sink:
+        sink.write("""
+version = 1
+driver = "adbc_driver_sqlite"
+[options]
+[foobar]
+""")
+    # Unknown keys is OK, though
+    with dbapi.connect("profile://unknownkeys"):
+        pass
+
+
+def test_driver_options(tmp_path, monkeypatch) -> None:
+    # Test that options are properly applied
+    monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
+
+    # On Windows, ensure we get file:///c:/ not file://c:/
+    windows = "/" if platform.system() == "Windows" else ""
+    uri = f"file://{windows}{tmp_path.resolve().absolute().as_posix()}/foo.db"
+    with (tmp_path / "sqlitetest.toml").open("w") as sink:
+        sink.write(f"""
+version = 1
+driver = "adbc_driver_sqlite"
+[options]
+uri = "{uri}"
+""")
+    with dbapi.connect("profile://sqlitetest") as conn:
+        assert conn.adbc_database.get_option("uri") == uri
+
+
+def test_load_driver_manifest(tmp_path, monkeypatch) -> None:
+    # Test a profile that references a manifest
+    manifest_path = tmp_path / "manifest"
+    profile_path = tmp_path / "profile"
+    manifest_path.mkdir()
+    profile_path.mkdir()
+    monkeypatch.setenv("ADBC_DRIVER_PATH", str(manifest_path))
+    monkeypatch.setenv("ADBC_PROFILE_PATH", str(profile_path))
+
+    with (manifest_path / "sqlitemanifest.toml").open("w") as sink:
+        sink.write("""
+manifest_version = 1
+[Driver]
+shared = "adbc_driver_sqlite"
+""")
+
+    with (profile_path / "proddata.toml").open("w") as sink:
+        sink.write("""
+version = 1
+driver = "sqlitemanifest"
+[options]
+""")
+    with dbapi.connect("profile://proddata") as conn:
+        with conn.cursor() as cursor:
+            cursor.execute("SELECT sqlite_version()")
+            assert cursor.fetchone() is not None
+
+
+def test_subdir(monkeypatch, tmp_path) -> None:
+    # Test that we can search in subdirectories
+    monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
+    subdir = tmp_path / "sqlite" / "prod"
+    subdir.mkdir(parents=True)
+
+    with (subdir / "sqlitetest.toml").open("w") as sink:
+        sink.write("""
+version = 1
+driver = "adbc_driver_sqlite"
+[options]
+""")
+    with dbapi.connect("profile://sqlite/prod/sqlitetest") as conn:
+        with conn.cursor() as cursor:
+            cursor.execute("SELECT sqlite_version()")
+            assert cursor.fetchone() is not None
+
+
+def test_absolute(monkeypatch, tmp_path) -> None:
+    # Test that we can load profiles by absolute path
+    monkeypatch.setenv("ADBC_PROFILE_PATH", str(tmp_path))
+    subdir = tmp_path / "sqlite" / "staging"
+    subdir.mkdir(parents=True)
+
+    with (subdir / "sqlitetest.toml").open("w") as sink:
+        sink.write("""
+version = 1
+driver = "adbc_driver_sqlite"
+[options]
+""")
+
+    path = (subdir / "sqlitetest.toml").absolute().as_posix()
+    with dbapi.connect(f"profile://{path}") as conn:
+        with conn.cursor() as cursor:
+            cursor.execute("SELECT sqlite_version()")
+            assert cursor.fetchone() is not None
+
+
[email protected]
+def test_user_path() -> None:
+    if platform.system() == "Darwin":
+        path = pathlib.Path.home() / "Library/Application 
Support/ADBC/Profiles"
+    elif platform.system() == "Linux":
+        path = pathlib.Path.home() / ".config/adbc/profiles"
+    elif platform.system() == "Windows":
+        path = pathlib.Path(os.environ["LOCALAPPDATA"]) / "ADBC/Profiles"
+    else:
+        pytest.skip(f"Unsupported platform {platform.system()}")
+
+    subdir = str(uuid.uuid4())
+    profile = str(uuid.uuid4())
+    subpath = path / subdir
+    path.mkdir(exist_ok=True, parents=True)
+    subpath.mkdir(exist_ok=True)
+
+    with (path / f"{profile}.toml").open("w") as sink:
+        sink.write("""
+version = 1
+driver = "adbc_driver_sqlite"
+[options]
+""")
+
+    with (subpath / f"{profile}.toml").open("w") as sink:
+        sink.write("""
+version = 1
+driver = "adbc_driver_sqlite"
+[options]
+""")
+
+    try:
+        with dbapi.connect(f"profile://{profile}") as conn:
+            with conn.cursor() as cursor:
+                cursor.execute("SELECT sqlite_version()")
+                assert cursor.fetchone() is not None
+
+        with dbapi.connect(f"profile://{subdir}/{profile}") as conn:
+            with conn.cursor() as cursor:
+                cursor.execute("SELECT sqlite_version()")
+                assert cursor.fetchone() is not None
+    finally:
+        (path / f"{profile}.toml").unlink()
+        (subpath / f"{profile}.toml").unlink()
+        subpath.rmdir()
+
+
+def test_conda(conda_prefix) -> None:
+    path = conda_prefix / "etc/adbc/profiles/"
+    path.mkdir(exist_ok=True, parents=True)
+    profile = str(uuid.uuid4())
+
+    with (path / f"{profile}.toml").open("w") as sink:
+        sink.write("""
+version = 1
+driver = "adbc_driver_sqlite"
+[options]
+""")
+    try:
+        with dbapi.connect(f"profile://{profile}") as conn:
+            with conn.cursor() as cursor:
+                cursor.execute("SELECT sqlite_version()")
+                assert cursor.fetchone() is not None
+    finally:
+        (path / f"{profile}.toml").unlink()
+
+
+def test_conda_subdir(conda_prefix) -> None:
+    subdir = str(uuid.uuid4())
+    path = conda_prefix / f"etc/adbc/profiles/{subdir}"
+    path.mkdir(exist_ok=True, parents=True)
+    profile = str(uuid.uuid4())
+
+    with (path / f"{profile}.toml").open("w") as sink:
+        sink.write("""
+version = 1
+driver = "adbc_driver_sqlite"
+[options]
+""")
+    try:
+        with dbapi.connect(f"profile://{subdir}/{profile}") as conn:
+            with conn.cursor() as cursor:
+                cursor.execute("SELECT sqlite_version()")
+                assert cursor.fetchone() is not None
+    finally:
+        (path / f"{profile}.toml").unlink()
+        path.rmdir()
+
+
+# For virtualenv tests: see Compose job python-venv

Reply via email to