caffeinated-whale-cli 3.2.0__tar.gz → 3.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/PKG-INFO +27 -2
  2. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/README.md +26 -1
  3. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/pyproject.toml +1 -1
  4. caffeinated_whale_cli-3.3.0/src/caffeinated_whale_cli/__init__.py +1 -0
  5. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/config.py +49 -0
  6. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/auto_inspect.py +8 -0
  7. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/config.py +35 -0
  8. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/inspect.py +12 -1
  9. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/supervision.py +110 -9
  10. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/where.py +18 -1
  11. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/cache.py +6 -1
  12. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/completion_utils.py +13 -1
  13. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/config_utils.py +60 -0
  14. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/db_utils.py +75 -14
  15. caffeinated_whale_cli-3.2.0/src/caffeinated_whale_cli/__init__.py +0 -1
  16. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/LICENSE +0 -0
  17. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/setup.cfg +0 -0
  18. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/__main__.py +0 -0
  19. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/__init__.py +0 -0
  20. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/apps.py +0 -0
  21. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/axi.py +0 -0
  22. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/backup.py +0 -0
  23. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/console.html +0 -0
  24. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/doctor.py +0 -0
  25. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/init.py +0 -0
  26. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/inspect.py +0 -0
  27. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/label.py +0 -0
  28. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/list.py +0 -0
  29. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/logs.py +0 -0
  30. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/open.py +0 -0
  31. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/restart.py +0 -0
  32. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/restore.py +0 -0
  33. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/rm.py +0 -0
  34. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/rm_bench.py +0 -0
  35. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/rm_site.py +0 -0
  36. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/run.py +0 -0
  37. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/scale.py +0 -0
  38. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/self_update.py +0 -0
  39. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/serve.py +0 -0
  40. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/setup.py +0 -0
  41. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/start.py +0 -0
  42. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/status.py +0 -0
  43. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/stop.py +0 -0
  44. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/unlock.py +0 -0
  45. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/update.py +0 -0
  46. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/utils.py +0 -0
  47. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/commands/where.py +0 -0
  48. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/__init__.py +0 -0
  49. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/apps.py +0 -0
  50. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/backup.py +0 -0
  51. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/bench_ops.py +0 -0
  52. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/cred_bridge.py +0 -0
  53. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/credbridge.py +0 -0
  54. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/docker.py +0 -0
  55. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/doctor.py +0 -0
  56. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/envelope.py +0 -0
  57. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/errors.py +0 -0
  58. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/exec_stream.py +0 -0
  59. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/fleet.py +0 -0
  60. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/init.py +0 -0
  61. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/label.py +0 -0
  62. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/list.py +0 -0
  63. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/logs.py +0 -0
  64. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/open.py +0 -0
  65. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/resolvers.py +0 -0
  66. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/restart.py +0 -0
  67. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/restore.py +0 -0
  68. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/rm.py +0 -0
  69. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/rm_bench.py +0 -0
  70. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/rm_site.py +0 -0
  71. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/run.py +0 -0
  72. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/scale.py +0 -0
  73. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/setup.py +0 -0
  74. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/start.py +0 -0
  75. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/status.py +0 -0
  76. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/stop.py +0 -0
  77. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/unlock.py +0 -0
  78. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/update.py +0 -0
  79. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/url.py +0 -0
  80. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/core/version.py +0 -0
  81. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/main.py +0 -0
  82. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/update_notice.py +0 -0
  83. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/__init__.py +0 -0
  84. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/agent_hooks.py +0 -0
  85. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/auto_inspect.py +0 -0
  86. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/bench_labels.py +0 -0
  87. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/bench_sites.py +0 -0
  88. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/console.py +0 -0
  89. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/cred_daemon.py +0 -0
  90. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/daemon_identity.py +0 -0
  91. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/docker_utils.py +0 -0
  92. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/port_utils.py +0 -0
  93. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/sendme_utils.py +0 -0
  94. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/shared_home.py +0 -0
  95. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/signals.py +0 -0
  96. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/startup.py +0 -0
  97. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/tips.py +0 -0
  98. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/toon.py +0 -0
  99. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli/utils/vscode_utils.py +0 -0
  100. {caffeinated_whale_cli-3.2.0 → caffeinated_whale_cli-3.3.0}/src/caffeinated_whale_cli.egg-info/SOURCES.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: caffeinated-whale-cli
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: A CLI tool to help manage Frappe Docker instances.
5
5
  Author-email: Christopher McKay <mckay.christopher73@outlook.com>
6
6
  License: MIT
@@ -2027,7 +2027,7 @@ cwcli config [SUBCOMMAND]
2027
2027
 
2028
2028
  ##### `config show` - Show the Effective Configuration
2029
2029
 
2030
- The one read for everything: the custom search paths, the auto-inspect settings together with live daemon state and boot-hook state, the credential-bridge state (enabled, daemon, transport, wired instances), the tips setting, and the config-file and cache-DB locations.
2030
+ The one read for everything: the cache on/off state (and whether `CWCLI_NO_CACHE` is overriding it), the custom search paths, the auto-inspect settings together with live daemon state and boot-hook state, the credential-bridge state (enabled, daemon, transport, wired instances), the tips setting, and the config-file and cache-DB locations.
2031
2031
 
2032
2032
  ```bash
2033
2033
  cwcli config show
@@ -2095,6 +2095,31 @@ cwcli config cache [SUBCOMMAND]
2095
2095
  - **`list [--json]`** - List all projects currently in the cache
2096
2096
  - Example: `cwcli config cache list --json`
2097
2097
 
2098
+ - **`disable`** / **`enable`** - Turn the on-disk cache off entirely, or back on (the default)
2099
+ - Example: `cwcli config cache disable`
2100
+ - Example: `cwcli config cache enable`
2101
+ - See ["Disabling caching entirely"](#disabling-caching-entirely) below.
2102
+
2103
+ ###### Disabling caching entirely
2104
+
2105
+ cwcli keeps an on-disk SQLite cache (under `~/.cwcli/cache`, or `$CWCLI_HOME/cache`) that read commands - `inspect`, `status`, `where`, `open`, the `apps` group, tab completion, and every `axi` read verb - work from, so they do not re-inspect a running instance every time.
2106
+
2107
+ You can turn it off entirely, in two equivalent ways:
2108
+
2109
+ - **Config key:** `cwcli config cache disable` sets `[cache] enabled = false` in the config file. `cwcli config cache enable` reverts it.
2110
+ - **Environment variable:** `CWCLI_NO_CACHE=1` disables caching for a single shell or CI job; `CWCLI_NO_CACHE=0` re-enables it. The environment variable **overrides** the config key, so you can force caching on or off for one invocation regardless of the persisted setting.
2111
+
2112
+ With caching disabled:
2113
+
2114
+ - Every read resolves **live**, as if a fresh `inspect` had just run - a stale cached row can never be served.
2115
+ - **Nothing** is written to or read from the cache DB, and a pre-existing cache file is left in place untouched, so re-enabling restores the previous behaviour.
2116
+ - Tab completion degrades gracefully to a live/empty lookup rather than serving stale names.
2117
+ - `auto-inspect` refuses to start (it exists only to keep the cache warm, which does nothing when caching is off).
2118
+ - Bench labels still work, because they are also stored in each bench's marker file.
2119
+ - Safety-critical commands (`rm`/`rm-site`'s backup step, `apps checkout`'s wrong-copy detection) get correct live data.
2120
+
2121
+ Because every read resolves live, commands are slower than with the cache warm - this is a correctness/isolation mode, not the everyday default.
2122
+
2098
2123
  ##### `config auto-inspect` - Automatic Project Inspection
2099
2124
 
2100
2125
  Manages automatic background inspection of running Frappe projects to keep cached data fresh (tab completion, project status queries, and other commands that rely on cached data).
@@ -1987,7 +1987,7 @@ cwcli config [SUBCOMMAND]
1987
1987
 
1988
1988
  ##### `config show` - Show the Effective Configuration
1989
1989
 
1990
- The one read for everything: the custom search paths, the auto-inspect settings together with live daemon state and boot-hook state, the credential-bridge state (enabled, daemon, transport, wired instances), the tips setting, and the config-file and cache-DB locations.
1990
+ The one read for everything: the cache on/off state (and whether `CWCLI_NO_CACHE` is overriding it), the custom search paths, the auto-inspect settings together with live daemon state and boot-hook state, the credential-bridge state (enabled, daemon, transport, wired instances), the tips setting, and the config-file and cache-DB locations.
1991
1991
 
1992
1992
  ```bash
1993
1993
  cwcli config show
@@ -2055,6 +2055,31 @@ cwcli config cache [SUBCOMMAND]
2055
2055
  - **`list [--json]`** - List all projects currently in the cache
2056
2056
  - Example: `cwcli config cache list --json`
2057
2057
 
2058
+ - **`disable`** / **`enable`** - Turn the on-disk cache off entirely, or back on (the default)
2059
+ - Example: `cwcli config cache disable`
2060
+ - Example: `cwcli config cache enable`
2061
+ - See ["Disabling caching entirely"](#disabling-caching-entirely) below.
2062
+
2063
+ ###### Disabling caching entirely
2064
+
2065
+ cwcli keeps an on-disk SQLite cache (under `~/.cwcli/cache`, or `$CWCLI_HOME/cache`) that read commands - `inspect`, `status`, `where`, `open`, the `apps` group, tab completion, and every `axi` read verb - work from, so they do not re-inspect a running instance every time.
2066
+
2067
+ You can turn it off entirely, in two equivalent ways:
2068
+
2069
+ - **Config key:** `cwcli config cache disable` sets `[cache] enabled = false` in the config file. `cwcli config cache enable` reverts it.
2070
+ - **Environment variable:** `CWCLI_NO_CACHE=1` disables caching for a single shell or CI job; `CWCLI_NO_CACHE=0` re-enables it. The environment variable **overrides** the config key, so you can force caching on or off for one invocation regardless of the persisted setting.
2071
+
2072
+ With caching disabled:
2073
+
2074
+ - Every read resolves **live**, as if a fresh `inspect` had just run - a stale cached row can never be served.
2075
+ - **Nothing** is written to or read from the cache DB, and a pre-existing cache file is left in place untouched, so re-enabling restores the previous behaviour.
2076
+ - Tab completion degrades gracefully to a live/empty lookup rather than serving stale names.
2077
+ - `auto-inspect` refuses to start (it exists only to keep the cache warm, which does nothing when caching is off).
2078
+ - Bench labels still work, because they are also stored in each bench's marker file.
2079
+ - Safety-critical commands (`rm`/`rm-site`'s backup step, `apps checkout`'s wrong-copy detection) get correct live data.
2080
+
2081
+ Because every read resolves live, commands are slower than with the cache warm - this is a correctness/isolation mode, not the everyday default.
2082
+
2058
2083
  ##### `config auto-inspect` - Automatic Project Inspection
2059
2084
 
2060
2085
  Manages automatic background inspection of running Frappe projects to keep cached data fresh (tab completion, project status queries, and other commands that rely on cached data).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "caffeinated-whale-cli"
7
- version = "3.2.0"
7
+ version = "3.3.0"
8
8
  authors = [
9
9
  { name = "Christopher McKay", email = "mckay.christopher73@outlook.com" },
10
10
  ]
@@ -0,0 +1 @@
1
+ __version__ = "3.3.0"
@@ -101,6 +101,17 @@ def show(
101
101
  console.print("[bold]Locations[/bold]")
102
102
  console.print(f" Config file: [green]{report.config_file}[/green]")
103
103
  console.print(f" Cache DB: [green]{report.cache_db}[/green]")
104
+
105
+ console.print("\n[bold]Cache[/bold]")
106
+ if report.cache_enabled:
107
+ console.print(" Enabled: [green]Yes[/green]")
108
+ else:
109
+ source = "CWCLI_NO_CACHE" if report.cache_env_override else "config"
110
+ console.print(f" Enabled: [red]No[/red] [dim](disabled via {source})[/dim]")
111
+ console.print(" [dim]Reads resolve live; nothing is written to the cache DB.[/dim]")
112
+ if report.cache_env_override:
113
+ console.print(" [dim]CWCLI_NO_CACHE is set and overrides the config key.[/dim]")
114
+
104
115
  console.print("\n[bold]Search paths[/bold]")
105
116
  if report.search_paths:
106
117
  for path in report.search_paths:
@@ -351,6 +362,44 @@ def clear_cache(
351
362
  )
352
363
 
353
364
 
365
+ @cache_app.command("enable")
366
+ def cache_enable():
367
+ """
368
+ Turn the on-disk cache back on (the default).
369
+ """
370
+ _apply_cache_setting(True)
371
+
372
+
373
+ @cache_app.command("disable")
374
+ def cache_disable():
375
+ """
376
+ Turn the on-disk cache off entirely.
377
+
378
+ Every read command then resolves live (as if a fresh inspect ran) and nothing
379
+ is written to or read from the cache DB. The existing cache file is left in
380
+ place, so re-enabling restores it. Set CWCLI_NO_CACHE=1 for the same effect
381
+ per shell / in CI.
382
+ """
383
+ _apply_cache_setting(False)
384
+
385
+
386
+ def _apply_cache_setting(enabled: bool):
387
+ state = core_config.set_cache(enabled).data
388
+ assert state is not None
389
+ if enabled:
390
+ console.print("[green]Caching enabled.[/green] Reads use the on-disk cache.")
391
+ else:
392
+ console.print(
393
+ "[yellow]Caching disabled.[/yellow] Reads resolve live; nothing is written "
394
+ "to the cache DB (the existing cache is left in place)."
395
+ )
396
+ if state.env_override and state.enabled != enabled:
397
+ console.print(
398
+ "[yellow]Note:[/yellow] CWCLI_NO_CACHE is set and overrides this setting; "
399
+ f"caching is currently {'enabled' if state.enabled else 'disabled'} for this shell."
400
+ )
401
+
402
+
354
403
  @cache_app.command("path")
355
404
  def cache_path():
356
405
  """
@@ -160,6 +160,14 @@ def enable(interval: int | None = None, at_boot: bool | None = None) -> Result[A
160
160
  it when a changed interval must take effect), then syncs the boot hook when
161
161
  ``at_boot`` was requested (``None`` leaves the hook untouched).
162
162
  """
163
+ if config_utils.cache_disabled():
164
+ raise CwcliError(
165
+ ErrorKind.USAGE,
166
+ "auto_inspect.cache_disabled",
167
+ "Caching is disabled, so auto-inspect has nothing to keep warm.",
168
+ hint="Re-enable caching first: `cwcli config cache enable` (or unset CWCLI_NO_CACHE).",
169
+ )
170
+
163
171
  if interval is not None and interval < MIN_INTERVAL:
164
172
  raise CwcliError(
165
173
  ErrorKind.USAGE,
@@ -18,6 +18,7 @@ cannot be checked, nothing is pruned.
18
18
 
19
19
  from __future__ import annotations
20
20
 
21
+ import os
21
22
  import posixpath
22
23
  from dataclasses import dataclass, field
23
24
 
@@ -38,12 +39,22 @@ class ConfigReport:
38
39
 
39
40
  config_file: str
40
41
  cache_db: str
42
+ cache_enabled: bool
43
+ cache_env_override: bool
41
44
  search_paths: list[str]
42
45
  auto_inspect: AutoInspectState
43
46
  cred_bridge: CredBridgeState
44
47
  show_tips: bool
45
48
 
46
49
 
50
+ @dataclass(frozen=True, slots=True, kw_only=True)
51
+ class CacheState:
52
+ """Whether the on-disk cache is on, and whether an env var is forcing it."""
53
+
54
+ enabled: bool
55
+ env_override: bool # True when CWCLI_NO_CACHE is set (config key is inert)
56
+
57
+
47
58
  @dataclass(frozen=True, slots=True, kw_only=True)
48
59
  class SearchPathChange:
49
60
  """A search-path add/remove: the normalized path and whether anything changed."""
@@ -79,6 +90,12 @@ def _normalize(path: str) -> str:
79
90
  return posixpath.normpath(posixpath.expanduser(path))
80
91
 
81
92
 
93
+ def _cache_env_override() -> bool:
94
+ """True when CWCLI_NO_CACHE is set to a non-empty value (it wins over config)."""
95
+ env = os.environ.get("CWCLI_NO_CACHE")
96
+ return env is not None and env.strip() != ""
97
+
98
+
82
99
  def show_config() -> Result[ConfigReport]:
83
100
  """The effective config as one report: paths, auto-inspect, tips, locations."""
84
101
  state = core_auto_inspect.status().data
@@ -91,6 +108,8 @@ def show_config() -> Result[ConfigReport]:
91
108
  data=ConfigReport(
92
109
  config_file=str(config_utils.CONFIG_FILE),
93
110
  cache_db=str(db_utils.DB_PATH),
111
+ cache_enabled=not config_utils.cache_disabled(),
112
+ cache_env_override=_cache_env_override(),
94
113
  search_paths=list(config["search_paths"]["custom_bench_paths"]),
95
114
  auto_inspect=state,
96
115
  cred_bridge=bridge_state,
@@ -99,6 +118,22 @@ def show_config() -> Result[ConfigReport]:
99
118
  )
100
119
 
101
120
 
121
+ def set_cache(enabled: bool) -> Result[CacheState]:
122
+ """Turn the on-disk cache on or off via the ``[cache] enabled`` config key.
123
+
124
+ The cache DB is never deleted or altered here - it is left in place so
125
+ re-enabling restores the previous behaviour. When ``CWCLI_NO_CACHE`` is set it
126
+ overrides this key, so the returned :class:`CacheState` reports that override.
127
+ """
128
+ config_utils.set_cache_enabled(enabled)
129
+ return Result(
130
+ status=Status.OK,
131
+ data=CacheState(
132
+ enabled=not config_utils.cache_disabled(), env_override=_cache_env_override()
133
+ ),
134
+ )
135
+
136
+
102
137
  def add_search_path(path: str) -> Result[SearchPathChange]:
103
138
  """Add a bench search path: refuse non-absolute, normalize before dedup.
104
139
 
@@ -581,6 +581,14 @@ def inspect_raw(
581
581
  f"Unknown refresh mode '{refresh}'. Use one of: {', '.join(_REFRESH_MODES)}.",
582
582
  )
583
583
 
584
+ # Caching off: always resolve live. Force the full tier so no cached row (T1)
585
+ # or partial refresh (T2) can be served, and the write below lands in the
586
+ # ephemeral in-memory store db_utils redirects to (the on-disk cache is never
587
+ # touched). This is also the recursion base for db_utils._live_populate: a
588
+ # forced-full run with no selector never reads the cache back.
589
+ if config_utils.cache_disabled():
590
+ refresh = "full"
591
+
584
592
  emit = on_event or _noop
585
593
  warnings: list[Message] = []
586
594
 
@@ -592,7 +600,10 @@ def inspect_raw(
592
600
  served_from = "cache"
593
601
 
594
602
  cached_data = None
595
- if refresh != "full" or bench is not None:
603
+ # With caching disabled there is nothing to read and the read would only
604
+ # trigger a redundant nested live populate; the forced-full path below does a
605
+ # fresh discovery and narrows the selector from that.
606
+ if (refresh != "full" or bench is not None) and not config_utils.cache_disabled():
596
607
  cached_data = db_utils.get_cached_project_data(project_name)
597
608
 
598
609
  if refresh != "full":
@@ -51,6 +51,7 @@ never returned across a boundary; every return is plain serializable data.
51
51
  from __future__ import annotations
52
52
 
53
53
  import json
54
+ import re
54
55
  import shlex
55
56
  import time
56
57
  from collections.abc import Callable
@@ -1230,6 +1231,89 @@ def _resync_failed(text: str, restarted: list[str]) -> ResyncOutcome:
1230
1231
  return ResyncOutcome(attempted=True, restarted=list(restarted), unserved_sites=[], error=text)
1231
1232
 
1232
1233
 
1234
+ # A bench's ``common_site_config.json`` MAY legitimately omit ``webserver_port`` -
1235
+ # a DNS-multitenant bench (many domain-named sites) removes it deliberately so
1236
+ # Frappe routes purely by Host header, not by a port cwcli would otherwise read as
1237
+ # a fixed default. `resolve_assigned_ports(fill_defaults=False)` reports that the
1238
+ # same way it reports a genuinely unreadable config: absent. Below, "the config has
1239
+ # no port" is answered with EVIDENCE instead - the live web process's own bound
1240
+ # port, from its argv - never a bare guess.
1241
+ _PORT_FLAG_RE = re.compile(r"--port[=\s]+(\d+)")
1242
+ _BIND_FLAG_RE = re.compile(r"(?:--bind|-b)[=\s]+\S*:(\d+)")
1243
+ _LISTENER_OWNERSHIP_SCRIPT = r"""
1244
+ import os
1245
+ import sys
1246
+
1247
+ pid = int(sys.argv[1])
1248
+ port = int(sys.argv[2])
1249
+ bench = os.path.realpath(sys.argv[3])
1250
+ try:
1251
+ cwd = os.path.realpath(f"/proc/{pid}/cwd")
1252
+ if os.path.commonpath((cwd, bench)) != bench:
1253
+ raise OSError
1254
+ inodes = set()
1255
+ for fd in os.listdir(f"/proc/{pid}/fd"):
1256
+ try:
1257
+ target = os.readlink(f"/proc/{pid}/fd/{fd}")
1258
+ except OSError:
1259
+ continue
1260
+ if target.startswith("socket:[") and target.endswith("]"):
1261
+ inodes.add(target[8:-1])
1262
+ for table in ("/proc/net/tcp", "/proc/net/tcp6"):
1263
+ with open(table, encoding="ascii") as rows:
1264
+ next(rows)
1265
+ for row in rows:
1266
+ fields = row.split()
1267
+ local_port = int(fields[1].rsplit(":", 1)[1], 16)
1268
+ if fields[3] == "0A" and local_port == port and fields[9] in inodes:
1269
+ raise SystemExit(0)
1270
+ except (OSError, ValueError):
1271
+ raise SystemExit(2)
1272
+ raise SystemExit(1)
1273
+ """.strip()
1274
+
1275
+
1276
+ def _port_from_process(container, pid: int) -> int | None:
1277
+ """The bound port of ``pid``, read from its own argv - evidence, not a guess.
1278
+
1279
+ bench bakes the bench's real assigned port into the Procfile's ``web:`` line at
1280
+ init (``bench serve --port <N>``), so once that line is running its cmdline IS
1281
+ the truth; a gunicorn-fronted web instead carries ``--bind``/``-b <host>:<N>``.
1282
+ Returns None when the process is gone or its argv names no port at all (a bare
1283
+ ``bench serve`` with no ``--port``, which defers to Frappe's own default).
1284
+ """
1285
+ for row in _ps_rows(container):
1286
+ if row.pid != pid:
1287
+ continue
1288
+ match = _PORT_FLAG_RE.search(row.args) or _BIND_FLAG_RE.search(row.args)
1289
+ return int(match.group(1)) if match else None
1290
+ return None
1291
+
1292
+
1293
+ def _process_owns_listener(container, pid: int, port: int, bench_path: str) -> bool:
1294
+ """Return whether this bench's process owns the listener on ``port``."""
1295
+ exit_code, _output = container.exec_run(
1296
+ ["python3", "-c", _LISTENER_OWNERSHIP_SCRIPT, str(pid), str(port), bench_path]
1297
+ )
1298
+ return bool(exit_code == 0)
1299
+
1300
+
1301
+ def _resync_web_port(container, bench_path: str, web_pid: int | None) -> int | None:
1302
+ ports = resolvers.resolve_assigned_ports(container, [bench_path], fill_defaults=False).get(
1303
+ bench_path
1304
+ )
1305
+ if ports is not None:
1306
+ return ports[0]
1307
+ process_port = _port_from_process(container, web_pid) if web_pid is not None else None
1308
+ if process_port is not None:
1309
+ return process_port
1310
+ if web_pid is not None and _process_owns_listener(
1311
+ container, web_pid, resolvers.WEB_CONTAINER_BASE, bench_path
1312
+ ):
1313
+ return resolvers.WEB_CONTAINER_BASE
1314
+ return None
1315
+
1316
+
1233
1317
  def resync_after_code_change(
1234
1318
  container,
1235
1319
  bench_path: str,
@@ -1280,6 +1364,18 @@ def resync_after_code_change(
1280
1364
  bound-port-shaped claim, and serving stale code is exactly the class of defect a
1281
1365
  claim like that misses. Every site the caller names must answer a real
1282
1366
  site-routed request before the outcome is ``ok``.
1367
+ - **An absent ``webserver_port`` is not a read failure.** A DNS-multitenant
1368
+ bench (many domain-named sites) deliberately removes it from
1369
+ ``common_site_config.json`` so Frappe routes purely by Host header; a bench
1370
+ like that used to fail here with "the bench's assigned port could not be
1371
+ read", even though the update itself had already succeeded. The probe port
1372
+ now falls back to EVIDENCE - the live web process's own bound port (its argv
1373
+ carries bench's real assignment, baked into the Procfile's ``web:`` line at
1374
+ init), then Frappe's own unconfigured default only when that listener is
1375
+ proven to belong to this bench's web process - before the sites are probed
1376
+ for real. Missing evidence fails honestly without probing a sibling bench or
1377
+ blaming a config key that was never supposed to exist.
1378
+ See :func:`_port_from_process`.
1283
1379
  """
1284
1380
  announce = on_restart or (lambda _program: None)
1285
1381
  unique_sites = list(dict.fromkeys(sites))
@@ -1290,6 +1386,7 @@ def resync_after_code_change(
1290
1386
 
1291
1387
  restarted: list[str] = []
1292
1388
  supervised = False
1389
+ web_port: int | None
1293
1390
  try:
1294
1391
  supervised = discover_stack(container, bench_path, required=True).supervisor_up
1295
1392
  if supervised:
@@ -1304,6 +1401,7 @@ def resync_after_code_change(
1304
1401
  # is no site to verify against, and starting one is not this verb's job.
1305
1402
  if states[web][0] not in _LIVE_STATES:
1306
1403
  return quiet
1404
+ web_port = _resync_web_port(container, bench_path, states[web][1])
1307
1405
 
1308
1406
  for program in code_bearing_programs(list(states)):
1309
1407
  if states[program][0] not in _LIVE_STATES:
@@ -1320,21 +1418,24 @@ def resync_after_code_change(
1320
1418
  restarted,
1321
1419
  )
1322
1420
  restarted.append(program)
1323
- elif not discover_unsupervised_stack(container, bench_path, required=True).manager_up:
1324
- return quiet
1421
+ else:
1422
+ unsupervised = discover_unsupervised_stack(container, bench_path, required=True)
1423
+ if not unsupervised.manager_up:
1424
+ return quiet
1425
+ web_process = next((p for p in unsupervised.processes if p.label == "web"), None)
1426
+ web_port = _resync_web_port(
1427
+ container, bench_path, web_process.pid if web_process is not None else None
1428
+ )
1325
1429
 
1326
- ports = resolvers.resolve_assigned_ports(container, [bench_path], fill_defaults=False).get(
1327
- bench_path
1328
- )
1329
- if ports is None:
1430
+ if web_port is None:
1330
1431
  return _resync_failed(
1331
- "the bench's assigned port could not be read, so cwcli could not confirm "
1332
- "the affected sites serve",
1432
+ "the bench's serving port could not be determined safely, so cwcli could not "
1433
+ "confirm the affected sites serve",
1333
1434
  restarted,
1334
1435
  )
1335
1436
 
1336
1437
  pending, codes = _wait_for_serving_sites(
1337
- container, port=ports[0], sites=unique_sites, timeout=timeout
1438
+ container, port=web_port, sites=unique_sites, timeout=timeout
1338
1439
  )
1339
1440
  if pending:
1340
1441
  observed = ", ".join(f"{site}={codes[site] or 'unreachable'}" for site in pending)
@@ -31,7 +31,7 @@ from dataclasses import dataclass, field, replace
31
31
 
32
32
  import peewee
33
33
 
34
- from ..utils import db_utils
34
+ from ..utils import config_utils, db_utils
35
35
  from .envelope import Message, Result, Status
36
36
  from .errors import CwcliError, ErrorKind
37
37
  from .list import list_instances
@@ -177,6 +177,21 @@ def _live_project_names() -> set[str] | None:
177
177
  return {instance.project_name for instance in (result.data or [])}
178
178
 
179
179
 
180
+ def _populate_live_if_disabled() -> None:
181
+ """With caching off, ``where`` has no cache to search, so inspect every live
182
+ instance into the ephemeral in-memory store first, then search that.
183
+
184
+ Reuses the same live-populate ``db_utils`` runs for a single project (one full
185
+ inspect per running instance); the on-disk cache is never touched. A daemon we
186
+ cannot reach leaves the store empty, which ``where`` reports as no matches.
187
+ """
188
+ if not config_utils.cache_disabled():
189
+ return
190
+ live = _live_project_names()
191
+ for project_name in live or set():
192
+ db_utils.get_cached_project_data(project_name)
193
+
194
+
180
195
  def where(
181
196
  search: str,
182
197
  *,
@@ -193,6 +208,8 @@ def where(
193
208
  "Cannot use --apps and --sites together.",
194
209
  )
195
210
 
211
+ _populate_live_if_disabled()
212
+
196
213
  matches: list[WhereMatch] = []
197
214
 
198
215
  # A corrupt/locked cache or a schema mismatch surfaces here as a raw peewee
@@ -5,7 +5,7 @@ This module provides utilities for managing the project inspection cache,
5
5
  including re-caching operations built on the core inspect slice.
6
6
  """
7
7
 
8
- from . import db_utils
8
+ from . import config_utils, db_utils
9
9
 
10
10
 
11
11
  def recache_project(project_name: str, verbose: bool = False) -> bool:
@@ -31,6 +31,11 @@ def recache_project(project_name: str, verbose: bool = False) -> bool:
31
31
  True if recache succeeded, False otherwise (including when the project's
32
32
  containers are not running, since a stopped bench cannot be inspected).
33
33
  """
34
+ # Caching off: there is no on-disk cache to refresh, so a post-mutation
35
+ # recache would only run a full inspect into a store that is discarded.
36
+ if config_utils.cache_disabled():
37
+ return True
38
+
34
39
  try:
35
40
  # Clear the cache for this project
36
41
  db_utils.clear_cache_for_project(project_name)
@@ -11,7 +11,7 @@ import time
11
11
  import docker
12
12
  import typer
13
13
 
14
- from . import db_utils
14
+ from . import config_utils, db_utils
15
15
 
16
16
  # Cache configuration
17
17
  _CACHE_TTL = 2.0 # Cache results for 2 seconds (enough for tab completion session)
@@ -138,6 +138,12 @@ def complete_app_names(
138
138
  if not project_name:
139
139
  return []
140
140
 
141
+ # Caching off: degrade to an empty completion rather than blocking the shell
142
+ # on a live inspect. Empty beats stale, and completion must never do container
143
+ # I/O.
144
+ if config_utils.cache_disabled():
145
+ return []
146
+
141
147
  # Check cache first
142
148
  cache_key = f"apps:{project_name}"
143
149
  cached = _get_cached(cache_key)
@@ -186,6 +192,12 @@ def complete_site_names(
186
192
  if not project_name:
187
193
  return []
188
194
 
195
+ # Caching off: degrade to an empty completion rather than blocking the shell
196
+ # on a live inspect. Empty beats stale, and completion must never do container
197
+ # I/O.
198
+ if config_utils.cache_disabled():
199
+ return []
200
+
189
201
  # Check cache first
190
202
  cache_key = f"sites:{project_name}"
191
203
  cached = _get_cached(cache_key)
@@ -99,6 +99,18 @@ startup_enabled = false
99
99
  # Applies immediately (no daemon restart needed); cwcli's own per-operation
100
100
  # bridge (apps install/update, init) is not restricted by this list.
101
101
  allowed_hosts = ["github.com", "gitlab.com"]
102
+
103
+ [cache]
104
+ # The on-disk SQLite cache under <cwcli home>/cache speeds up read commands
105
+ # (inspect, status, where, tab completion, ...) by remembering each project's
106
+ # benches, sites, and apps.
107
+ #
108
+ # Set enabled = false to turn caching off entirely: every read resolves live
109
+ # (as if a fresh inspect ran), nothing is written to or read from the cache DB,
110
+ # and a pre-existing cache is left in place untouched so turning caching back on
111
+ # restores it. The CWCLI_NO_CACHE environment variable overrides this key per
112
+ # shell / in CI (CWCLI_NO_CACHE=1 disables, CWCLI_NO_CACHE=0 re-enables).
113
+ enabled = true
102
114
  """
103
115
 
104
116
  DEFAULT_ALLOWED_HOSTS = ("github.com", "gitlab.com")
@@ -150,6 +162,10 @@ def load_config() -> dict:
150
162
  else:
151
163
  for key, value in _cred_bridge_defaults().items():
152
164
  config_data["cred_bridge"].setdefault(key, value)
165
+ if "cache" not in config_data:
166
+ config_data["cache"] = {"enabled": True}
167
+ elif "enabled" not in config_data["cache"]:
168
+ config_data["cache"]["enabled"] = True
153
169
  return config_data
154
170
  except toml.TomlDecodeError:
155
171
  return {
@@ -157,6 +173,7 @@ def load_config() -> dict:
157
173
  "auto_inspect": {"enabled": False, "interval": 3600, "startup_enabled": False},
158
174
  "ui": {"show_tips": True},
159
175
  "cred_bridge": _cred_bridge_defaults(),
176
+ "cache": {"enabled": True},
160
177
  }
161
178
 
162
179
 
@@ -286,6 +303,49 @@ def set_cred_bridge_startup(enabled: bool):
286
303
  save_config(config)
287
304
 
288
305
 
306
+ def read_cache_config() -> dict:
307
+ """Read the [cache] config WITHOUT creating config state.
308
+
309
+ Used by :func:`cache_disabled`, which runs on the hot path of every cache
310
+ read; it must never write a default config file into the user's home as a
311
+ side effect (mirrors :func:`read_cred_bridge_config`).
312
+ """
313
+ default = {"enabled": True}
314
+ if not CONFIG_FILE.is_file():
315
+ return default
316
+ try:
317
+ with open(CONFIG_FILE) as f:
318
+ config = toml.load(f)
319
+ except (OSError, toml.TomlDecodeError):
320
+ return default
321
+ cache = config.get("cache", default)
322
+ return cache if isinstance(cache, dict) else default
323
+
324
+
325
+ def cache_disabled() -> bool:
326
+ """Whether cwcli's on-disk SQLite cache is turned off.
327
+
328
+ The ``CWCLI_NO_CACHE`` environment variable overrides the ``[cache] enabled``
329
+ config key, so caching can be toggled per shell / in CI - and can even be
330
+ re-enabled (``CWCLI_NO_CACHE=0``) when the config file disables it. An unset
331
+ or empty env var falls through to the config key (default: enabled).
332
+ """
333
+ env = os.environ.get("CWCLI_NO_CACHE")
334
+ if env is not None and env.strip() != "":
335
+ return env.strip().lower() in ("1", "true", "yes", "on")
336
+ return not bool(read_cache_config().get("enabled", True))
337
+
338
+
339
+ def set_cache_enabled(enabled: bool):
340
+ """Enable or disable the on-disk cache via the ``[cache] enabled`` config key."""
341
+ config = load_config()
342
+ if "cache" not in config:
343
+ config["cache"] = {"enabled": enabled}
344
+ else:
345
+ config["cache"]["enabled"] = enabled
346
+ save_config(config)
347
+
348
+
289
349
  def get_show_tips() -> bool:
290
350
  """
291
351
  Get whether tips should be shown during long-running operations.
@@ -12,7 +12,7 @@ from peewee import (
12
12
  TextField,
13
13
  )
14
14
 
15
- from . import shared_home
15
+ from . import config_utils, shared_home
16
16
  from .config_utils import cwcli_home
17
17
 
18
18
  # Resolve the cache location through the shared cwcli_home() helper so the
@@ -21,6 +21,12 @@ from .config_utils import cwcli_home
21
21
  CACHE_DIR = cwcli_home() / "cache"
22
22
  DB_PATH = CACHE_DIR / "cwc-cache.db"
23
23
 
24
+ # The sentinel path an ephemeral in-memory SQLite database uses. When caching is
25
+ # disabled (config_utils.cache_disabled()) the whole cache is redirected here, so
26
+ # the on-disk DB is never opened, read, or written and a stale row can never leak
27
+ # through - see initialize_database() and _live_populate().
28
+ _MEMORY_DB = ":memory:"
29
+
24
30
  # SECURITY: config_json rows are whitelist-filtered by _redact_config_for_cache
25
31
  # before write, so the cache never stores DB credentials, encryption keys, or
26
32
  # redis URLs. Restrictive filesystem permissions (0700 dir / 0600 file) remain
@@ -30,20 +36,23 @@ DB_PATH = CACHE_DIR / "cwc-cache.db"
30
36
  # shared_home).
31
37
 
32
38
  # Create cache directory with restricted permissions (0700 = owner-only access)
33
- # This prevents other users on the system from reading cached credentials
34
- CACHE_DIR.mkdir(parents=True, mode=shared_home.dir_mode(0o700), exist_ok=True)
35
-
36
- # Ensure existing directory has correct permissions
37
- if CACHE_DIR.exists():
38
- try:
39
- CACHE_DIR.chmod(shared_home.dir_mode(0o700))
40
- shared_home.apply_group(CACHE_DIR)
41
- except (OSError, PermissionError):
42
- # On Windows or restricted filesystems, chmod may fail
43
- # Still proceed but permissions may not be as strict
44
- pass
39
+ # This prevents other users on the system from reading cached credentials. Skip
40
+ # it entirely when caching is disabled, so a disabled install leaves no on-disk
41
+ # footprint at all.
42
+ if not config_utils.cache_disabled():
43
+ CACHE_DIR.mkdir(parents=True, mode=shared_home.dir_mode(0o700), exist_ok=True)
44
+
45
+ # Ensure existing directory has correct permissions
46
+ if CACHE_DIR.exists():
47
+ try:
48
+ CACHE_DIR.chmod(shared_home.dir_mode(0o700))
49
+ shared_home.apply_group(CACHE_DIR)
50
+ except (OSError, PermissionError):
51
+ # On Windows or restricted filesystems, chmod may fail
52
+ # Still proceed but permissions may not be as strict
53
+ pass
45
54
 
46
- db = SqliteDatabase(DB_PATH)
55
+ db = SqliteDatabase(_MEMORY_DB if config_utils.cache_disabled() else DB_PATH)
47
56
 
48
57
 
49
58
  class BaseModel(Model):
@@ -371,7 +380,54 @@ def _scrub_cached_config_secrets():
371
380
  print(f"Warning: could not scrub cached config secrets: {e}", file=sys.stderr)
372
381
 
373
382
 
383
+ # Projects live-populated into the ephemeral in-memory cache this process (see
384
+ # _live_populate). A reentrancy flag stops the populating inspect - which writes
385
+ # the same in-memory DB - from recursing back through a cache read.
386
+ _live_populated: set[str] = set()
387
+ _populating = False
388
+
389
+
390
+ def _db_target() -> str:
391
+ """Where the cache DB should point right now: in-memory when disabled."""
392
+ return _MEMORY_DB if config_utils.cache_disabled() else str(DB_PATH)
393
+
394
+
395
+ def _live_populate(project_name: str) -> None:
396
+ """In cache-disabled mode, populate the ephemeral in-memory DB with one live
397
+ inspect so downstream cache reads resolve fresh data instead of nothing.
398
+
399
+ The on-disk cache is never touched: the disabled DB points at ``:memory:``,
400
+ so the inspect's write lands in the ephemeral store this process discards on
401
+ exit. Memoized and reentrancy-guarded (the inspect writes the same DB), and
402
+ best-effort: a stopped/absent project simply yields no in-memory rows, which
403
+ every caller already handles as a cache miss.
404
+ """
405
+ global _populating
406
+ if not config_utils.cache_disabled() or _populating or project_name in _live_populated:
407
+ return
408
+ _populating = True
409
+ try:
410
+ from ..core import inspect as core_inspect
411
+
412
+ core_inspect.inspect(project_name, refresh="full", offer_choice=False)
413
+ except Exception: # noqa: BLE001 - a failed live read just leaves the store empty
414
+ pass
415
+ finally:
416
+ _live_populated.add(project_name) # never retry within one process
417
+ _populating = False
418
+
419
+
374
420
  def initialize_database():
421
+ target = _db_target()
422
+ if str(db.database) != target:
423
+ # The disabled state flipped (e.g. a test toggled CWCLI_NO_CACHE, or the
424
+ # first read after enable/disable). Re-point the shared database so every
425
+ # model follows it. Switching TO :memory: gives a fresh empty store;
426
+ # switching back opens the untouched on-disk cache.
427
+ if not db.is_closed():
428
+ db.close()
429
+ db.init(target)
430
+ _live_populated.clear()
375
431
  if db.is_closed():
376
432
  db.connect()
377
433
  # Create tables if missing
@@ -528,6 +584,7 @@ def _cache_project_data(project_name: str, bench_instances_data: list[dict]) ->
528
584
 
529
585
  def get_cached_project_data(project_name):
530
586
  initialize_database()
587
+ _live_populate(project_name)
531
588
  try:
532
589
  project = Project.get(Project.name == project_name)
533
590
 
@@ -643,6 +700,7 @@ def get_common_site_config(project_name: str, bench_path: str | None = None) ->
643
700
  Dictionary with common site config or None if not found
644
701
  """
645
702
  initialize_database()
703
+ _live_populate(project_name)
646
704
  try:
647
705
  project = Project.get(Project.name == project_name)
648
706
 
@@ -685,6 +743,7 @@ def get_site_config(
685
743
  Dictionary with site config or None if not found
686
744
  """
687
745
  initialize_database()
746
+ _live_populate(project_name)
688
747
  try:
689
748
  project = Project.get(Project.name == project_name)
690
749
 
@@ -729,6 +788,7 @@ def get_all_site_configs(project_name: str, bench_path: str | None = None) -> di
729
788
  Dictionary mapping site names to their configs
730
789
  """
731
790
  initialize_database()
791
+ _live_populate(project_name)
732
792
  result = {}
733
793
 
734
794
  try:
@@ -763,6 +823,7 @@ def get_current_site(project_name: str, bench_path: str | None = None) -> str |
763
823
  non-empty ``current_site`` found (scoped to ``bench_path`` when given).
764
824
  """
765
825
  initialize_database()
826
+ _live_populate(project_name)
766
827
  try:
767
828
  project = Project.get(Project.name == project_name)
768
829
 
@@ -1 +0,0 @@
1
- __version__ = "3.2.0"