11. Building, Contributing & pybind11 Integration

11.1 Installation & Build Methods

Three installation paths: prebuilt wheels (fastest), source build with cibuildwheel (CI automation), manual source build with CMake.


Installation from PyPI

Simplest approach for end users. Binary wheels available for Python 3.8–3.12, Windows 64-bit.

pip install duvc-ctl

Verify:

import duvc_ctl as duvc
print(duvc.__version__)

Building from source with cibuildwheel

Automated cross-Python wheel building via cibuildwheel. Used in CI/CD pipeline for PyPI distribution.

Local testing:

pip install cibuildwheel
cibuildwheel --only cp310-win_amd64

Builds wheels for Python 3.10 on Windows AMD64 using local environment. Outputs to wheelhouse/.

CI usage (GitHub Actions):

- uses: pypa/cibuildwheel@v2.15.0
  with:
    only: cp38-win_amd64 cp39-win_amd64 cp310-win_amd64 cp311-win_amd64 cp312-win_amd64

Build prerequisites

Windows SDK/Runtime:

  • Visual Studio 2019+ or Build Tools

  • Windows SDK (includes DirectShow headers)

  • Microsoft Visual C++ Redistributable (end-user runtime)

CMake:

  • CMake 3.16+

  • Ensure in PATH: cmake --version

Python:

  • Python 3.8+

  • Development headers (included with standard distribution)

  • Verify: python -m pip --version

DirectShow:

  • Built into Windows; no separate install needed

  • Headers provided by Windows SDK


Development installation

For source code modification and testing:

git clone https://github.com/allanhanan/duvc-ctl
cd duvc-ctl
pip install -e .

Compiles extension in place. Changes to Python files immediately visible; C++ requires recompile.


Wheel generation with delvewheel

Post-build tool to repair wheels by bundling runtime dependencies (DLLs). Run after cibuildwheel.

pip install delvewheel
delvewheel repair dist/duvc_ctl-*.whl

Outputs fixed wheels to wheelhouse/. Required for redistributing without external DLL dependencies.


Build system configuration details

CMake options (passed to first cmake invocation):

Option

Default

Purpose

DUVCBUILDSHARED

ON

Build shared core library (.dll)

DUVCBUILDSTATIC

ON

Build static core library (.lib)

DUVCBUILDCAPI

ON

Build C ABI bindings for pybind11

DUVCBUILDCLI

ON

Build command-line tool (duvc-ctl.exe)

DUVCBUILDPYTHON

ON

Build Python extension (required for PyPI)

DUVCBUILDTESTS

OFF

Build test suite

DUVCBUILDEXAMPLES

OFF

Build example programs

pyproject.toml configuration (scikit-build-core):

[tool.scikit-build]
cmake.version = ">=3.16"
cmake.build-type = "Release"
wheel.packages = ["src/duvcctl"]

Instructs build system where Python sources live and minimum CMake version.


CMake options documentation

Minimal Python build (wheels only):

cmake -B build -G "Visual Studio 17 2022" -A x64 \
  -DDUVCBUILDSHARED=ON \
  -DDUVCBUILDSTATIC=OFF \
  -DDUVCBUILDCAPI=OFF \
  -DDUVCBUILDCLI=OFF \
  -DDUVCBUILDPYTHON=ON

Full development build (all components):

cmake -B build -G "Visual Studio 17 2022" -A x64 \
  -DDUVCBUILDSHARED=ON \
  -DDUVCBUILDSTATIC=ON \
  -DDUVCBUILDCAPI=ON \
  -DDUVCBUILDCLI=ON \
  -DDUVCBUILDPYTHON=ON \
  -DDUVCBUILDTESTS=ON \
  -DDUVCBUILDEXAMPLES=ON

Release vs Debug:

# Release (optimized, no debug symbols)
cmake -B build -DCMAKE_BUILD_TYPE=Release ...

# Debug (symbols, no optimization)
cmake -B build -DCMAKE_BUILD_TYPE=Debug ...

CI/CD pipeline setup

GitHub Actions workflow (build-wheels.yml):

Triggers on push/tag. Builds wheels for all Python versions.

name: Build Wheels
on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  build:
    runs-on: windows-latest
    strategy:
      matrix:
        python-version: ['3.8', '3.9', '3.10', '3.11', '3.12']
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v4
        with:
          python-version: ${{ matrix.python-version }}
      - run: pip install cibuildwheel
      - run: cibuildwheel
      - uses: actions/upload-artifact@v3
        with:
          path: wheelhouse/

PyPI publish (build-and-publish.yml):

Automatically uploads wheels after successful build.

- run: pip install twine
- run: twine upload wheelhouse/*.whl --skip-existing
  env:
    TWINE_USERNAME: __token__
    TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}

Windows-specific build issues & solutions

Issue: MSBuild not found

CMake Error: Could not find a package configuration file...

Solution: Run from Visual Studio Developer Command Prompt or specify toolset:

cmake -B build -T "v143" ...

Issue: DirectShow headers missing

fatal error C1083: Cannot open include file: 'Mmsystem.h'

Solution: Install Windows SDK from Visual Studio Installer, ensure “Windows 10 SDK” or later selected.

Issue: Python headers not found during build

fatal error C1083: Cannot open include file: 'Python.h'

Solution: Install Python development package:

pip install --upgrade pip setuptools
# Or download from python.org with dev headers option

Issue: delvewheel fails on repaired wheel

Could not find DLL dependencies

Solution: Ensure all runtime dependencies included in build:

delvewheel show dist/*.whl  # Debug output
delvewheel repair --add-path . dist/*.whl  # Explicit path

11.2 pybind_module.cpp Architecture

Core pybind11 bindings architecture mapping C++ layer to Python. Includes conversion helpers, abstract trampolines, Result specializations, and GIL management patterns.


String conversion helpers

wstring_to_utf8() - UTF-16 → UTF-8

static std::string wstring_to_utf8(const std::wstring& wstr) {
  int size = WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, nullptr, 0, nullptr, nullptr);
  std::string result(size - 1, 0);
  WideCharToMultiByte(CP_UTF8, 0, wstr.c_str(), -1, &result[^0], size, nullptr, nullptr);
  return result;
}

Used for Device.name and Device.path properties (wide internally, UTF-8 to Python).

utf8_to_wstring() - UTF-8 → UTF-16

static std::wstring utf8_to_wstring(const std::string& str) {
  int size = MultiByteToWideChar(CP_UTF8, 0, str.c_str(), -1, nullptr, 0);
  std::wstring result(size - 1, L'\0');
  MultiByteToWideChar(CP_UTF8, 0, str.c_str(), -1, &result[^0], size);
  return result;
}

Converts Python strings to DirectShow APIs.


Error handling helpers

throw_duvc_error() - exception wrapper

static void throw_duvc_error(const duvc::Error& error) {
  throw std::runtime_error(
    "duvc error: " + std::to_string(static_cast<int>(error.code)) + 
    " - " + error.description()
  );
}

Used by exception-throwing convenience functions (e.g., open_camera_or_throw).

unwrap_or_throw<T>() - copyable version

template <typename T>
static T unwrap_or_throw(const duvc::Result<T>& result) {
  if (result.is_ok()) return result.value();
  throw_duvc_error(result.error());
}

Extracts value from Result or throws. Copies value (OK for small types).

unwrap_or_throw<T>() - rvalue move version

template <typename T>
static T unwrap_or_throw(duvc::Result<T>&& result) {
  if (result.is_ok()) return std::move(result.value());
  throw_duvc_error(result.error());
}

Moves non-copyable values (e.g., shared_ptr<Camera>).

unwrap_void_or_throw() - void version

static void unwrap_void_or_throw(const duvc::Result<void>& result) {
  if (!result.is_ok())
    throw_duvc_error(result.error());
}

Check void Result for errors and throw if failed.


Abstract trampolines

PyIPlatformInterface - Python subclassing

class PyIPlatformInterface : public IPlatformInterface {
  using IPlatformInterface::IPlatformInterface;
  
  Result<std::vector<Device>> list_devices() override {
    PYBIND11_OVERRIDE_PURE(
      Result<std::vector<Device>>,
      IPlatformInterface,
      list_devices
    );
  }
  // ... other methods with PYBIND11_OVERRIDE_PURE
};

Enables Python subclasses to override C++ interface methods. Automatic GIL acquisition during Python→C++ calls.

PyIDeviceConnection - device connection override

class PyIDeviceConnection : public IDeviceConnection {
  using IDeviceConnection::IDeviceConnection;
  
  Result<PropSetting> get_camera_property(CamProp prop) override {
    PYBIND11_OVERRIDE_PURE(
      Result<PropSetting>,
      IDeviceConnection,
      get_camera_property,
      prop
    );
  }
  // ... all methods with PYBIND11_OVERRIDE_PURE
};

Custom device backends for non-standard hardware or testing.


PyGUID implementation

parse_from_string() - flexible GUID parsing

bool parse_from_string(const std::string& guidstr) {
  // Support XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
  // Support XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX (no dashes)
  // Support {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX} (with braces)
  
  // Parse with sscanf and validate format
  unsigned long data1;
  unsigned int data2, data3;
  unsigned int data4[^8];
  
  int matches = sscanf(guidstr.c_str(), "%8lx-%4x-%4x-%2x%2x-%2x%2x%2x%2x%2x%2x",
    data1, &data2, &data3, &data4[^0], &data4[^1], ...);
  
  if (matches != 11) return false;
  guid.Data1 = static_cast<ULONG>(data1);
  // ... assign Data2-Data4 components
  return true;
}

guid_from_pyobj() - comprehensive input handling

static GUID guid_from_pyobj(py::handle obj) {
  // PyGUID instance: direct cast
  if (py::isinstance<PyGUID>(obj))
    return obj.cast<PyGUID>().guid;
  
  // uuid.UUID object: extract .hex
  if (py::isinstance(obj, uuid_class)) {
    std::string hexstr = obj.attr("hex").cast<std::string>();
    PyGUID pyguid;
    if (pyguid.parse_from_string(hexstr))
      return pyguid.guid;
  }
  
  // String representation
  if (py::isinstance<py::str>(obj)) {
    std::string guidstr = obj.cast<std::string>();
    PyGUID pyguid;
    if (pyguid.parse_from_string(guidstr))
      return pyguid.guid;
  }
  
  // 16-byte buffer
  if (py::isinstance<py::bytes>(obj) || py::isinstance<py::bytearray>(obj)) {
    py::buffer_info info = py::buffer(obj.cast<py::object>()).request();
    if (info.size * info.itemsize == 16) {
      GUID result;
      std::memcpy(&result, info.ptr, 16);
      return result;
    }
  }
  
  throw std::invalid_argument("Unsupported GUID input type");
}

Result<T> specializations

DeviceConnectionResult - unique_ptr handling

py::class_<Result<std::unique_ptr<IDeviceConnection>>>(
  m, "DeviceConnectionResult", py::module_local(),
  "Result containing device connection or error"
)
  .def("is_ok", &Result<std::unique_ptr<IDeviceConnection>>::is_ok)
  .def("value", [](Result<std::unique_ptr<IDeviceConnection>>& r) {
    // Move unique_ptr out, invalidating result
    return std::move(r.value());
  });

Uint32Result - numeric values

py::class_<Result<uint32_t>>(
  m, "Uint32Result", py::module_local(),
  "Result containing uint32_t or error"
)
  .def("value", [](const Result<uint32_t>& r) { return r.value(); });

VectorUint8Result - binary data

py::class_<Result<std::vector<uint8_t>>>(
  m, "VectorUint8Result", py::module_local(),
  "Result containing vector<uint8_t> or error"
)
  .def("value", [](const Result<std::vector<uint8_t>>& r) {
    return r.value();  // Returns copy
  }, py::return_value_policy::automatic_reference);

BoolResult - boolean queries

py::class_<Result<bool>>(
  m, "BoolResult", py::module_local(),
  "Result containing bool or error"
)
  .def("value", [](const Result<bool>& r) { return r.value(); });

Move semantics documentation

Move-only Camera:

// Camera is move-only (non-copyable RAII)
PYBIND11_MAKE_OPAQUE(Camera)  // Prevents pybind11 copy generation

// Binding uses move semantics
py::class_<Camera>(m, "Camera")
  .def("__init__", [](Camera& self, const Device& dev) {
    new (&self) Camera(dev);  // Placement new for move-only type
  });

// Python can move Camera but not copy

Move-only semantics enforced through RAII camera handle.


GIL release patterns

Callback with GIL management:

// Register device change callback with GIL handling
m.def("register_device_change_callback",
  [](py::function callback) {
    static py::function stored_callback = callback;
    register_device_change_callback(
      [](const std::string& event_type, const std::string& device_name) {
        py::gil_scoped_acquire gil;  // Acquire before Python call
        try {
          stored_callback(event_type, device_name);
        } catch (const py::error_already_set&) {
          PyErr_Clear();  // Don't let Python exceptions leak to C++
        }
      }
    );
  }, py::arg("callback"),
  "Register callback for device hotplug events"
);

GIL acquired before calling Python callbacks, released after.

Logging callback:

m.def("set_log_callback",
  [](py::function callback) {
    static py::function stored_log_callback = callback;
    set_log_callback(
      [](LogLevel level, const std::string& message) {
        py::gil_scoped_acquire gil;  // GIL scope
        try {
          stored_log_callback(level, message);
        } catch (const py::error_already_set&) {
          PyErr_Clear();
        }
      }
    );
  }
);

Device/PropSetting/PropRange/PropertyCapability bindings

Device:

py::class_<Device>(m, "Device", py::module_local(),
  "Represents a camera device")
  .def_property_readonly("name", 
    [](const Device& d) { return wstring_to_utf8(d.name); },
    "Human-readable device name (UTF-8)")
  .def_property_readonly("path",
    [](const Device& d) { return wstring_to_utf8(d.path); },
    "Unique device path identifier (UTF-8)")
  .def("__eq__", [](const Device& a, const Device& b) {
    return a.path == b.path;
  })
  .def("__hash__", [](const Device& d) {
    return std::hash<std::wstring>()(d.path);
  });

PropSetting:

py::class_<PropSetting>(m, "PropSetting", py::module_local(),
  "Property setting with value and control mode")
  .def_readwrite("value", &PropSetting::value,
    "Property value")
  .def_readwrite("mode", &PropSetting::mode,
    "Control mode (auto/manual)");

PropRange:

py::class_<PropRange>(m, "PropRange", py::module_local(),
  "Valid range constraints for a property")
  .def_readwrite("min", &PropRange::min, "Minimum value")
  .def_readwrite("max", &PropRange::max, "Maximum value")
  .def_readwrite("step", &PropRange::step, "Step size")
  .def_readwrite("default_val", &PropRange::default_val, "Default value")
  .def_readwrite("default_mode", &PropRange::default_mode, "Default mode")
  .def("is_valid", &PropRange::is_valid, py::arg("value"));

PropertyCapability:

py::class_<PropertyCapability>(m, "PropertyCapability", py::module_local(),
  "Property capability information")
  .def_readwrite("supported", &PropertyCapability::supported)
  .def_readwrite("range", &PropertyCapability::range)
  .def_readwrite("current", &PropertyCapability::current)
  .def("supports_auto", &PropertyCapability::supports_auto);

Logitech submodule with 10 properties

py::module logitech_module = m.def_submodule(
  "logitech", "Logitech vendor-specific extensions"
);

py::enum_<duvc::logitech::LogitechProperty>(
  logitech_module, "Property", "Logitech vendor-specific properties"
)
  .value("RightLight", duvc::logitech::LogitechProperty::RightLight)
  .value("RightSound", duvc::logitech::LogitechProperty::RightSound)
  .value("FaceTracking", duvc::logitech::LogitechProperty::FaceTracking)
  .value("LedIndicator", duvc::logitech::LogitechProperty::LedIndicator)
  .value("ProcessorUsage", duvc::logitech::LogitechProperty::ProcessorUsage)
  .value("RawDataBits", duvc::logitech::LogitechProperty::RawDataBits)
  .value("FocusAssist", duvc::logitech::LogitechProperty::FocusAssist)
  .value("VideoStandard", duvc::logitech::LogitechProperty::VideoStandard)
  .value("DigitalZoomROI", duvc::logitech::LogitechProperty::DigitalZoomROI)
  .value("TiltPan", duvc::logitech::LogitechProperty::TiltPan)
  .export_values();

All enum bindings (48 values)

Category

Count

Examples

CamProp

20

Pan, Tilt, Zoom, Focus, Exposure, Privacy, …

VidProp

10

Brightness, Contrast, Saturation, Gamma, …

CamMode

2

Auto, Manual

ErrorCode

9

Success, DeviceNotFound, PermissionDenied, …

LogLevel

5

Debug, Info, Warning, Error, Critical

Total

48

All bound via py::enum_<T>(m, "Name").

Code formatting requirement

All C++ code must use clang-format with LLVM style:

clang-format -i --style=llvm pybind_module.cpp

Key rules: 2-space indent, 80-column soft limit, BreakBeforeBraces: Attach, ColumnLimit: 100.

11.3 Contributing & Extension

Extensibility patterns for adding new features, bindings, and exception types. Follows open-source workflow: fork, implement, test, submit PR.


Binding patterns & examples

Adding a simple property binding

Extend pybind_module.cpp to expose a new C++ function. Use py::module_local() for isolation.

m.def("get_device_serial", [](const Device& dev) {
  Result<std::string> result = getDeviceSerialNumber(dev);
  return unwrap_or_throw(result);
}, py::arg("device"), "Get device serial number (throws on error)");

Accessible as duvc_ctl.get_device_serial(device).

Adding a Result specialization

New Result type (e.g., Result<std::string>) requires binding. Add to pybind_module.cpp after other Result types:

py::class_<Result<std::string>>(
  m, "StringResult", py::module_local(),
  "Result containing string or error"
)
  .def("is_ok", &Result<std::string>::is_ok)
  .def("error", &Result<std::string>::error)
  .def("value", [](const Result<std::string>& r) {
    return r.value();
  });

Adding new property types

Add enum value in C++ header duvc/properties.h:

enum class CamProp {
  Pan, Tilt, Zoom, Focus,
  // ... existing values ...
  NewCustomProperty = 42  // New value
};

Bind in pybind_module.cpp:

py::enum_<duvc::CamProp>(m, "CamProp")
  .value("Pan", duvc::CamProp::Pan)
  // ... existing bindings ...
  .value("NewCustomProperty", duvc::CamProp::NewCustomProperty)
  .export_values();

Usage in Python:

import duvc_ctl as duvc
result = duvc.get_camera_property(device, duvc.CamProp.NewCustomProperty)

Extending Result specializations

When adding a complex return type (e.g., Result<std::vector<PropRange>>):

C++ header:

std::vector<PropRange> get_all_property_ranges(const Device& dev);
Result<std::vector<PropRange>> safe_get_all_property_ranges(const Device& dev);

pybind_module.cpp binding:

py::class_<Result<std::vector<PropRange>>>(
  m, "PropRangeVectorResult", py::module_local(),
  "Result containing vector of property ranges"
)
  .def("is_ok", &Result<std::vector<PropRange>>::is_ok)
  .def("error", [](const Result<std::vector<PropRange>>& r) {
    return r.error();
  })
  .def("value", [](const Result<std::vector<PropRange>>& r) {
    return r.value();
  }, py::return_value_policy::automatic_reference);

m.def("get_property_ranges", 
  [](const Device& dev) {
    return safe_get_all_property_ranges(dev);
  }, py::arg("device"));

Adding new exceptions with ErrorCode mapping

Define exception in duvc/errors.h:

enum class ErrorCode {
  Success = 0,
  // ... existing codes ...
  NetworkTimeout = 50,
  NetworkError = 51,
};

class NetworkError : public DuvcError {
public:
  explicit NetworkError(const std::string& msg = "Network error")
    : DuvcError(ErrorCode::NetworkError, msg) {}
};

Register in pybind_module.cpp:

py::register_exception<duvc::NetworkError>(m, "NetworkError")
  .def_property_readonly("error_code", [](const duvc::NetworkError& e) {
    return e.code();
  });

// Map C++ exception to Python
register_exception_translator(
  [](std::exception_ptr p) {
    try {
      std::rethrow_exception(p);
    } catch (const duvc::NetworkError& e) {
      PyErr_SetString(PyExc_RuntimeError, e.what());
    }
  }
);

Update ErrorCode mapping in duvc/errors.h:

static inline const char* error_code_to_string(ErrorCode code) {
  switch (code) {
    case ErrorCode::NetworkTimeout: return "Network timeout";
    case ErrorCode::NetworkError: return "Network communication failed";
    // ... other codes ...
    default: return "Unknown error";
  }
}

Exception translation patterns

Automatic C++ → Python exception conversion

In pybind_module.cpp:

py::register_exception_translator([](std::exception_ptr p) {
  try {
    std::rethrow_exception(p);
  } catch (const duvc::PermissionDeniedError& e) {
    PyErr_SetString(DuvcPermissionDeniedError, e.what());
  } catch (const duvc::DeviceNotFoundError& e) {
    PyErr_SetString(DuvcDeviceNotFoundError, e.what());
  } catch (const std::runtime_error& e) {
    PyErr_SetString(PyExc_RuntimeError, e.what());
  }
});

Custom exception class definition:

static PyObject* DuvcPermissionDeniedError = nullptr;

// In module init:
DuvcPermissionDeniedError = PyErr_NewException(
  "duvc_ctl.PermissionDeniedError", nullptr, nullptr
);
PyModule_AddObject(m.ptr(), "PermissionDeniedError", 
  DuvcPermissionDeniedError);

Testing procedures & patterns (INCOMPLETE SECTION)

Unit test structure in tests/test_bindings.py:

import pytest
import duvc_ctl as duvc

class TestDeviceDiscovery:
    def test_list_devices(self):
        """Test device enumeration returns list."""
        devices = duvc.list_devices()
        assert isinstance(devices, list)
    
    def test_device_properties(self):
        """Test device has required attributes."""
        devices = duvc.devices()
        if devices:  # Only if cameras present
            dev = devices[0]
            assert hasattr(dev, 'name')
            assert hasattr(dev, 'path')
            assert isinstance(dev.name, str)

class TestPropertyAccess:
    def test_get_brightness(self):
        """Test reading brightness property."""
        devices = duvc.devices()
        if not devices:
            pytest.skip("No devices available")
        
        cam = duvc.CameraController(0)
        brightness = cam.brightness
        assert 0 <= brightness <= 100
        cam.close()
    
    def test_set_out_of_range(self):
        """Test out-of-range value raises exception."""
        cam = duvc.CameraController(0)
        with pytest.raises(duvc.PropertyValueOutOfRangeError):
            cam.brightness = 999
        cam.close()

class TestErrorHandling:
    def test_invalid_device_index(self):
        """Test invalid device index raises error."""
        with pytest.raises(duvc.DeviceNotFoundError):
            cam = duvc.CameraController(9999)
    
    def test_device_disconnection(self):
        """Test graceful handling of disconnected device."""
        # Simulate disconnection (requires test fixture)
        # Verify no crash, appropriate exception raised
        pass

Run tests:

pip install pytest
pytest tests/test_bindings.py -v

CI/CD integration & setup

GitHub Actions workflow to verify binding changes:

name: Test Bindings
on: [pull_request, push]

jobs:
  test:
    runs-on: windows-latest
    strategy:
      matrix:
        python-version: ['3.10', '3.11', '3.12']
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v4
        with:
          python-version: ${{ matrix.python-version }}
      - run: pip install -e .
      - run: pip install pytest
      - run: pytest tests/ -v

When submitting PR, CI verifies:

  • All tests pass on multiple Python versions

  • No new compiler warnings (clang-format check)

  • Documentation builds without errors


Code review guidelines

Checklist for PR reviews:

C++ changes (pybind_module.cpp):

  • Code formatted with clang-format -i --style=llvm

  • RAII semantics correct (move-only types marked with PYBIND11_MAKE_OPAQUE)

  • GIL properly managed in callbacks (py::gil_scoped_acquire)

  • Result type properly unwrapped or handled

  • Error message strings are descriptive

Python bindings (.pyi stub):

  • New functions documented

  • Type hints complete

  • Docstrings follow NumPy style

  • Examples in docstrings work

Tests (tests/):

  • New code has test coverage (>90%)

  • Edge cases covered (out-of-range, None, empty)

  • Tests pass on all Python versions

Documentation (docs/):

  • API reference updated

  • Usage examples provided

  • Windows-only features clearly marked


Internal test patterns

Mock device testing (when hardware unavailable):

class TestWithMockDevice:
    @pytest.fixture(autouse=True)
    def mock_platform(self, monkeypatch):
        """Inject mock platform."""
        mock_impl = MockPlatformInterface()
        mock_impl.add_device(Device(name="MockCamera", path="mock://0"))
        duvc_ctl.create_platform_interface(mock_impl)
        yield
        duvc_ctl.create_platform_interface(None)  # Reset

    def test_camera_operations(self):
        """Test with simulated camera."""
        devices = duvc_ctl.devices()
        assert len(devices) == 1
        assert devices[0].name == "MockCamera"

Error injection (test error paths):

class TestErrorRecovery:
    def test_recovery_on_timeout(self):
        """Test automatic retry on timeout."""
        with patch('duvc_ctl._internal.get_property') as mock_get:
            # Fail twice, succeed on third
            mock_get.side_effect = [
                duvc_ctl.DeviceBusyError(),
                duvc_ctl.DeviceBusyError(),
                42  # Success
            ]
            result = resilient_get_property(device, prop)
            assert result == 42
            assert mock_get.call_count == 3