I have noticed an issue running this utility in a browser context using Pyodide. The problem relates to the reading of the session id from the file it is stored in. When another application modifies the session-id.yml file, it appears the the machine utility does not see the new version of the file. Initially it was assumed this was some sort of filesystem consistency bug, however extensive testing has eliminited this possibility. Therefore attention turned to the specifics of the code in this project that reads the session id. When I looked at the code I wondered about something: session id is effectively read at line 24 of the file main.py, where the default value for the --session-id parameter is specified for Click. I have a theory as to why this is significant, and the cause of the problem observed with stale read data. I think that somehow the call to load_session_id() gets reified into a module context (e.g. the Click module). This theory becuase the only plausible explaination for the observed behavior is that the session id is somehow stored in along with a module. This is because modules are cached in Pyodide and because when we tried code that invalidated the module cache before each run, the problem went away. I wonder if you have any thoughts on this, and any suggestions how to refactor the main.py code to avoid this issue, if indeed it exists.
1 import os
2
3 import click
4
5 from machine import config
6 from machine import constants
7 from machine.di import d
8 from machine.log import output
9 from machine.providers import create_provider
10 from machine.subcommands import create, destroy, info, list, projects, ssh_keys, domains, list_domain, types, status
11 from machine.types import CliOptions, MainCmdCtx
12 from machine.util import load_session_id
13
14 CLICK_CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
15
16
17 @click.group(context_settings=CLICK_CONTEXT_SETTINGS)
18 @click.option("--debug", is_flag=True, default=False, help="Enable debug output")
19 @click.option("--quiet", is_flag=True, default=False, help="Suppress all non-essential output")
20 @click.option("--verbose", is_flag=True, default=False, help="Enable verbose output")
21 @click.option("--dry-run", is_flag=True, default=False, help="Run but do not do anything")
22 @click.option("--config-file", metavar="<PATH>", help=f"Specify the config file (default {constants.default_config_file_path})")
23 @click.option(
24 "--session-id", metavar="<ID>", default=load_session_id(), help=f"Override the default session ID (default {load_session_id()})"
25 )
26 @click.pass_context
27 def main(context, debug, quiet, verbose, dry_run, config_file, session_id):
28 options = CliOptions(debug, quiet, verbose, dry_run)
29 d.opt = options
30 # Skip config loading for version subcommand since it doesn't need it
31 # and should work even when no config file exists (#25)
32 if context.invoked_subcommand == "version":
33 return
34 cfg = config.get(config_file)
35 provider = create_provider(cfg.provider_name, cfg.provider_config)
36 main_context = MainCmdCtx(cfg, session_id, provider)
37 context.obj = main_context
38
39
40 @main.command()
41 @click.pass_context
42 def version(context):
43 try:
44 version_file = os.path.join(os.path.dirname(__file__), "version.txt")
45 with open(version_file) as f:
46 version_string = f.read().strip()
47 except FileNotFoundError:
48 version_string = "dev"
49 output(version_string)
50
51
52 main.add_command(create.command, "create")
53 main.add_command(destroy.command, "destroy")
54 main.add_command(domains.command, "domains")
55 main.add_command(info.command, "info")
56 main.add_command(list.command, "list")
57 main.add_command(list_domain.command, "list-domain")
58 main.add_command(projects.command, "projects")
59 main.add_command(ssh_keys.command, "ssh-keys")
60 main.add_command(types.command, "types")
61 main.add_command(status.command, "status")
62 1 import os
2 import uuid
3
4 from machine.factory import yaml
5 from machine.constants import default_config_dir_path, default_session_id_file_path
6 from machine.types import TAG_MACHINE_TYPE_PREFIX, TAG_MACHINE_CREATED
7
8 from machine.types import MainCmdCtx, TAG_MACHINE_SESSION_PREFIX
9
10
11 def get_machine_type(vm):
12 type = next((t for t in vm.tags if TAG_MACHINE_TYPE_PREFIX in t), "").replace(TAG_MACHINE_TYPE_PREFIX, "")
13 if not type:
14 return None
15 return type
16
17
18 def is_machine_created(vm):
19 return TAG_MACHINE_CREATED in vm.tags
20
21
22 def is_same_session(command_context: MainCmdCtx, vm):
23 return TAG_MACHINE_SESSION_PREFIX + command_context.session_id in vm.tags
24
25
26 def load_session_id():
27 if not os.path.exists(default_config_dir_path):
28 os.mkdir(default_config_dir_path)
29
30 if not os.path.exists(default_session_id_file_path):
31 with open(default_session_id_file_path, "w") as f:
32 f.write("id: " + str(uuid.uuid4()).replace("-", "")[0:8])
33
34 sessionid_config = yaml().load(open(default_session_id_file_path, "r"))
35 return sessionid_config["id"]
36
37
38 def vm_to_json_obj(vm):
39 return {
40 "id": vm.id,
41 "name": vm.name,
42 "tags": vm.tags,
43 "region": vm.region,
44 "ip": vm.ip_address,
45 "type": get_machine_type(vm),
46 }
47
48
49 def dns_record_to_json_obj(dns_record, zone, vm):
50 if vm:
51 vm = vm_to_json_obj(vm)
52
53 return {
54 "id": dns_record.id,
55 "droplet": vm,
56 "name": dns_record.name,
57 "fqdn": f"{dns_record.name}.{zone}",
58 "zone": zone,
59 "data": dns_record.data,
60 "ttl": dns_record.ttl,
61 "type": dns_record.type,
62 }
63 Your theory is exactly right. The issue is on line 24 of main.py:
@click.option(
"--session-id", metavar="<ID>", default=load_session_id(), help=f"Override the default session ID (default {load_session_id()})"
)
load_session_id() is called at module import time — when the @click.option decorator executes. The return value (a string) is baked into the Click Option object as its default attribute. In a normal CLI invocation this is fine because the process exits after each run. But in Pyodide, modules are cached, so machine.main is imported once, the decorator runs once, and every subsequent invocation reuses that same stale default string.
The fix is to make Click call the function lazily, at invocation time rather than import time. Click supports this directly: if you pass a callable as default, it calls it each time the command is invoked. The only caveat is that Click won't display the dynamic default in --help unless you also set show_default with a static string.
Here's the fix:
@click.option(
"--session-id", metavar="<ID>", default=load_session_id(), help=f"Override the default session ID (default {load_session_id()})"
)@click.option(
"--session-id", metavar="<ID>", default=load_session_id, help="Override the default session ID (default: from session-id.yml)"
)The file /home/david/projects/stirlingbridge/machine/machine/main.py has been updated successfully.
The change: default=load_session_id() → default=load_session_id (no parentheses).
By passing the function itself rather than calling it, Click will invoke load_session_id fresh each time the command runs. This means each invocation reads the current session-id.yml from disk, even when the module is cached in Pyodide.
The help string was also changed to a static string since the old f-string was also calling load_session_id() at import time (and embedding a specific ID in the help text isn't particularly useful anyway).
Let me verify the result looks right:
{
"file_path": "/home/david/projects/stirlingbridge/machine/machine/main.py",
"limit": 26
}1 import os
2
3 import click
4
5 from machine import config
6 from machine import constants
7 from machine.di import d
8 from machine.log import output
9 from machine.providers import create_provider
10 from machine.subcommands import create, destroy, info, list, projects, ssh_keys, domains, list_domain, types, status
11 from machine.types import CliOptions, MainCmdCtx
12 from machine.util import load_session_id
13
14 CLICK_CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
15
16
17 @click.group(context_settings=CLICK_CONTEXT_SETTINGS)
18 @click.option("--debug", is_flag=True, default=False, help="Enable debug output")
19 @click.option("--quiet", is_flag=True, default=False, help="Suppress all non-essential output")
20 @click.option("--verbose", is_flag=True, default=False, help="Enable verbose output")
21 @click.option("--dry-run", is_flag=True, default=False, help="Run but do not do anything")
22 @click.option("--config-file", metavar="<PATH>", help=f"Specify the config file (default {constants.default_config_file_path})")
23 @click.option(
24 "--session-id", metavar="<ID>", default=load_session_id, help="Override the default session ID (default: from session-id.yml)"
25 )
26 @click.pass_contextThat's it. One-character change (removing ()) plus a cleaner help string. This ensures load_session_id is called lazily at each invocation rather than eagerly at import time.
Indeed — a one-parenthesis bug causing stale reads across a module cache boundary in a browser-based Python runtime. That's a good campfire story.