Skip to content

Commit 81acb33

Browse files
authored
Merge pull request #41 from cachedjdk/restore-modularity
Restore separation of concerns between internal modules
2 parents f8b3d5d + 68b93fb commit 81acb33

File tree

15 files changed

+306
-98
lines changed

15 files changed

+306
-98
lines changed

docs/development.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,23 @@ New notebook pages can be added by first creating the notebook (`.ipynb`) in
5353
Jupyter Lab, then running `jupytext mypage.ipynb --to myst`. Delete the
5454
`.ipynb` file so that the MyST (`.md`) file is the single source of truth.
5555

56+
## Modularity
57+
58+
We try to maintain good separation of concerns between modules, each of which
59+
should have a single area of responsibility. The division will undoubtedly
60+
evolve over time. See each module's docstring for their intended scope (and
61+
keep it up to date!). Think hard before introducing new dependencies between
62+
modules.
63+
64+
All internal modules (which currently are all modules except for `__init__.py`)
65+
begin with an underscore. The public API is defined by `_api` and `_exceptions`
66+
and exposed by `__init__.py`. The command-line interface, defined in
67+
`__main__.py`, uses the public API only.
68+
69+
All module-internal names are prefixed with an underscore. Such names should
70+
not be used from another module. Modules must avoid reaching into each other's
71+
internals.
72+
5673
(versioning-scheme)=
5774

5875
## Versioning

src/cjdk/__main__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,7 @@
99

1010
import click
1111

12-
from . import __version__, _api
13-
from ._exceptions import CjdkError
12+
from . import CjdkError, __version__, _api
1413

1514
__all__ = [
1615
"main",

src/cjdk/_api.py

Lines changed: 32 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
# This file is part of cjdk.
22
# Copyright 2022-25 Board of Regents of the University of Wisconsin System
33
# SPDX-License-Identifier: MIT
4+
5+
"""
6+
Public API surface.
7+
8+
Exposes all user-facing functions (which are re-exported by __init__.py).
9+
Coordinates calls to other modules.
10+
"""
11+
412
from __future__ import annotations
513

614
import hashlib
@@ -9,7 +17,7 @@
917
from contextlib import contextmanager
1018
from typing import TYPE_CHECKING
1119

12-
from . import _cache, _conf, _index, _install, _jdk
20+
from . import _conf, _install, _jdk
1321
from ._exceptions import (
1422
CjdkError,
1523
ConfigError,
@@ -64,7 +72,8 @@ def list_vendors(**kwargs: Unpack[ConfigKwargs]) -> list[str]:
6472
InstallError
6573
If fetching the index fails.
6674
"""
67-
return sorted(_get_vendors(**kwargs))
75+
conf = _conf.configure(**kwargs)
76+
return sorted(_jdk.available_vendors(conf))
6877

6978

7079
def list_jdks( # type: ignore [misc] # overlap with kwargs
@@ -113,9 +122,27 @@ def list_jdks( # type: ignore [misc] # overlap with kwargs
113122
InstallError
114123
If fetching the index fails.
115124
"""
116-
return _get_jdks(
117-
vendor=vendor, version=version, cached_only=cached_only, **kwargs
118-
)
125+
jdk = kwargs.pop("jdk", None)
126+
if jdk:
127+
parsed_vendor, parsed_version = _conf.parse_vendor_version(jdk)
128+
vendor = vendor or parsed_vendor or None
129+
version = version or parsed_version or None
130+
131+
if vendor is None:
132+
conf = _conf.configure(**kwargs)
133+
return [
134+
jdk
135+
for v in sorted(_jdk.available_vendors(conf))
136+
for jdk in list_jdks(
137+
vendor=v,
138+
version=version,
139+
cached_only=cached_only,
140+
**kwargs,
141+
)
142+
]
143+
144+
conf = _conf.configure(vendor=vendor, version=version, **kwargs)
145+
return _jdk.matching_jdks(conf, cached_only=cached_only)
119146

120147

121148
def clear_cache(**kwargs: Unpack[ConfigKwargs]) -> Path:
@@ -425,84 +452,6 @@ def cache_package(
425452
raise ConfigError(str(e)) from e
426453

427454

428-
def _get_vendors(**kwargs: Unpack[ConfigKwargs]) -> set[str]:
429-
conf = _conf.configure(**kwargs)
430-
index = _index.jdk_index(conf)
431-
return {
432-
vendor.replace("jdk@", "")
433-
for osys in index
434-
for arch in index[osys]
435-
for vendor in index[osys][arch]
436-
}
437-
438-
439-
def _get_jdks(
440-
*,
441-
vendor: str | None = None,
442-
version: str | None = None,
443-
cached_only: bool = True,
444-
**kwargs: Unpack[ConfigKwargs],
445-
) -> list[str]:
446-
jdk = kwargs.pop("jdk", None)
447-
if jdk:
448-
parsed_vendor, parsed_version = _conf.parse_vendor_version(jdk)
449-
vendor = vendor or parsed_vendor or None
450-
version = version or parsed_version or None
451-
452-
# Handle "all vendors" before creating Configuration.
453-
if vendor is None:
454-
return [
455-
jdk
456-
for v in sorted(_get_vendors())
457-
for jdk in _get_jdks(
458-
vendor=v,
459-
version=version,
460-
cached_only=cached_only,
461-
**kwargs,
462-
)
463-
]
464-
465-
conf = _conf.configure(vendor=vendor, version=version, **kwargs)
466-
index = _index.jdk_index(conf)
467-
jdks = _index.available_jdks(index, conf)
468-
versions = _index._get_versions(jdks, conf)
469-
matched = _index._match_versions(conf.vendor, versions, conf.version)
470-
471-
if cached_only:
472-
# Filter matches by existing key directories.
473-
def is_cached(v: str) -> bool:
474-
url = _index.jdk_url(index, conf, v)
475-
key = (_jdk._JDK_KEY_PREFIX, _cache._key_for_url(url))
476-
keydir = _cache._key_directory(conf.cache_dir, key)
477-
return keydir.exists()
478-
479-
matched = {k: v for k, v in matched.items() if is_cached(v)}
480-
481-
class VersionElement:
482-
def __init__(self, value: int | str) -> None:
483-
self.value = value
484-
485-
def __eq__(self, other: VersionElement) -> bool: # type: ignore[override]
486-
if isinstance(self.value, int) and isinstance(other.value, int):
487-
return self.value == other.value
488-
return str(self.value) == str(other.value)
489-
490-
def __lt__(self, other: VersionElement) -> bool:
491-
if isinstance(self.value, int) and isinstance(other.value, int):
492-
return self.value < other.value
493-
return str(self.value) < str(other.value)
494-
495-
def version_key(
496-
version_tuple: tuple[tuple[int | str, ...], str],
497-
) -> tuple[VersionElement, ...]:
498-
return tuple(VersionElement(elem) for elem in version_tuple[0])
499-
500-
return [
501-
f"{conf.vendor}:{v}"
502-
for k, v in sorted(matched.items(), key=version_key)
503-
]
504-
505-
506455
def _make_hash_checker(
507456
hashes: dict[str, str | None],
508457
) -> Callable[[Path], None]:

src/cjdk/_cache.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
# This file is part of cjdk.
22
# Copyright 2022-25 Board of Regents of the University of Wisconsin System
33
# SPDX-License-Identifier: MIT
4+
5+
"""
6+
Low-level caching primitives.
7+
8+
Manages URL-to-cache-key mapping (via SHA-1 hashing of normalized URLs), atomic
9+
file/directory operations, TTL-based freshness checks, and inter-process
10+
coordination (waiting when another process is downloading the same file).
11+
12+
No JDK-specific operations. All network/unarchive operations are
13+
dependency-injected.
14+
"""
15+
416
from __future__ import annotations
517

618
import hashlib
@@ -19,10 +31,17 @@
1931

2032
__all__ = [
2133
"atomic_file",
34+
"is_cached",
2235
"permanent_directory",
2336
]
2437

2538

39+
def is_cached(prefix: str, key_url: str, *, cache_dir: Path) -> bool:
40+
"""Check if content for the given prefix and URL is cached."""
41+
key = (prefix, _key_for_url(key_url))
42+
return _key_directory(cache_dir, key).is_dir()
43+
44+
2645
def _key_for_url(url: str | urllib.parse.ParseResult) -> str:
2746
"""
2847
Return a cache key suitable to cache content retrieved from the given URL.

src/cjdk/_conf.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
# This file is part of cjdk.
22
# Copyright 2022-25 Board of Regents of the University of Wisconsin System
33
# SPDX-License-Identifier: MIT
4+
5+
"""
6+
Configuration management.
7+
8+
Defines the Configuration dataclass, parses and validates parameters, detects
9+
platform (OS/architecture canonicalization), determines default cache
10+
directories (platform-specific), and resolves environment variable overrides
11+
(CJDK_*).
12+
13+
No actual operations.
14+
"""
15+
416
from __future__ import annotations
517

618
import os

src/cjdk/_download.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
# This file is part of cjdk.
22
# Copyright 2022-25 Board of Regents of the University of Wisconsin System
33
# SPDX-License-Identifier: MIT
4+
5+
"""
6+
HTTP downloads and archive extraction.
7+
8+
Downloads files via HTTPS with progress tracking, extracts .zip and .tgz
9+
archives, and preserves executable bits from zip files.
10+
11+
No JDK-specific or cache-related operations.
12+
"""
13+
414
from __future__ import annotations
515

616
import sys

src/cjdk/_exceptions.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22
# Copyright 2022-25 Board of Regents of the University of Wisconsin System
33
# SPDX-License-Identifier: MIT
44

5+
"""
6+
Exception hierarchy.
7+
8+
Defines the base CjdkError and specific subclasses. These are exposed to the
9+
API by __init__.py.
10+
"""
11+
512
__all__ = [
613
"CjdkError",
714
"ConfigError",

src/cjdk/_index.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,18 @@
11
# This file is part of cjdk.
22
# Copyright 2022-25 Board of Regents of the University of Wisconsin System
33
# SPDX-License-Identifier: MIT
4+
5+
"""
6+
JDK index handling.
7+
8+
Fetches and caches the Coursier JDK index, parses JSON, normalizes vendor names
9+
(e.g., merges ibm-semeru-*-java## variants), and performs version
10+
matching/resolution with support for version expressions like "17+".
11+
12+
No actual operations except for caching the index itself. _index should be
13+
considered an internal helper for _jdk and should not be used directly.
14+
"""
15+
416
from __future__ import annotations
517

618
import copy
@@ -19,9 +31,9 @@
1931

2032
__all__ = [
2133
"jdk_index",
22-
"available_jdks",
23-
"resolve_jdk_version",
2434
"jdk_url",
35+
"matching_jdk_versions",
36+
"resolve_jdk_version",
2537
]
2638

2739

@@ -43,7 +55,9 @@ def jdk_index(conf: Configuration) -> Index:
4355
return _read_index(_cached_index_path(conf))
4456

4557

46-
def available_jdks(index: Index, conf: Configuration) -> list[tuple[str, str]]:
58+
def _available_jdks(
59+
index: Index, conf: Configuration
60+
) -> list[tuple[str, str]]:
4761
"""
4862
Find in index the available JDK vendor-version combinations.
4963
@@ -71,7 +85,7 @@ def resolve_jdk_version(index: Index, conf: Configuration) -> str:
7185
Arguments:
7286
index -- The JDK index (nested dict)
7387
"""
74-
jdks = available_jdks(index, conf)
88+
jdks = _available_jdks(index, conf)
7589
versions = _get_versions(jdks, conf)
7690
if not versions:
7791
raise JdkNotFoundError(
@@ -266,3 +280,43 @@ def _is_version_compatible_with_spec(
266280
and version[len(spec) - 1] >= spec[-1]
267281
)
268282
return len(version) >= len(spec) and version[: len(spec)] == spec
283+
284+
285+
class _VersionElement:
286+
"""Wrapper for version tuple elements enabling mixed int/str comparison."""
287+
288+
def __init__(self, value: int | str) -> None:
289+
self.value = value
290+
291+
def __eq__(self, other: object) -> bool:
292+
if not isinstance(other, _VersionElement):
293+
return NotImplemented
294+
if isinstance(self.value, int) and isinstance(other.value, int):
295+
return self.value == other.value
296+
return str(self.value) == str(other.value)
297+
298+
def __lt__(self, other: _VersionElement) -> bool:
299+
if isinstance(self.value, int) and isinstance(other.value, int):
300+
return self.value < other.value
301+
return str(self.value) < str(other.value)
302+
303+
304+
def matching_jdk_versions(index: Index, conf: Configuration) -> list[str]:
305+
"""
306+
Return all version strings matching the configuration, sorted by version.
307+
308+
Unlike resolve_jdk_version() which returns only the best match, this
309+
returns all compatible versions.
310+
"""
311+
jdks = _available_jdks(index, conf)
312+
versions = _get_versions(jdks, conf)
313+
if not versions:
314+
return []
315+
matched = _match_versions(conf.vendor, versions, conf.version)
316+
317+
def version_sort_key(
318+
item: tuple[tuple[int | str, ...], str],
319+
) -> tuple[_VersionElement, ...]:
320+
return tuple(_VersionElement(e) for e in item[0])
321+
322+
return [v for _, v in sorted(matched.items(), key=version_sort_key)]

src/cjdk/_install.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
# This file is part of cjdk.
22
# Copyright 2022-25 Board of Regents of the University of Wisconsin System
33
# SPDX-License-Identifier: MIT
4+
5+
"""
6+
Installation orchestration.
7+
8+
Bridges _cache and _download: provides install_file and install_dir functions
9+
that coordinate downloading with caching, and prints progress headers.
10+
11+
No JDK-specific operations.
12+
"""
13+
414
from __future__ import annotations
515

616
import sys

0 commit comments

Comments
 (0)