Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,6 @@ CLAUDE.md
# Direnv
.envrc
.direnv

# Local scratch / experiment dirs
scratch/
4 changes: 2 additions & 2 deletions novem/cli/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ def __call__(self, args: Dict[str, Any]) -> None:
path = args["load"]

print(f'Loading api tree structure from "{path}"')
vis.api_load(inpath=path)
vis.api_load(inpath=path, dry_run=args.get("dry_run", False))
return

# if we detect a tree query then we'll discard all other IO
Expand Down Expand Up @@ -405,7 +405,7 @@ def job(args: Dict[str, Any]) -> None:
if "load" in args and args["load"]:
path = args["load"]
print(f'Loading api tree structure from "{path}"')
j.api_load(inpath=path)
j.api_load(inpath=path, dry_run=args.get("dry_run", False))
return

# --tree: print API tree structure
Expand Down
9 changes: 9 additions & 0 deletions novem/cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ def setup(raw_args: Any = None) -> Tuple[Any, Dict[str, str]]:
help=ap.SUPPRESS,
)

parser.add_argument(
"--dry-run",
dest="dry_run",
action="store_true",
required=False,
default=False,
help=ap.SUPPRESS,
)

parser.add_argument(
"--version",
dest="version",
Expand Down
118 changes: 7 additions & 111 deletions novem/job/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from ..api_ref import NovemAPI
from ..shared import NovemShare
from ..sync import NovemTreeSync
from ..tags import NovemTags
from ..utils import cl
from ..utils import colors as clrs
Expand All @@ -17,7 +18,7 @@
"""


class NovemJobAPI(NovemAPI):
class NovemJobAPI(NovemTreeSync, NovemAPI):
config: Optional[NovemJobConfig]
shared: Optional[NovemShare]
tags: Optional[NovemTags]
Expand Down Expand Up @@ -453,117 +454,12 @@ def run(
if self._debug:
print(" files out: 0")

def api_dump(self, outpath: str) -> None:
"""
Iterate over current job and dump output to supplied path
"""

# Base path without trailing slash
qpath = self._path()

# create util function
def rec_tree(path: str) -> None:
qp = f"{qpath}{path}"
fp = f"{outpath}{path}"
req = self._session.get(qp)

if not req.ok:
return None

headers = req.headers
tp = headers.get("X-NVM-Type", headers.get("X-NS-Type", "file"))

# if i am a file, write to disc
if tp == "file":
# skip files with default values
if headers.get("x-nvm-default", "").lower() == "true":
print(f"Skipping default: {fp}")
return None
# ensure parent directory exists before writing
parent_dir = os.path.dirname(fp)
if parent_dir and not os.path.exists(parent_dir):
os.makedirs(parent_dir, exist_ok=True)
print(f"Creating folder: {parent_dir}")
with open(fp, "w") as f:
f.write(req.text)
print(f"Writing file: {fp}")
return None

# if I am a folder, recurse without creating yet
nodes: List[Dict[str, str]] = req.json()

# Recurse relevant structure (skip system entries and read-only files)
for r in nodes:
if r["type"] in ["system_file", "system_dir"]:
continue
child_path = f'{path}/{r["name"]}'
child_fp = f"{outpath}{child_path}"

# /shared/ and /tags/ are special markers - create empty files from listing
if r["type"] in ["file", "link"] and (
child_path.startswith("/shared/") or child_path.startswith("/tags/")
):
parent_dir = os.path.dirname(child_fp)
if parent_dir and not os.path.exists(parent_dir):
os.makedirs(parent_dir, exist_ok=True)
print(f"Creating folder: {parent_dir}")
with open(child_fp, "w") as f:
f.write("")
print(f"Writing file: {child_fp}")
continue

# skip read-only files/links
if r["type"] in ["file", "link"] and "w" not in r.get("permissions", []):
continue
rec_tree(child_path)

# start recursion
rec_tree("/")

def api_load(self, inpath: str) -> None:
"""
Load a dumped folder structure back into the API.
Walks the folder and for each file: PUT to create, then POST content.
"""
if self.user:
print("You cannot modify another user's job")
return

qpath = self._path()

def load_tree(local_path: str, api_path: str) -> None:
full_local = os.path.join(inpath, local_path.lstrip("/")) if local_path else inpath

if os.path.isfile(full_local):
# Read file content
with open(full_local, "r") as f:
content = f.read()

full_api = f"{qpath}{api_path}"

# Try PUT first to create the resource
r = self._session.put(full_api)
put_status = r.status_code

# POST the content
r = self._session.post(
full_api,
headers={"Content-type": "text/plain"},
data=content.encode("utf-8"),
)
print(f"Loaded file: {api_path} (PUT: {put_status}, POST: {r.status_code}, {len(content)} bytes)")

elif os.path.isdir(full_local):
print(f"Processing dir: {api_path or '/'}")

# Iterate over directory contents
for entry in sorted(os.listdir(full_local)):
entry_local = os.path.join(local_path, entry) if local_path else entry
entry_api = f"{api_path}/{entry}"
load_tree(entry_local, entry_api)
def _sync_base(self, user_aware: bool) -> str:
# _path() is already user-aware
return self._path()

# Start loading from root
load_tree("", "")
def _sync_label(self) -> str:
return "job"

def api_tree(self, colors: bool = False, relpath: str = "/") -> str:
"""
Expand Down
Loading