Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
7c65baa
added context loader helper
james-rl Nov 20, 2025
5dcdbfd
Merge branch 'main' of github.com:runloopai/api-client-python
james-rl Nov 20, 2025
c8916eb
added context loader helper to make it easier to add build context wh…
james-rl Nov 20, 2025
4f47bf3
added oop build context functional method wrapper
james-rl Nov 20, 2025
5fb7945
added context helpers to SDKs
james-rl Nov 21, 2025
471334f
Merge branch 'main' of github.com:runloopai/api-client-python
james-rl Nov 21, 2025
0bc941b
Merge branch 'main' into james/ctxt-loader
james-rl Nov 21, 2025
dcbaad0
made ctxt example a bit better
james-rl Nov 21, 2025
e19c775
made this example better
james-rl Nov 21, 2025
fe9abbd
Merge branch 'main' of github.com:runloopai/api-client-python
james-rl Nov 24, 2025
f2a3b03
merged main
james-rl Nov 24, 2025
2f62fee
refactored ignore logic to follow moby (docker project) pattern match…
james-rl Nov 25, 2025
81dba83
fixed some tests and broken imports
james-rl Nov 25, 2025
e0a1725
improved typing and made the ignore matching less docker-specific
james-rl Nov 25, 2025
9ef3450
added tar filter for upload_from_dir
james-rl Nov 26, 2025
d5621ab
fixed bad type
james-rl Nov 26, 2025
bbc1f78
removed some dead code and standardized the ignore interface & made i…
james-rl Dec 1, 2025
7070b95
rolled back change that made tar its own filter type -- big misunders…
james-rl Dec 1, 2025
14c45c0
Merge branch 'main' of github.com:runloopai/api-client-python
james-rl Dec 3, 2025
deeea33
Merge branch 'main' into james/ctxt-loader
james-rl Dec 3, 2025
be047f6
Merge branch 'main' of github.com:runloopai/api-client-python
james-rl Dec 5, 2025
b50f80e
Merge branch 'main' into james/ctxt-loader
james-rl Dec 5, 2025
136ad7e
added handling for extremely weird edge case behavior for dockerignore
james-rl Dec 5, 2025
2a7fc17
added some type hints and override flags
james-rl Dec 5, 2025
597825d
docstring fixes and consolidation of duplicated ignore code
james-rl Dec 6, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 76 additions & 16 deletions README-SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,36 @@ The `RunloopSDK` builds on top of the underlying REST client and provides a Pyth

## Table of Contents

- [Installation](#installation)
- [Quickstart (synchronous)](#quickstart-synchronous)
- [Quickstart (asynchronous)](#quickstart-asynchronous)
- [Core Concepts](#core-concepts)
- [Devbox](#devbox)
- [Blueprint](#blueprint)
- [Snapshot](#snapshot)
- [StorageObject](#storageobject)
- [Mounting Storage Objects to Devboxes](#mounting-storage-objects-to-devboxes)
- [Accessing the Underlying REST Client](#accessing-the-underlying-rest-client)
- [Error Handling](#error-handling)
- [Advanced Configuration](#advanced-configuration)
- [Async Usage](#async-usage)
- [Polling Configuration](#polling-configuration)
- [Complete API Reference](#complete-api-reference)
- [Feedback](#feedback)
- [Runloop SDK – Python Object-Oriented Client](#runloop-sdk--python-object-oriented-client)
Comment thread
sid-rl marked this conversation as resolved.
Outdated
- [Table of Contents](#table-of-contents)
Comment thread
sid-rl marked this conversation as resolved.
Outdated
- [Installation](#installation)
- [Quickstart (synchronous)](#quickstart-synchronous)
- [Quickstart (asynchronous)](#quickstart-asynchronous)
- [Core Concepts](#core-concepts)
- [RunloopSDK](#runloopsdk)
- [Available Resources](#available-resources)
- [Devbox](#devbox)
- [Command Execution](#command-execution)
- [Execution Management](#execution-management)
- [Execution Results](#execution-results)
- [Streaming Command Output](#streaming-command-output)
- [File Operations](#file-operations)
- [Network Operations](#network-operations)
- [Snapshot Operations](#snapshot-operations)
- [Devbox Lifecycle Management](#devbox-lifecycle-management)
- [Context Manager Support](#context-manager-support)
- [Blueprint](#blueprint)
- [Snapshot](#snapshot)
- [StorageObject](#storageobject)
- [Storage Object Upload Helpers](#storage-object-upload-helpers)
- [Mounting Storage Objects to Devboxes](#mounting-storage-objects-to-devboxes)
- [Accessing the Underlying REST Client](#accessing-the-underlying-rest-client)
- [Error Handling](#error-handling)
- [Advanced Configuration](#advanced-configuration)
- [Async Usage](#async-usage)
- [Polling Configuration](#polling-configuration)
- [Complete API Reference](#complete-api-reference)
- [Feedback](#feedback)

## Installation

Expand Down Expand Up @@ -409,6 +423,52 @@ blueprint = runloop.blueprint.create(
system_setup_commands=["pip install numpy pandas"],
)

# Or create a blueprint with a Docker build context from a local directory
from pathlib import Path
from runloop_api_client.lib.context_loader import build_docker_context_tar

context_root = Path("./my-app")
tar_bytes = build_docker_context_tar(context_root)

build_ctx_obj = runloop.storage_object.upload_from_bytes(
data=tar_bytes,
name="my-app-context.tar.gz",
content_type="tgz",
)

shared_root = Path("./shared-lib")
shared_tar = build_docker_context_tar(shared_root)

shared_ctx_obj = runloop.storage_object.upload_from_bytes(
data=shared_tar,
name="shared-lib-context.tar.gz",
content_type="tgz",
)

blueprint_with_context = runloop.blueprint.create(
name="my-blueprint-with-context",
dockerfile=\"\"\"\
Comment thread
james-rl marked this conversation as resolved.
Outdated
FROM node:22
Comment thread
james-rl marked this conversation as resolved.
WORKDIR /usr/src/app

# copy using the build context from the object
COPY package.json package.json
COPY src src

# copy from named context
COPY --from=shared / ./libs

RUN npm install --only=production
CMD ["node", "src/app.js"]
\"\"\",
# Primary build context
build_context=build_ctx_obj.as_build_context(),
# Additional named build contexts (for Docker buildx-style usage)
named_build_contexts={
"shared": shared_ctx_obj.as_build_context(),
},
)

# Or get an existing one
blueprint = runloop.blueprint.from_id(blueprint_id="bpt_123")

Expand Down
3 changes: 3 additions & 0 deletions src/runloop_api_client/lib/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""
Helpers for `runloop_api_client`.
"""
Comment on lines +1 to +3

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

stop pytest from complaining when using resources from here

283 changes: 283 additions & 0 deletions src/runloop_api_client/lib/_ignore.py
Comment thread
sid-rl marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
from __future__ import annotations

import os
from typing import Iterable, Optional, Sequence
from pathlib import Path, PurePosixPath
from dataclasses import dataclass

__all__ = [
"IgnorePattern",
"read_ignorefile",
"compile_ignore",
"path_match",
"is_ignored",
]


@dataclass(frozen=True)
class IgnorePattern:
"""Single parsed ignore pattern.

Follows Docker-style .dockerignore semantics, supports other ignore use cases following same approach.

Details:
- ``pattern``: The normalized pattern text with leading/trailing ``/`` removed.
Always uses POSIX ``'/'`` separators.
- ``negated``: True if this is a negation pattern starting with ``!``.
- ``directory_only``: True if the original pattern ended with ``/`` and should
apply only to directories and their descendants.
- ``anchored``: True if the pattern contains a path separator and should be
matched relative to the root path rather than at any depth.
Comment thread
sid-rl marked this conversation as resolved.
Outdated
"""

pattern: str
negated: bool
directory_only: bool
anchored: bool


def _normalize_pattern_line(raw: bytes, *, is_first_line: bool) -> Optional[str]:
"""Normalize a single ignorefile line, mirroring moby's ignorefile.ReadAll.

Behavior is based on:
https://github.com/moby/patternmatcher/blob/main/ignorefile/ignorefile.go
"""

# Strip UTF-8 BOM from the first line if present
if is_first_line and raw.startswith(b"\xef\xbb\xbf"):
raw = raw[len(b"\xef\xbb\xbf") :]

# Decode as UTF-8; we are strict here to surface bad encodings
text = raw.decode("utf-8", errors="strict")
text = text.rstrip("\r\n")

# Lines starting with '#' are comments and are ignored before processing,
# i.e. we do *not* treat leading spaces as part of the comment detection.
if text.startswith("#"):
return None

# Trim leading and trailing whitespace
pattern = text.strip()
if not pattern:
return None

# Normalize absolute paths to paths relative to the context (taking care of '!' prefix)
invert = pattern[0] == "!"
if invert:
pattern = pattern[1:].strip()

if pattern:
# filepath.Clean equivalent
pattern = os.path.normpath(pattern)
Comment thread
sid-rl marked this conversation as resolved.
Outdated
# filepath.ToSlash equivalent
pattern = pattern.replace(os.sep, "/")
# Leading forward-slashes are removed so "/some/path" and "some/path"
# are considered equivalent.
if len(pattern) > 1 and pattern[0] == "/":
pattern = pattern[1:]

if invert:
pattern = "!" + pattern

return pattern


def read_ignorefile(path: Optional[Path]) -> list[str]:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I thought we discussed making an object for this so that it encapsulates the pattern parsing.

Ideally we have a class like DockerIgnoreMatcher("path/to/dockerignore") and it implements some interface that allows us to assign it to the upload_from_dir method:

await runloop.objects.upload_from_dir(
  "path/to/dir",
  name = "my-context",
  ignore=DockerIgnoreMatcher("path/to/dir/.dockerignore"),
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I made an interface for ignore types so you can have different .ignore files & patterns and added a DockerIgnoreMatcher type

Comment thread
sid-rl marked this conversation as resolved.
Outdated
"""Read an ignore file and return a list of normalized pattern strings.

This mirrors the behavior of moby's ``ignorefile.ReadAll``:

- UTF-8 BOM on the first line is stripped.
- Lines starting with ``#`` are treated as comments and skipped.
- Remaining lines are trimmed, optionally negated with ``!``, cleaned,
have path separators normalized to ``/``, and leading ``/`` removed.
Comment thread
sid-rl marked this conversation as resolved.
Outdated
"""

if path is None:
return []

if not path.exists():
return []

patterns: list[str] = []
with path.open("rb") as f:
first = True
for raw in f:
normalized = _normalize_pattern_line(raw, is_first_line=first)
first = False
if normalized is None:
continue
patterns.append(normalized)

return patterns


def compile_ignore(patterns: Sequence[str]) -> list[IgnorePattern]:
"""Compile raw pattern strings into :class:`IgnorePattern` objects."""

compiled: list[IgnorePattern] = []

for raw in patterns:
if not raw:
continue

negated = raw[0] == "!"
pattern_text = raw[1:] if negated else raw

if not pattern_text:
# Bare "!" is ignored, matching Docker / moby behavior.
continue

directory_only = pattern_text.endswith("/")
if directory_only:
pattern_text = pattern_text.rstrip("/")

if not pattern_text:
continue

# Treat patterns containing a path separator as anchored to the root
anchored = "/" in pattern_text

compiled.append(
IgnorePattern(
pattern=PurePosixPath(pattern_text).as_posix(),
negated=negated,
directory_only=directory_only,
anchored=anchored,
)
)

return compiled


def _segment_match(pattern_segment: str, path_segment: str) -> bool:
"""Match a single path segment against a glob pattern segment.

Supports:
- ``*``: any sequence of characters except ``/``.
- ``?``: any single character except ``/``.
- ``[]``: character classes, excluding ``/``.
"""

import re

escaped = ""
i = 0
while i < len(pattern_segment):
ch = pattern_segment[i]
if ch == "*":
escaped += "[^/]*"
elif ch == "?":
escaped += "[^/]"
elif ch == "[":
# Copy character class as-is until closing ']'.
j = i + 1
while j < len(pattern_segment) and pattern_segment[j] != "]":
j += 1
if j < len(pattern_segment):
escaped += pattern_segment[i : j + 1]
i = j
else:
# Unterminated '['; treat it literally.
escaped += re.escape(ch)
else:
escaped += re.escape(ch)
i += 1

regex = re.compile(rf"^{escaped}$")
return regex.match(path_segment) is not None


def _match_parts_recursive(pattern_parts: list[str], path_parts: list[str]) -> bool:
"""Recursive helper implementing ``**`` segment semantics."""

if not pattern_parts:
return not path_parts

if pattern_parts[0] == "**":
# '**' matches zero or more segments.
for i in range(len(path_parts) + 1):
if _match_parts_recursive(pattern_parts[1:], path_parts[i:]):
return True
return False

if not path_parts:
return False

if not _segment_match(pattern_parts[0], path_parts[0]):
return False

return _match_parts_recursive(pattern_parts[1:], path_parts[1:])


def path_match(pattern: IgnorePattern, relpath: str, *, is_dir: bool) -> bool:
"""Return True if ``relpath`` matches a compiled ignore pattern."""

relpath_posix = PurePosixPath(relpath).as_posix()
path_parts = PurePosixPath(relpath_posix).parts
pattern_parts = PurePosixPath(pattern.pattern).parts

# Directory-only patterns never directly match files here; the effect on
# descendants is enforced by directory pruning in the traversal.
if pattern.directory_only and not is_dir:
return False

if pattern.anchored:
return _match_parts_recursive(list(pattern_parts), list(path_parts))

for start in range(len(path_parts)):
if _match_parts_recursive(list(pattern_parts), list(path_parts[start:])):
return True
return False


def is_ignored(relpath: str, *, is_dir: bool, patterns: Sequence[IgnorePattern]) -> bool:
"""Apply ignore patterns with 'last match wins' semantics.

Examples::

*.log
!important.log

excludes all ``.log`` files except ``important.log``. Patterns are applied
in order, and the last matching pattern determines inclusion.
"""

included = True # include by default
for pat in patterns:
if path_match(pat, relpath, is_dir=is_dir):
included = pat.negated
return not included


def iter_included_files(
root: Path,
*,
patterns: Sequence[IgnorePattern],
) -> Iterable[Path]:
"""Yield all files under ``root`` that are not ignored.

This performs directory pruning so that ignored directories are never
traversed, mirroring Docker's behavior for .dockerignore.
"""

if not root.is_dir():
raise ValueError(f"root must be a directory, got: {root}")

for dirpath, dirs, files in os.walk(root):
dir_path = Path(dirpath)

# Prune ignored directories
for name in list(dirs):
subdir = dir_path / name
rel_dir = subdir.relative_to(root).as_posix()
if is_ignored(rel_dir, is_dir=True, patterns=patterns):
dirs.remove(name)

# Yield non-ignored files
for name in files:
file_path = dir_path / name
rel_file = file_path.relative_to(root).as_posix()
if is_ignored(rel_file, is_dir=False, patterns=patterns):
continue
yield file_path
Loading