Skip to content

Conversation

@ieaves
Copy link
Collaborator

@ieaves ieaves commented Oct 17, 2025

There are a number of open issues related to pulling and running generic model artifacts with a number of open technical questions related to each. Among which include

This PR does not attempt to address all of these question. Given the scope of the problem though, I thought having some code to look at might form a useful jumping off point to begin picking off problems one at a time.

As pertains standards, one path is to implement a system capable of identifying discrete packaging strategies (like modelpack) but so far as I'm aware docker doesn't provide equivalent functionality to podman artifact. To that end, I've taken a rough shot at a pure python implementation for oci_artifacts.

I've attached some of this code to the rlcr transport option only because I have a variety of oras packaged model artifacts (e.g. rlcr://gemma3-270m:gguf) I could test against already.

I just want to reiterate this PR is only intended to facilitate the broader conversation around artifact pulling. Would it be better to only support podman for artifact running? Are there strong opinions about package structure?

Comments and thoughts would be welcome @rhatdan @engelmi.

Summary by Sourcery

Add pure Python OCI artifact pulling support and integrate it as a fallback in the RLCR transport when standard pulls fail.

New Features:

  • Implement a new oci_artifact module to fetch OCI manifests and download blobs over HTTP with checksum verification and token authentication
  • Enable RamalamaContainerRegistry to fall back to OCI artifact pulling when conventional container pulls fail

Enhancements:

  • Track and detect cached artifact snapshots in RLCR transport, overriding exists, pull, entry model path, and mount setup accordingly

Tests:

  • Add unit tests for RLCR fallback behavior, including successful and failed artifact download scenarios
  • Add unit tests for OCI artifact download to verify manifest parsing, blob retrieval, snapshot creation, and model path resolution

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Oct 17, 2025

Reviewer's Guide

This PR introduces Python-based OCI artifact pulling support and integrates it as a fallback in the RLCR transport when container pulls fail, along with unit tests validating both the fallback logic and the new OCI artifact download module.

Sequence diagram for RLCR transport artifact pulling fallback

sequenceDiagram
    participant User
    participant RLCRTransport
    participant OCI
    participant OCIArtifactModule
    User->>RLCRTransport: pull(args)
    RLCRTransport->>OCI: pull(args)
    alt OCI pull fails
        RLCRTransport->>OCIArtifactModule: attempt_artifact_pull(args)
        OCIArtifactModule->>OCIArtifactModule: download_oci_artifact(...)
        OCIArtifactModule-->>RLCRTransport: success/failure
        RLCRTransport-->>User: result
    else OCI pull succeeds
        OCI-->>RLCRTransport: success
        RLCRTransport-->>User: result
    end
Loading

Class diagram for new and updated RLCR and OCI artifact classes

classDiagram
    class RamalamaContainerRegistry {
        - _artifact_downloaded: bool
        + pull(args)
        + _attempt_artifact_pull(args) bool
        + _has_artifact_snapshot() bool
        + exists() bool
        + _get_entry_model_path(*args, **kwargs) str
        + setup_mounts(args)
    }
    RamalamaContainerRegistry --|> OCI

    class OCIRegistryClient {
        - registry: str
        - repository: str
        - reference: str
        - base_url: str
        - _bearer_token: str | None
        + get_manifest() (dict, str)
        + download_blob(digest, dest_path)
        + _prepare_headers(headers)
        + _open(url, headers)
        + _request_bearer_token(challenge)
    }

    class RegistryBlobSnapshotFile {
        + download(blob_file_path, snapshot_dir) str
        - client: OCIRegistryClient
        - digest: str
    }
    RegistryBlobSnapshotFile --|> SnapshotFile

    class SnapshotFile {
        <<external>>
    }

    class download_oci_artifact {
        <<function>>
    }
    OCIRegistryClient <.. RegistryBlobSnapshotFile : uses
    RegistryBlobSnapshotFile <.. download_oci_artifact : used by
Loading

File-Level Changes

Change Details Files
RLCR transport now falls back to OCI artifact download and tracks snapshot state
  • Initialize and track _artifact_downloaded based on existing snapshots
  • Override pull to catch CalledProcessError and call _attempt_artifact_pull
  • Implement _attempt_artifact_pull and _has_artifact_snapshot methods
  • Extend exists, _get_entry_model_path, and setup_mounts to respect artifact downloads
ramalama/transports/rlcr.py
Add pure-Python OCI artifact client and download integration
  • Create OCIRegistryClient for manifest fetching and blob downloads with token authentication
  • Define RegistryBlobSnapshotFile and snapshot file type inference
  • Implement download_oci_artifact to assemble snapshot files and drive model_store.new_snapshot
  • Add utility functions for reference splitting and file type mapping
ramalama/transports/oci_artifact.py
Enhance unit tests to cover RLCR fallback and OCI artifact download
  • Augment test args with new artifact-related flags
  • Add TestRLCRArtifactFallback for pull fallback success and failure
  • Add TestOCIArtifactDownload to simulate OCI client and verify snapshot creation
test/unit/test_rlcr.py

Possibly linked issues

  • #unknown: PR implements pure Python OCI artifact pulling, addressing the issue's question on pulling new OCI artifact types.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello @ieaves, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request lays the groundwork for more flexible model artifact management by introducing direct OCI artifact pulling capabilities. It provides a pure Python implementation for interacting with OCI registries to fetch model artifacts, serving as an initial step towards addressing broader issues related to generic model artifact handling. The changes integrate this new functionality into the existing rlcr transport, enabling it to fall back to OCI artifact pulling when traditional container image pulls are unsuccessful, thereby enhancing the robustness of model retrieval.

Highlights

  • OCI Artifact Pulling Implementation: Introduces a new Python module (oci_artifact.py) to handle pulling Open Container Initiative (OCI) artifacts from registries, including manifest fetching, authentication, and blob downloading.
  • Fallback Mechanism for Model Pulling: Modifies the rlcr transport to attempt OCI artifact pulling as a fallback mechanism if the primary container image pulling (e.g., via Podman) fails.
  • Enhanced Model Lifecycle Management: Integrates OCI artifact handling into the RamalamaContainerRegistry class, allowing it to recognize and manage models downloaded as OCI artifacts, affecting exists, _get_entry_model_path, and setup_mounts methods.
  • Comprehensive Unit Tests: Adds new unit tests to validate the OCI artifact fallback logic and the core functionality of downloading OCI artifacts and creating model snapshots.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@ieaves ieaves marked this pull request as draft October 17, 2025 23:10
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location> `ramalama/transports/rlcr.py:63` </location>
<code_context>
+            raise exc
+
+    def _attempt_artifact_pull(self, args) -> bool:
+        registry, reference, _ = self._target_decompose(self.model)
+
+        success = False
</code_context>

<issue_to_address>
**nitpick:** Unused third value from _target_decompose could be omitted for clarity.

Consider renaming the unused variable to '_' to make it clear that it is intentionally discarded. If the third value is never used elsewhere, refactoring _target_decompose to return only the required values may improve clarity.
</issue_to_address>

### Comment 2
<location> `ramalama/transports/oci_artifact.py:117-126` </location>
<code_context>
+
+        hasher = hashlib.sha256()
+
+        with TemporaryFile() as out_file:
+            while True:
+                chunk = response.read(BLOB_CHUNK_SIZE)
+                if not chunk:
+                    break
+                out_file.write(chunk)
+                if hash_algo == "sha256":
+                    hasher.update(chunk)
+
+            if hash_algo == "sha256" and (actual_hash := hasher.hexdigest()) != expected_hash:
+                raise ValueError(f"Digest mismatch for {digest}: expected {expected_hash}, got {actual_hash}")
+
+            os.replace(out_file.name, dest_path)
+
+    def _prepare_headers(self, headers: dict[str, str] | None = None) -> dict[str, str]:
</code_context>

<issue_to_address>
**issue:** Using TemporaryFile with os.replace may not work as expected on all platforms.

TemporaryFile may not provide a usable file path for os.replace on all platforms. Use NamedTemporaryFile with delete=False for better compatibility, or write directly to dest_path.
</issue_to_address>

### Comment 3
<location> `ramalama/transports/oci_artifact.py:133` </location>
<code_context>
+
+    def _prepare_headers(self, headers: dict[str, str] | None = None) -> dict[str, str]:
+        final_headers = dict() if headers is None else headers.copy()
+        final_headers.setdefault("Authorization", f"Bearer {self._bearer_token}")
+
+        return final_headers
</code_context>

<issue_to_address>
**issue (bug_risk):** Authorization header is set even if _bearer_token is None.

Set the Authorization header only when _bearer_token is not None to avoid sending 'Bearer None', which can cause authentication issues.
</issue_to_address>

### Comment 4
<location> `ramalama/transports/oci_artifact.py:193` </location>
<code_context>
+
+
+def _build_snapshot_files(client: OCIRegistryClient, manifest: dict[str, Any]) -> Iterable[SnapshotFile]:
+    descriptors = manifest.get("layers") or manifest.get("blobs") or []
+    for descriptor in descriptors:
+        digest = descriptor.get("digest")
</code_context>

<issue_to_address>
**issue (bug_risk):** Manifest parsing may miss descriptors if both 'layers' and 'blobs' are present.

Currently, only one of 'layers' or 'blobs' is processed, which may omit descriptors if both exist. Merge both lists to ensure all descriptors are included.
</issue_to_address>

### Comment 5
<location> `ramalama/transports/oci_artifact.py:81` </location>
<code_context>
+        return os.path.relpath(blob_file_path, start=snapshot_dir)
+
+
+class OCIRegistryClient:
+    def __init__(
+        self,
</code_context>

<issue_to_address>
**issue (complexity):** Consider replacing the custom HTTP and token handling code with the 'requests' library to simplify the implementation.

```markdown
Most of this file is a hand-rolled HTTP client, token parser and chunked downloader. You can preserve exactly the same behavior with far less code (and less nesting) by switching to `requests` (or `httpx`) and its streaming support. Here’s a sketch of how you could collapse most of `_open`, `_request_bearer_token`, `get_manifest()` and `download_blob()`:

```python
import requests

class OCIRegistryClient:
    def __init__(self, registry, repository, reference):
        self.session = requests.Session()
        self.base = f"https://{registry}/v2/{repository}"
        self.ref = reference

    def _authenticate(self, challenge):
        # parse WWW-Authenticate header into dict
        params = dict(item.split("=",1) for item in challenge.split(",") if "=" in item)
        params = {k: v.strip('"') for k,v in params.items()}
        url = params["realm"]
        resp = self.session.get(url, params={"service": params["service"], "scope": params.get("scope")})
        self.session.headers["Authorization"] = "Bearer " + resp.json().get("token")

    def _get(self, url, **kw):
        resp = self.session.get(url, **kw)
        if resp.status_code == 401 and "Bearer" in resp.headers.get("WWW-Authenticate", ""):
            self._authenticate(resp.headers["WWW-Authenticate"])
            resp = self.session.get(url, **kw)
        resp.raise_for_status()
        return resp

    def get_manifest(self):
        url = f"{self.base}/manifests/{self.ref}"
        resp = self._get(url, headers={"Accept": ",".join(MANIFEST_ACCEPT_HEADERS)})
        digest = resp.headers.get("Docker-Content-Digest") or f"sha256:{hashlib.sha256(resp.content).hexdigest()}"
        return resp.json(), digest

    def download_blob(self, digest, dest_path):
        url = f"{self.base}/blobs/{digest}"
        resp = self._get(url, stream=True)
        os.makedirs(os.path.dirname(dest_path), exist_ok=True)
        hasher = hashlib.sha256()
        with open(dest_path, "wb") as f:
            for chunk in resp.iter_content(chunk_size=BLOB_CHUNK_SIZE):
                f.write(chunk)
                hasher.update(chunk)
        algo, _, expected = digest.partition(":")
        if algo == "sha256" and hasher.hexdigest() != expected:
            raise ValueError(f"Digest mismatch {digest}")
```

Actionable steps:
1. Add `requests` to your dependencies.
2. Replace `urllib.request.urlopen` calls with `self.session.get(...)` (see `_get` above).
3. Use `resp.iter_content(...)` instead of `TemporaryFile` and manual reads.
4. Drop custom header‐merging and WWW-Authenticate parsing in favour of the `_authenticate` helper.

This preserves all existing functionality (manifest fetch + digest fallback, token retry, chunked download + checksum) in ~60 lines instead of 200+.
```
</issue_to_address>

### Comment 6
<location> `ramalama/transports/oci_artifact.py:99-101` </location>
<code_context>
        digest = response.headers.get("Docker-Content-Digest")
        if not digest:
            digest = f"sha256:{hashlib.sha256(manifest_bytes).hexdigest()}"

</code_context>

<issue_to_address>
**suggestion (code-quality):** Use `or` for providing a fallback value ([`use-or-for-fallback`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/use-or-for-fallback))

```suggestion
        digest = response.headers.get("Docker-Content-Digest") or f"sha256:{hashlib.sha256(manifest_bytes).hexdigest()}"

```

<br/><details><summary>Explanation</summary>Thanks to the flexibility of Python's `or` operator, you can use a single
assignment statement, even if a variable can retrieve its value from various
sources. This is shorter and easier to read than using multiple assignments with
`if not` conditions.
</details>
</issue_to_address>

### Comment 7
<location> `ramalama/transports/oci_artifact.py:132` </location>
<code_context>
        final_headers = dict() if headers is None else headers.copy()

</code_context>

<issue_to_address>
**suggestion (code-quality):** Replace `dict()` with `{}` ([`dict-literal`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/dict-literal))

```suggestion
        final_headers = {} if headers is None else headers.copy()
```

<br/><details><summary>Explanation</summary>The most concise and Pythonic way to create a dictionary is to use the `{}`
notation.

This fits in with the way we create dictionaries with items, saving a bit of
mental energy that might be taken up with thinking about two different ways of
creating dicts.

```python
x = {"first": "thing"}
```

Doing things this way has the added advantage of being a nice little performance
improvement.

Here are the timings before and after the change:

```
$ python3 -m timeit "x = dict()"
5000000 loops, best of 5: 69.8 nsec per loop
```

```
$ python3 -m timeit "x = {}"
20000000 loops, best of 5: 29.4 nsec per loop
```

Similar reasoning and performance results hold for replacing `list()` with `[]`.
</details>
</issue_to_address>

### Comment 8
<location> `ramalama/transports/rlcr.py:48-50` </location>
<code_context>
        self._artifact_downloaded = self._has_artifact_snapshot()
        if not self._artifact_downloaded:
            self._artifact_downloaded = super().exists()

</code_context>

<issue_to_address>
**suggestion (code-quality):** Use `or` for providing a fallback value ([`use-or-for-fallback`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/use-or-for-fallback))

```suggestion
        self._artifact_downloaded = self._has_artifact_snapshot() or super().exists()

```

<br/><details><summary>Explanation</summary>Thanks to the flexibility of Python's `or` operator, you can use a single
assignment statement, even if a variable can retrieve its value from various
sources. This is shorter and easier to read than using multiple assignments with
`if not` conditions.
</details>
</issue_to_address>

### Comment 9
<location> `test/unit/test_rlcr.py:149-150` </location>
<code_context>

</code_context>

<issue_to_address>
**issue (code-quality):** Avoid conditionals in tests. ([`no-conditionals-in-tests`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/no-conditionals-in-tests))

<details><summary>Explanation</summary>Avoid complex code, like conditionals, in test functions.

Google's software engineering guidelines says:
"Clear tests are trivially correct upon inspection"
To reach that avoid complex code in tests:
* loops
* conditionals

Some ways to fix this:

* Use parametrized tests to get rid of the loop.
* Move the complex logic into helpers.
* Move the complex part into pytest fixtures.

> Complexity is most often introduced in the form of logic. Logic is defined via the imperative parts of programming languages such as operators, loops, and conditionals. When a piece of code contains logic, you need to do a bit of mental computation to determine its result instead of just reading it off of the screen. It doesn't take much logic to make a test more difficult to reason about.

Software Engineering at Google / [Don't Put Logic in Tests](https://abseil.io/resources/swe-book/html/ch12.html#donapostrophet_put_logic_in_tests)
</details>
</issue_to_address>

### Comment 10
<location> `test/unit/test_rlcr.py:162-163` </location>
<code_context>

</code_context>

<issue_to_address>
**issue (code-quality):** Avoid conditionals in tests. ([`no-conditionals-in-tests`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/Python/Default-Rules/no-conditionals-in-tests))

<details><summary>Explanation</summary>Avoid complex code, like conditionals, in test functions.

Google's software engineering guidelines says:
"Clear tests are trivially correct upon inspection"
To reach that avoid complex code in tests:
* loops
* conditionals

Some ways to fix this:

* Use parametrized tests to get rid of the loop.
* Move the complex logic into helpers.
* Move the complex part into pytest fixtures.

> Complexity is most often introduced in the form of logic. Logic is defined via the imperative parts of programming languages such as operators, loops, and conditionals. When a piece of code contains logic, you need to do a bit of mental computation to determine its result instead of just reading it off of the screen. It doesn't take much logic to make a test more difficult to reason about.

Software Engineering at Google / [Don't Put Logic in Tests](https://abseil.io/resources/swe-book/html/ch12.html#donapostrophet_put_logic_in_tests)
</details>
</issue_to_address>

### Comment 11
<location> `ramalama/transports/oci_artifact.py:35-36` </location>
<code_context>
def get_snapshot_file_type(name: str, media_type: str) -> SnapshotFileType:
    if name.endswith(".gguf") or media_type.endswith(".gguf"):
        return SnapshotFileType.GGUFModel
    if name.endswith(".safetensors") or media_type.endswith("safetensors"):
        return SnapshotFileType.SafetensorModel
    if name.endswith(".mmproj"):
        return SnapshotFileType.Mmproj
    if name.endswith(".json"):
        return SnapshotFileType.Other
    return SnapshotFileType.Other

</code_context>

<issue_to_address>
**suggestion (code-quality):** We've found these issues:

- Lift code into else after jump in control flow ([`reintroduce-else`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/reintroduce-else/))
- Hoist repeated code outside conditional statement ([`hoist-statement-from-if`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/hoist-statement-from-if/))

```suggestion

```
</issue_to_address>

### Comment 12
<location> `ramalama/transports/oci_artifact.py:145-146` </location>
<code_context>
    def _open(self, url: str, headers: dict[str, str] | None = None):
        req = urllib.request.Request(url, headers=self._prepare_headers(headers))
        try:
            return urllib.request.urlopen(req)
        except urllib.error.HTTPError as exc:
            if exc.code == 401:
                www_authenticate = exc.headers.get("WWW-Authenticate", "")
                if "Bearer" in www_authenticate:
                    token = self._request_bearer_token(www_authenticate)
                    if token:
                        self._bearer_token = token
                        req = urllib.request.Request(url, headers=self._prepare_headers(headers))
                        return urllib.request.urlopen(req)
            raise

</code_context>

<issue_to_address>
**suggestion (code-quality):** Use named expression to simplify assignment and conditional ([`use-named-expression`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/use-named-expression/))

```suggestion
                    if token := self._request_bearer_token(www_authenticate):
```
</issue_to_address>

### Comment 13
<location> `ramalama/transports/oci_artifact.py:171` </location>
<code_context>
    def _request_bearer_token(self, challenge: str) -> str | None:
        scheme, _, params = challenge.partition(" ")
        if scheme.lower() != "bearer":
            return None

        auth_params: dict[str, str] = {}
        for item in params.split(","):
            if "=" not in item:
                continue
            key, value = item.strip().split("=", 1)
            auth_params[key.lower()] = value.strip('"')

        realm = auth_params.get("realm")
        if not realm:
            return None

        query = {}
        if "service" in auth_params:
            query["service"] = auth_params["service"]
        if "scope" in auth_params:
            query["scope"] = auth_params["scope"]
        else:
            query["scope"] = f"repository:{self.repository}:pull"
        token_url = realm
        if query:
            token_url = f"{realm}?{urllib.parse.urlencode(query)}"

        req_headers = {"User-Agent": "ramalama/oci-artifact"}

        request = urllib.request.Request(token_url, headers=req_headers)
        try:
            response = urllib.request.urlopen(request)
            data = json.loads(response.read().decode("utf-8"))
            token = data.get("token") or data.get("access_token")
            return token
        except urllib.error.URLError as exc:
            perror(f"Failed to obtain registry token: {exc}")
            return None

</code_context>

<issue_to_address>
**issue (code-quality):** We've found these issues:

- Replace if statement with if expression ([`assign-if-exp`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/assign-if-exp/))
- Inline variable that is immediately returned ([`inline-immediately-returned-variable`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/inline-immediately-returned-variable/))
- Simplify dictionary access using default get ([`default-get`](https://docs.sourcery.ai/Reference/Default-Rules/refactorings/default-get/))
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +117 to +126
with TemporaryFile() as out_file:
while True:
chunk = response.read(BLOB_CHUNK_SIZE)
if not chunk:
break
out_file.write(chunk)
if hash_algo == "sha256":
hasher.update(chunk)

if hash_algo == "sha256" and (actual_hash := hasher.hexdigest()) != expected_hash:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Using TemporaryFile with os.replace may not work as expected on all platforms.

TemporaryFile may not provide a usable file path for os.replace on all platforms. Use NamedTemporaryFile with delete=False for better compatibility, or write directly to dest_path.


def _prepare_headers(self, headers: dict[str, str] | None = None) -> dict[str, str]:
final_headers = dict() if headers is None else headers.copy()
final_headers.setdefault("Authorization", f"Bearer {self._bearer_token}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Authorization header is set even if _bearer_token is None.

Set the Authorization header only when _bearer_token is not None to avoid sending 'Bearer None', which can cause authentication issues.



def _build_snapshot_files(client: OCIRegistryClient, manifest: dict[str, Any]) -> Iterable[SnapshotFile]:
descriptors = manifest.get("layers") or manifest.get("blobs") or []
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Manifest parsing may miss descriptors if both 'layers' and 'blobs' are present.

Currently, only one of 'layers' or 'blobs' is processed, which may omit descriptors if both exist. Merge both lists to ensure all descriptors are included.

Comment on lines +35 to +36
if name.endswith(".json"):
return SnapshotFileType.Other
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): We've found these issues:

Suggested change
if name.endswith(".json"):
return SnapshotFileType.Other

Comment on lines +145 to +146
token = self._request_bearer_token(www_authenticate)
if token:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): Use named expression to simplify assignment and conditional (use-named-expression)

Suggested change
token = self._request_bearer_token(www_authenticate)
if token:
if token := self._request_bearer_token(www_authenticate):

query = {}
if "service" in auth_params:
query["service"] = auth_params["service"]
if "scope" in auth_params:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): We've found these issues:

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a pure Python implementation for pulling OCI artifacts, integrating it into the RLCR transport as a fallback mechanism. The changes include a new oci_artifact module for fetching OCI manifests and downloading blobs, enhancements to the RamalamaContainerRegistry to enable fallback to OCI artifact pulling, and added unit tests to verify the new functionality. I have provided comments addressing potential improvements in error handling and code clarity.


success = False
previous_store = self._model_store
self._model_store = None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

critical: Setting self._model_store to None before assigning the actual store could lead to issues if the model_store property is accessed during the artifact download process. This could cause a NoneType error. Consider assigning the store directly without setting it to None first.

        success = False
        store = self.model_store # Assign directly
        previous_store = self._model_store

Comment on lines +126 to +127
if hash_algo == "sha256" and (actual_hash := hasher.hexdigest()) != expected_hash:
raise ValueError(f"Digest mismatch for {digest}: expected {expected_hash}, got {actual_hash}")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

high: Consider using logger.error instead of raising a ValueError to allow the program to continue execution, possibly with a default value or alternative approach. This can prevent abrupt termination and provide a more graceful degradation of functionality. If the digest mismatch is critical and the application cannot proceed without a valid digest, then re-raising the exception is appropriate.

Comment on lines +55 to +60
except subprocess.CalledProcessError as exc:
if getattr(args, "dryrun", False):
raise
if self._attempt_artifact_pull(args):
return
raise exc
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

high: Re-raising the original exception after attempting the artifact pull might mask the underlying issue. Consider logging the artifact pull attempt's success or failure for debugging purposes.

Suggested change
except subprocess.CalledProcessError as exc:
if getattr(args, "dryrun", False):
raise
if self._attempt_artifact_pull(args):
return
raise exc
except subprocess.CalledProcessError as exc:
if getattr(args, "dryrun", False):
raise
if self._attempt_artifact_pull(args):
logger.info("Successfully pulled artifact as fallback.") # Log success
return
logger.error("Artifact pull failed after container pull.", exc_info=True) # Log failure
raise exc

Comment on lines +188 to +189
perror(f"Failed to obtain registry token: {exc}")
return None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

medium: It would be helpful to include the URL in the error message to provide more context for debugging.

Suggested change
perror(f"Failed to obtain registry token: {exc}")
return None
perror(f"Failed to obtain registry token from {token_url}: {exc}")
return None

Comment on lines +92 to +93
except Exception:
return False
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

medium: Catching Exception is too broad. It might hide important errors. Consider catching more specific exceptions like OSError or ValueError that are more likely to occur when accessing the model store.

        try:
            _, cached_files, complete = self.model_store.get_cached_files(self.model_tag)
            return complete and bool(cached_files)
        except (OSError, ValueError): # Catch specific exceptions
            return False

@rhatdan
Copy link
Member

rhatdan commented Nov 3, 2025

@ieaves still working on this? I think this along with the podman PR which is waiting for podman 5.7 release should move us forward with artifact support.

@ieaves
Copy link
Collaborator Author

ieaves commented Nov 6, 2025

@rhatdan I'm taking a look at your podman changes now. I need to adapt this to use

  1. The podman annotation specs
  2. Add this is a fallback to the podman functionality you PRed.

rhatdan and others added 2 commits November 19, 2025 13:48
Signed-off-by: Ian Eaves <[email protected]>
@ieaves ieaves force-pushed the feat/artifact-pulling branch from 06ba53b to 7c52164 Compare November 20, 2025 21:32
@ieaves
Copy link
Collaborator Author

ieaves commented Nov 21, 2025

@rhatdan I rebased this to your branch and took a shot at integrating a hierarchy of decision strategies depending on the users configuration. The basic structure is supposed to be

  1. If there is a sufficiently advanced version of podman use podman pull
  2. If not, identify whether the artifact exists locally and check the manifest to distinguish artifacts from images
  3. If not, http probe the manifest to identify its type and use the appropriate strategy (docker / podman for images, http for artifacts)

Then, if the user is running docker we pipe the model file into the container since we can't use artifact/image mounting the same way we can with podman.

I haven't done extensive testing but I've got things working with both docker and podman testing against one of my models at rlcr.io/ramalama/gemma3-270m:gguf.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants