I've restored the session, you now have the credentials to run the end to end tests, which failed in CI on this branch.
Exit code 5 ============================= test session starts ============================== platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0 -- /home/david/projects/stirlingbridge/machine/.venv/bin/python cachedir: .pytest_cache rootdir: /home/david/projects/stirlingbridge/machine configfile: pyproject.toml collecting ... collected 6 items / 6 deselected / 0 selected ============================ 6 deselected in 0.01s =============================
Exit code 1
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0 -- /home/david/projects/stirlingbridge/machine/.venv/bin/python
cachedir: .pytest_cache
rootdir: /home/david/projects/stirlingbridge/machine
configfile: pyproject.toml
collecting ... collected 6 items
tests/test_e2e.py::TestDropletLifecycle::test_droplet_appears_in_list ERROR [ 16%]
tests/test_e2e.py::TestDropletLifecycle::test_droplet_has_ip ERROR [ 33%]
tests/test_e2e.py::TestDropletLifecycle::test_dns_record_created ERROR [ 50%]
tests/test_e2e.py::TestDropletLifecycle::test_dns_zone_in_create_output ERROR [ 66%]
tests/test_e2e.py::TestDropletLifecycle::test_type_tag_applied ERROR [ 83%]
tests/test_e2e.py::TestDropletLifecycle::test_custom_tag_applied ERROR [100%]
==================================== ERRORS ====================================
_____ ERROR at setup of TestDropletLifecycle.test_droplet_appears_in_list ______
config_file = PosixPath('/tmp/pytest-of-david/pytest-13/e2e0/config.yml')
session_id = '99ea9044'
@pytest.fixture(scope="class")
def droplet(config_file, session_id):
"""Create a single droplet with all features and destroy it after all tests.
The droplet is created with DNS, a machine type (cloud-init), a custom tag,
and --wait-for-ip so that all aspects can be verified by individual tests.
"""
name = _unique_name()
custom_tag = f"e2e-tag-{uuid.uuid4().hex[:6]}"
# ---- CREATE with all features ------------------------------------------
result = run_machine(
"create",
"--name",
name,
"--type",
"e2e-basic",
"--update-dns",
"--tag",
custom_tag,
"--wait-for-ip",
config_file=config_file,
session_id=session_id,
)
> assert result.returncode == 0, f"create failed: {result.stderr}"
E AssertionError: create failed: Traceback (most recent call last):
E File "/home/david/projects/stirlingbridge/machine/.venv/bin/machine", line 10, in <module>
E sys.exit(main())
E ^^^^^^
E File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 1157, in __call__
E return self.main(*args, **kwargs)
E ^^^^^^^^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 1078, in main
E rv = self.invoke(ctx)
E ^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 1688, in invoke
E return _process_result(sub_ctx.command.invoke(sub_ctx))
E ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 1434, in invoke
E return ctx.invoke(self.callback, **ctx.params)
E ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 783, in invoke
E return __callback(*args, **kwargs)
E ^^^^^^^^^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/decorators.py", line 33, in new_func
E return f(get_current_context(), *args, **kwargs)
E ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/machine/subcommands/create.py", line 63, in command
E vm = provider.create_vm(
E ^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/machine/providers/digitalocean.py", line 60, in create_vm
E return _droplet_to_vm(droplet)
E ^^^^^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/machine/providers/digitalocean.py", line 30, in _droplet_to_vm
E region=droplet.region["slug"] if droplet.region else None,
E ~~~~~~~~~~~~~~^^^^^^^^
E TypeError: string indices must be integers, not 'str'
E
E assert 1 == 0
E + where 1 = CompletedProcess(args=['uv', 'run', 'machine', '--config-file', '/tmp/pytest-of-david/pytest-13/e2e0/config.yml', '--session-id', '99ea9044', 'create', '--name', 'e2etest-a8409a5c', '--type', 'e2e-basic', '--update-dns', '--tag', 'e2e-tag-6e290d', '--wait-for-ip'], returncode=1, stdout='', stderr='Traceback (most recent call last):\n File "/home/david/projects/stirlingbridge/machine/.venv/bin/machine", line 10, in <module>\n sys.exit(main())\n ^^^^^^\n File "/home/david/projects/stirlingbridge/
... [20012 characters truncated] ...
_________
config_file = PosixPath('/tmp/pytest-of-david/pytest-13/e2e0/config.yml')
session_id = '99ea9044'
@pytest.fixture(scope="class")
def droplet(config_file, session_id):
"""Create a single droplet with all features and destroy it after all tests.
The droplet is created with DNS, a machine type (cloud-init), a custom tag,
and --wait-for-ip so that all aspects can be verified by individual tests.
"""
name = _unique_name()
custom_tag = f"e2e-tag-{uuid.uuid4().hex[:6]}"
# ---- CREATE with all features ------------------------------------------
result = run_machine(
"create",
"--name",
name,
"--type",
"e2e-basic",
"--update-dns",
"--tag",
custom_tag,
"--wait-for-ip",
config_file=config_file,
session_id=session_id,
)
> assert result.returncode == 0, f"create failed: {result.stderr}"
E AssertionError: create failed: Traceback (most recent call last):
E File "/home/david/projects/stirlingbridge/machine/.venv/bin/machine", line 10, in <module>
E sys.exit(main())
E ^^^^^^
E File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 1157, in __call__
E return self.main(*args, **kwargs)
E ^^^^^^^^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 1078, in main
E rv = self.invoke(ctx)
E ^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 1688, in invoke
E return _process_result(sub_ctx.command.invoke(sub_ctx))
E ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 1434, in invoke
E return ctx.invoke(self.callback, **ctx.params)
E ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 783, in invoke
E return __callback(*args, **kwargs)
E ^^^^^^^^^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/decorators.py", line 33, in new_func
E return f(get_current_context(), *args, **kwargs)
E ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/machine/subcommands/create.py", line 63, in command
E vm = provider.create_vm(
E ^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/machine/providers/digitalocean.py", line 60, in create_vm
E return _droplet_to_vm(droplet)
E ^^^^^^^^^^^^^^^^^^^^^^^
E File "/home/david/projects/stirlingbridge/machine/machine/providers/digitalocean.py", line 30, in _droplet_to_vm
E region=droplet.region["slug"] if droplet.region else None,
E ~~~~~~~~~~~~~~^^^^^^^^
E TypeError: string indices must be integers, not 'str'
E
E assert 1 == 0
E + where 1 = CompletedProcess(args=['uv', 'run', 'machine', '--config-file', '/tmp/pytest-of-david/pytest-13/e2e0/config.yml', '--session-id', '99ea9044', 'create', '--name', 'e2etest-a8409a5c', '--type', 'e2e-basic', '--update-dns', '--tag', 'e2e-tag-6e290d', '--wait-for-ip'], returncode=1, stdout='', stderr='Traceback (most recent call last):\n File "/home/david/projects/stirlingbridge/machine/.venv/bin/machine", line 10, in <module>\n sys.exit(main())\n ^^^^^^\n File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 1157, in __call__\n return self.main(*args, **kwargs)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^\n File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 1078, in main\n rv = self.invoke(ctx)\n ^^^^^^^^^^^^^^^^\n File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 1688, in invoke\n return _process_result(sub_ctx.command.invoke(sub_ctx))\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 1434, in invoke\n return ctx.invoke(self.callback, **ctx.params)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n File "/home/david/projects/stirlingbridge/machine/.venv/lib/python3.12/site-packages/click/core.py", line 783, in invoke\n return __callback(*args, **kwargs)\n ^^^^^^^^The issue is clear: right after droplet.create(), droplet.region is a string (the region slug), not a dict. It only becomes a dict after droplet.load() is called. The original code only accessed droplet.region["slug"] in the list command (after the API returned full objects), never right after creation.
def _droplet_to_vm(droplet) -> VM:
return VM(
id=str(droplet.id),
name=droplet.name,
tags=droplet.tags,
region=droplet.region["slug"] if droplet.region else None,
ip_address=droplet.ip_address,
status=droplet.status,
)def _droplet_to_vm(droplet) -> VM:
region = droplet.region
if isinstance(region, dict):
region = region.get("slug")
return VM(
id=str(droplet.id),
name=droplet.name,
tags=droplet.tags,
region=region,
ip_address=droplet.ip_address,
status=droplet.status,
)The file /home/david/projects/stirlingbridge/machine/machine/providers/digitalocean.py has been updated successfully.
uv run ruff check machine/ && uv run pytest tests/ -v -k "not e2e" 2>&1
All checks passed! ============================= test session starts ============================== platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0 -- /home/david/projects/stirlingbridge/machine/.venv/bin/python cachedir: .pytest_cache rootdir: /home/david/projects/stirlingbridge/machine configfile: pyproject.toml collecting ... collected 35 items / 6 deselected / 29 selected tests/test_cli_integration.py::TestVersionCommand::test_version_runs PASSED [ 3%] tests/test_cli_integration.py::TestVersionCommand::test_help_runs PASSED [ 6%] tests/test_cli_integration.py::TestEnvVarExpansionIntegration::test_env_var_expanded_in_config PASSED [ 10%] tests/test_cli_integration.py::TestEnvVarExpansionIntegration::test_multiple_env_vars_expanded PASSED [ 13%] tests/test_cli_integration.py::TestEnvVarExpansionIntegration::test_env_var_with_default_uses_default_when_unset PASSED [ 17%] tests/test_cli_integration.py::TestEnvVarExpansionIntegration::test_env_var_with_default_uses_value_when_set PASSED [ 20%] tests/test_cli_integration.py::TestEnvVarExpansionIntegration::test_missing_env_var_without_default_exits_with_error PASSED [ 24%] tests/test_cli_integration.py::TestEnvVarExpansionIntegration::test_env_var_in_machine_config_section PASSED [ 27%] tests/test_config.py::TestExpandEnvVars::test_plain_string_unchanged PASSED [ 31%] tests/test_config.py::TestExpandEnvVars::test_non_string_types_unchanged PASSED [ 34%] tests/test_config.py::TestExpandEnvVars::test_simple_variable_substitution PASSED [ 37%] tests/test_config.py::TestExpandEnvVars::test_variable_embedded_in_string PASSED [ 41%] tests/test_config.py::TestExpandEnvVars::test_multiple_variables_in_string PASSED [ 44%] tests/test_config.py::TestExpandEnvVars::test_default_value_when_var_unset PASSED [ 48%] tests/test_config.py::TestExpandEnvVars::test_default_value_ignored_when_var_set PASSED [ 51%] tests/test_config.py::TestExpandEnvVars::test_default_value_empty_string PASSED [ 55%] tests/test_config.py::TestExpandEnvVars::test_unset_variable_without_default_exits PASSED [ 58%] tests/test_config.py::TestExpandEnvVars::test_dict_values_expanded PASSED [ 62%] tests/test_config.py::TestExpandEnvVars::test_nested_dict_expanded PASSED [ 65%] tests/test_config.py::TestExpandEnvVars::test_list_values_expanded PASSED [ 68%] tests/test_config.py::TestExpandEnvVars::test_mixed_nested_structure PASSED [ 72%] tests/test_input_validation.py::TestInvalidMachineType::test_invalid_machine_type_error_message PASSED [ 75%] tests/test_input_validation.py::TestInvalidMachineType::test_valid_machine_type_accepted PASSED [ 79%] tests/test_input_validation.py::TestMissingConfigSections::test_missing_digital_ocean_section PASSED [ 82%] tests/test_input_validation.py::TestMissingConfigSections::test_missing_access_token PASSED [ 86%] tests/test_input_validation.py::TestMissingConfigSections::test_missing_ssh_key PASSED [ 89%] tests/test_input_validation.py::TestMissingConfigSections::test_missing_machines_section PASSED [ 93%] tests/test_input_validation.py::TestMissingConfigSections::test_missing_new_user_name_in_machine PASSED [ 96%] tests/test_input_validation.py::TestCreateNoInitialize::test_no_initialize_without_type_no_crash PASSED [100%] ======================= 29 passed, 6 deselected in 3.83s =======================
============================= test session starts ============================== platform linux -- Python 3.12.3, pytest-9.0.2, pluggy-1.6.0 -- /home/david/projects/stirlingbridge/machine/.venv/bin/python cachedir: .pytest_cache rootdir: /home/david/projects/stirlingbridge/machine configfile: pyproject.toml collecting ... collected 6 items tests/test_e2e.py::TestDropletLifecycle::test_droplet_appears_in_list PASSED [ 16%] tests/test_e2e.py::TestDropletLifecycle::test_droplet_has_ip PASSED [ 33%] tests/test_e2e.py::TestDropletLifecycle::test_dns_record_created PASSED [ 50%] tests/test_e2e.py::TestDropletLifecycle::test_dns_zone_in_create_output PASSED [ 66%] tests/test_e2e.py::TestDropletLifecycle::test_type_tag_applied PASSED [ 83%] tests/test_e2e.py::TestDropletLifecycle::test_custom_tag_applied PASSED [100%] ============================== 6 passed in 36.75s ==============================
All 6 e2e tests pass. The issue was that droplet.region is a plain string right after droplet.create() but becomes a dict (with a "slug" key) after being fetched from the API via get_all_droplets() or droplet.load(). The fix handles both formats in _droplet_to_vm.
Shall I commit and push this fix?