Skip to content

Commit b34ce91

Browse files
authored
Merge pull request #71 from mxstack/fix/70-offline-http-cache
Fix #70: Implement HTTP caching for offline mode
2 parents bff9be8 + 62877d7 commit b34ce91

File tree

6 files changed

+368
-19
lines changed

6 files changed

+368
-19
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
*.egg-info
22
.*-cache
3+
.mxdev_cache/
34
.coverage
45
.coverage.*
56
!.coveragerc

CHANGES.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

33
## 5.0.2 (unreleased)
44

5-
- Nothing yet.
5+
- Fix #70: HTTP-referenced requirements/constraints files are now properly cached and respected in offline mode. Previously, offline mode only skipped VCS operations but still fetched HTTP URLs. Now mxdev caches all HTTP content in `.mxdev_cache/` during online mode and reuses it during offline mode, enabling true offline operation. This fixes the inconsistent behavior where `-o/--offline` didn't prevent all network activity.
6+
[jensens]
7+
- Improvement: Enhanced help text for `-n/--no-fetch`, `-f/--fetch-only`, and `-o/--offline` command-line options to better explain their differences and when to use each one.
8+
[jensens]
69

710
## 5.0.1 (2025-10-23)
811

README.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ The **main section** must be called `[settings]`, even if kept empty.
8585
| `default-target` | Target directory for VCS checkouts | `./sources` |
8686
| `threads` | Number of parallel threads for fetching sources | `4` |
8787
| `smart-threading` | Process HTTPS packages serially to avoid overlapping credential prompts (see below) | `True` |
88-
| `offline` | Skip all VCS fetch operations (handy for offline work) | `False` |
88+
| `offline` | Skip all VCS and HTTP fetches; use cached HTTP content from `.mxdev_cache/` (see below) | `False` |
8989
| `default-install-mode` | Default `install-mode` for packages: `editable`, `fixed`, or `skip` (see below) | `editable` |
9090
| `default-update` | Default update behavior: `yes` or `no` | `yes` |
9191
| `default-use` | Default use behavior (when false, sources not checked out) | `True` |
@@ -103,6 +103,34 @@ This solves the problem where parallel git operations would cause multiple crede
103103

104104
**When to disable**: Set `smart-threading = false` if you have git credential helpers configured (e.g., credential cache, credential store) and never see prompts.
105105

106+
##### Offline Mode and HTTP Caching
107+
108+
When `offline` mode is enabled (or via `-o/--offline` flag), mxdev operates without any network access:
109+
110+
1. **HTTP Caching**: HTTP-referenced requirements/constraints files are automatically cached in `.mxdev_cache/` during online mode
111+
2. **Offline Usage**: In offline mode, mxdev reads from the cache instead of fetching from the network
112+
3. **Cache Miss**: If a referenced HTTP file is not in the cache, mxdev will error and prompt you to run in online mode first
113+
114+
**Example workflow:**
115+
```bash
116+
# First run in online mode to populate cache
117+
mxdev
118+
119+
# Subsequent runs can be offline (e.g., on airplane, restricted network)
120+
mxdev -o
121+
122+
# Cache persists across runs, enabling true offline development
123+
```
124+
125+
**Cache location**: `.mxdev_cache/` (automatically added to `.gitignore`)
126+
127+
**When to use offline mode**:
128+
- Working without internet access (airplanes, restricted networks)
129+
- Testing configuration changes without re-fetching
130+
- Faster iterations when VCS sources are already checked out
131+
132+
**Note**: Offline mode tolerates missing source directories (logs warnings), while non-offline mode treats missing sources as fatal errors.
133+
106134
#### Package Overrides
107135

108136
##### `version-overrides`

src/mxdev/main.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,19 @@
2727
type=str,
2828
default="mx.ini",
2929
)
30-
parser.add_argument("-n", "--no-fetch", help="Do not fetch sources", action="store_true")
31-
parser.add_argument("-f", "--fetch-only", help="Only fetch sources", action="store_true")
32-
parser.add_argument("-o", "--offline", help="Do not fetch sources, work offline", action="store_true")
30+
parser.add_argument(
31+
"-n",
32+
"--no-fetch",
33+
help="Skip VCS checkout/update; regenerate files from existing sources (error if missing)",
34+
action="store_true",
35+
)
36+
parser.add_argument("-f", "--fetch-only", help="Only perform VCS operations; skip file generation", action="store_true")
37+
parser.add_argument(
38+
"-o",
39+
"--offline",
40+
help="Work offline; skip VCS and HTTP fetches; use cached files (tolerate missing)",
41+
action="store_true",
42+
)
3343
parser.add_argument(
3444
"-t",
3545
"--threads",

src/mxdev/processing.py

Lines changed: 136 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,83 @@
77
from urllib import request
88
from urllib.error import URLError
99

10+
import hashlib
1011
import os
1112
import typing
1213

1314

15+
def _get_cache_key(url: str) -> str:
16+
"""Generate a deterministic cache key from a URL.
17+
18+
Uses SHA256 hash of the URL, truncated to 16 hex characters for readability
19+
while maintaining low collision probability.
20+
21+
Args:
22+
url: The URL to generate a cache key for
23+
24+
Returns:
25+
16-character hex string (cache key)
26+
27+
"""
28+
hash_obj = hashlib.sha256(url.encode("utf-8"))
29+
return hash_obj.hexdigest()[:16]
30+
31+
32+
def _cache_http_content(url: str, content: str, cache_dir: Path) -> None:
33+
"""Cache HTTP content to disk.
34+
35+
Args:
36+
url: The URL being cached
37+
content: The content to cache
38+
cache_dir: Directory to store cache files
39+
40+
"""
41+
cache_dir.mkdir(parents=True, exist_ok=True)
42+
cache_key = _get_cache_key(url)
43+
44+
# Write content
45+
cache_file = cache_dir / cache_key
46+
cache_file.write_text(content, encoding="utf-8")
47+
48+
# Write URL metadata for debugging
49+
url_file = cache_dir / f"{cache_key}.url"
50+
url_file.write_text(url, encoding="utf-8")
51+
52+
logger.debug(f"Cached {url} to {cache_file}")
53+
54+
55+
def _read_from_cache(url: str, cache_dir: Path) -> str | None:
56+
"""Read cached HTTP content from disk.
57+
58+
Args:
59+
url: The URL to look up in cache
60+
cache_dir: Directory containing cache files
61+
62+
Returns:
63+
Cached content if found, None otherwise
64+
65+
"""
66+
if not cache_dir.exists():
67+
return None
68+
69+
cache_key = _get_cache_key(url)
70+
cache_file = cache_dir / cache_key
71+
72+
if cache_file.exists():
73+
logger.debug(f"Cache hit for {url} from {cache_file}")
74+
return cache_file.read_text(encoding="utf-8")
75+
76+
return None
77+
78+
1479
def process_line(
1580
line: str,
1681
package_keys: list[str],
1782
override_keys: list[str],
1883
ignore_keys: list[str],
1984
variety: str,
85+
offline: bool = False,
86+
cache_dir: Path | None = None,
2087
) -> tuple[list[str], list[str]]:
2188
"""Take line from a constraints or requirements file and process it recursively.
2289
@@ -41,6 +108,8 @@ def process_line(
41108
override_keys=override_keys,
42109
ignore_keys=ignore_keys,
43110
variety="c",
111+
offline=offline,
112+
cache_dir=cache_dir,
44113
)
45114
elif line.startswith("-r"):
46115
return resolve_dependencies(
@@ -49,6 +118,8 @@ def process_line(
49118
override_keys=override_keys,
50119
ignore_keys=ignore_keys,
51120
variety="r",
121+
offline=offline,
122+
cache_dir=cache_dir,
52123
)
53124
try:
54125
parsed = Requirement(line)
@@ -78,14 +149,18 @@ def process_io(
78149
override_keys: list[str],
79150
ignore_keys: list[str],
80151
variety: str,
152+
offline: bool = False,
153+
cache_dir: Path | None = None,
81154
) -> None:
82155
"""Read lines from an open file and trigger processing of each line
83156
84157
each line is processed and the result appendend to given requirements
85158
and constraint lists.
86159
"""
87160
for line in fio:
88-
new_requirements, new_constraints = process_line(line, package_keys, override_keys, ignore_keys, variety)
161+
new_requirements, new_constraints = process_line(
162+
line, package_keys, override_keys, ignore_keys, variety, offline, cache_dir
163+
)
89164
requirements += new_requirements
90165
constraints += new_constraints
91166

@@ -96,10 +171,23 @@ def resolve_dependencies(
96171
override_keys: list[str],
97172
ignore_keys: list[str],
98173
variety: str = "r",
174+
offline: bool = False,
175+
cache_dir: Path | None = None,
99176
) -> tuple[list[str], list[str]]:
100177
"""Takes a file or url, loads it and trigger to recursivly processes its content.
101178
102-
returns tuple of requirements and constraints
179+
Args:
180+
file_or_url: Path to local file or HTTP(S) URL
181+
package_keys: List of package names being developed from source
182+
override_keys: List of package names with version overrides
183+
ignore_keys: List of package names to ignore
184+
variety: "r" for requirements, "c" for constraints
185+
offline: If True, use cached HTTP content and don't make network requests
186+
cache_dir: Directory for caching HTTP content (default: ./.mxdev_cache)
187+
188+
Returns:
189+
Tuple of (requirements, constraints) as lists of strings
190+
103191
"""
104192
requirements: list[str] = []
105193
constraints: list[str] = []
@@ -113,6 +201,10 @@ def resolve_dependencies(
113201
# Windows drive letters are single characters, URL schemes are longer
114202
is_url = parsed.scheme and len(parsed.scheme) > 1
115203

204+
# Default cache directory
205+
if cache_dir is None:
206+
cache_dir = Path(".mxdev_cache")
207+
116208
if not is_url:
117209
requirements_in_file = Path(file_or_url)
118210
if requirements_in_file.exists():
@@ -125,25 +217,51 @@ def resolve_dependencies(
125217
override_keys,
126218
ignore_keys,
127219
variety,
220+
offline,
221+
cache_dir,
128222
)
129223
else:
130224
logger.info(
131225
f"Can not read {variety_verbose} file '{file_or_url}', " "it does not exist. Empty file assumed."
132226
)
133227
else:
134-
try:
135-
with request.urlopen(file_or_url) as fio:
136-
process_io(
137-
fio,
138-
requirements,
139-
constraints,
140-
package_keys,
141-
override_keys,
142-
ignore_keys,
143-
variety,
228+
# HTTP(S) URL handling with caching
229+
content: str
230+
if offline:
231+
# Offline mode: try to read from cache
232+
cached_content = _read_from_cache(file_or_url, cache_dir)
233+
if cached_content is None:
234+
raise RuntimeError(
235+
f"Offline mode: HTTP reference '{file_or_url}' not found in cache. "
236+
f"Run mxdev in online mode first to populate the cache at {cache_dir}"
144237
)
145-
except URLError as e:
146-
raise Exception(f"Failed to fetch '{file_or_url}': {e}")
238+
content = cached_content
239+
logger.info(f"Using cached content for {file_or_url}")
240+
else:
241+
# Online mode: fetch from HTTP and cache it
242+
try:
243+
with request.urlopen(file_or_url) as fio:
244+
content = fio.read().decode("utf-8")
245+
# Cache the content for future offline use
246+
_cache_http_content(file_or_url, content, cache_dir)
247+
except URLError as e:
248+
raise Exception(f"Failed to fetch '{file_or_url}': {e}")
249+
250+
# Process the content (either from cache or fresh from HTTP)
251+
from io import StringIO
252+
253+
with StringIO(content) as fio:
254+
process_io(
255+
fio,
256+
requirements,
257+
constraints,
258+
package_keys,
259+
override_keys,
260+
ignore_keys,
261+
variety,
262+
offline,
263+
cache_dir,
264+
)
147265

148266
if requirements and variety == "r":
149267
requirements = (
@@ -172,12 +290,16 @@ def read(state: State) -> None:
172290
173291
The result is stored on the state object
174292
"""
293+
from .config import to_bool
294+
175295
cfg = state.configuration
296+
offline = to_bool(cfg.settings.get("offline", False))
176297
state.requirements, state.constraints = resolve_dependencies(
177298
file_or_url=cfg.infile,
178299
package_keys=cfg.package_keys,
179300
override_keys=cfg.override_keys,
180301
ignore_keys=cfg.ignore_keys,
302+
offline=offline,
181303
)
182304

183305

0 commit comments

Comments
 (0)