Skip to content

Commit c29e3aa

Browse files
authored
Merge pull request #19 from machow/refactor-rsconnect
Refactor rsconnect
2 parents 8997013 + ce4da2a commit c29e3aa

File tree

6 files changed

+403
-386
lines changed

6 files changed

+403
-386
lines changed

pins/rsconnect_api.py renamed to pins/rsconnect/api.py

Lines changed: 1 addition & 378 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import tempfile
44
import json
55

6-
from dataclasses import dataclass, asdict, field, fields
6+
from dataclasses import dataclass
77
from pathlib import Path
88
from functools import partial
99
from io import IOBase
@@ -460,380 +460,3 @@ def create_first_admin(self, user, password, email, keyname="first-key"):
460460
)
461461

462462
return RsConnectApi(self.server_url, api_key=api_key["key"])
463-
464-
465-
@dataclass
466-
class PinBundleManifestMetadata:
467-
appmode: str = "static"
468-
primary_rmd: "str|None" = None
469-
primary_html: str = "index.html"
470-
content_category: str = "pin"
471-
has_parameters: bool = False
472-
473-
474-
@dataclass
475-
class PinBundleManifest:
476-
version: int = 1
477-
local: str = "en_US"
478-
platform: str = "3.5.1"
479-
metadata: PinBundleManifestMetadata = field(
480-
default_factory=PinBundleManifestMetadata
481-
)
482-
packages: None = None
483-
files: list = field(default_factory=list)
484-
users: None = None
485-
486-
@classmethod
487-
def from_directory(cls, dir_name, recursive: bool = True):
488-
root_dir = Path(dir_name)
489-
490-
paths = root_dir.rglob("*") if recursive else root_dir.glob("*")
491-
flat_rel_files = [str(p.relative_to(root_dir)) for p in paths]
492-
493-
return cls(files=flat_rel_files)
494-
495-
@classmethod
496-
def add_manifest_to_directory(cls, dir_name: "str | Path", **kwargs) -> None:
497-
import json
498-
499-
# TODO(question): R lib uses RSCONNECT_TAR env variable
500-
bundle = cls.from_directory(dir_name, **kwargs)
501-
with (Path(dir_name) / "manifest.json").open("w") as f_manifest:
502-
json.dump(bundle.to_dict(), f_manifest)
503-
504-
def to_dict(self):
505-
return asdict(self)
506-
507-
508-
# FSSPEC ----------------------------------------------------------------------
509-
510-
511-
@dataclass
512-
class EmptyPath:
513-
pass
514-
515-
516-
@dataclass
517-
class UserPath:
518-
username: str
519-
520-
def path_to_field(self, field_name):
521-
all_fields = [field.name for field in fields(self)]
522-
keep_fields = all_fields[: all_fields.index(field_name) + 1]
523-
return "/".join(getattr(self, k) for k in keep_fields)
524-
525-
526-
@dataclass
527-
class ContentPath(UserPath):
528-
content: str
529-
530-
531-
@dataclass
532-
class BundlePath(ContentPath):
533-
bundle: str
534-
535-
536-
@dataclass
537-
class BundleFilePath(BundlePath):
538-
file_name: str
539-
540-
541-
class RsConnectFs:
542-
protocol: str = "rsc"
543-
544-
def __init__(self, server_url, **kwargs):
545-
if isinstance(server_url, RsConnectApi):
546-
self.api = server_url
547-
else:
548-
self.api = RsConnectApi(server_url, **kwargs)
549-
550-
def ls(
551-
self, path, details=False, **kwargs
552-
) -> "Sequence[BaseEntity] | Sequence[str]":
553-
"""List contents of Rstudio Connect Server.
554-
555-
Parameters
556-
----------
557-
path: str
558-
"" -> users
559-
"<username>" -> user content
560-
"<username>/<content>" -> user content bundles
561-
"""
562-
563-
if isinstance(self.parse_path(path), EmptyPath):
564-
# root path specified, so list users
565-
all_results = self.api.get_users()
566-
567-
else:
568-
entity = self.info(path)
569-
570-
if isinstance(entity, User):
571-
all_results = self.api.get_content(entity["guid"])
572-
elif isinstance(entity, Content):
573-
all_results = self.api.get_content_bundles(entity["guid"])
574-
else:
575-
raise ValueError(
576-
"path must have form {username} or {username}/{content_name}"
577-
)
578-
579-
if not details:
580-
return [entry.get_name() for entry in all_results]
581-
582-
return all_results
583-
584-
# all_results = self.api.query(
585-
# "applications/", filter="content_type:pin", count=1000
586-
# )
587-
588-
def put(
589-
self,
590-
lpath,
591-
rpath,
592-
recursive=False,
593-
*args,
594-
deploy=True,
595-
cls_manifest=PinBundleManifest,
596-
**kwargs,
597-
) -> None:
598-
"""Put a bundle onto Rstudio Connect.
599-
600-
Parameters
601-
----------
602-
lpath: str
603-
A path to the local bundle directory.
604-
rpath: str
605-
A path to the content where the bundle is being put.
606-
cls_manifest:
607-
If maniest does not exist, a class with an .add_manifest_to_directory()
608-
method.
609-
610-
"""
611-
612-
parsed = self.parse_path(rpath)
613-
614-
if recursive is False:
615-
raise NotImplementedError(
616-
"Must set recursive to True in order to put any RSConnect content."
617-
)
618-
619-
if not isinstance(parsed, ContentPath):
620-
# TODO: replace all these with a custom PathError
621-
raise ValueError("Path must point to content.")
622-
623-
# Create content item if missing ----
624-
625-
try:
626-
content = self.info(rpath)
627-
except RsConnectApiMissingContentError:
628-
# TODO: this could be seen as analogous to mkdir (which gets
629-
# called by pins anyway)
630-
# TODO: hard-coded acl bad?
631-
content = self.api.post_content_item(parsed.content, "acl")
632-
633-
# Create bundle (with manifest.json inserted if missing) ----
634-
635-
if not (Path(lpath) / "manifest.json").exists():
636-
# TODO(question): does R pins copy content to tmp directory, or
637-
# insert mainfest.json into the source directory?
638-
cls_manifest.add_manifest_to_directory(lpath)
639-
640-
bundle = self.api.post_content_bundle(content["guid"], lpath)
641-
642-
# Deploy bundle ----
643-
644-
if deploy:
645-
task = self.api.post_content_item_deploy(
646-
bundle["content_guid"], bundle["id"]
647-
)
648-
649-
task = self.api.poll_tasks(task["task_id"])
650-
if task["code"] != 0 or not task["finished"]:
651-
raise RsConnectApiError(f"deployment failed for task: {task}")
652-
653-
# TODO: should return bundle itself?
654-
return f"{rpath}/{bundle['id']}"
655-
656-
def open(self, path: str, mode: str = "rb", *args, **kwargs):
657-
"""Open a file inside an RStudio Connect bundle."""
658-
659-
if mode != "rb":
660-
raise NotImplementedError()
661-
662-
parsed = self.parse_path(path)
663-
664-
if not isinstance(parsed, BundleFilePath):
665-
raise ValueError(
666-
"Path to a bundle file required. "
667-
"e.g. <user_name>/<content_name>/<bundle_id>/<file_name>"
668-
)
669-
670-
bundle = self.info(
671-
parsed.path_to_field("bundle")
672-
) # f"{parsed.username}/{parsed.content}/{parsed.bundle}")
673-
674-
# TODO: do whatever other remote backends do
675-
from io import BytesIO
676-
677-
f = BytesIO()
678-
679-
self.api.misc_get_content_bundle_file(
680-
bundle["content_guid"], bundle["id"], parsed.file_name, f
681-
)
682-
f.seek(0)
683-
return f
684-
685-
def get(self, rpath, lpath, recursive=False, *args, **kwargs) -> None:
686-
"""Fetch a bundle or file from RStudio Connect."""
687-
parsed = self.parse_path(rpath)
688-
689-
if recursive:
690-
if not isinstance(parsed, BundlePath):
691-
raise ValueError("Must receive path to bundle for recursive get.")
692-
693-
bundle = self.info(rpath)
694-
self.api.get_content_bundle_archive(
695-
bundle["content_guid"], bundle["id"], lpath
696-
)
697-
698-
elif isinstance(parsed, BundleFilePath):
699-
bundle = self.info(parsed.path_to_field("bundle"))
700-
self.api.misc_get_content_bundle_file(
701-
bundle["content_guid"], bundle["id"], parsed.file_name, lpath
702-
)
703-
704-
def exists(self, path: str, **kwargs) -> bool:
705-
try:
706-
self.info(path)
707-
return True
708-
except RsConnectApiMissingContentError:
709-
return False
710-
711-
def mkdir(self, path, create_parents=True, **kwargs) -> None:
712-
parsed = self.parse_path(path)
713-
714-
if not isinstance(parsed, ContentPath):
715-
raise ValueError(f"Requires path to content, but received: {path}")
716-
717-
if self.exists(path):
718-
raise FileExistsError(path)
719-
720-
# TODO: could implement and call makedirs, but seems overkill
721-
# TODO: hard-coded "acl"?
722-
self.api.post_content_item(parsed.content, "acl", **kwargs)
723-
724-
def info(self, path, **kwargs) -> "User | Content | Bundle":
725-
# TODO: source of fsspec info uses self._parent to check cache?
726-
# S3 encodes refresh (for local cache) and version_id arguments
727-
728-
return self._get_entity_from_path(path)
729-
730-
def rm(self, path, recursive=False, maxdepth=None) -> None:
731-
parsed = self.parse_path(path)
732-
733-
# guards ----
734-
if maxdepth is not None:
735-
raise NotImplementedError("rm maxdepth argument not supported.")
736-
if isinstance(parsed, BundleFilePath):
737-
raise ValueError("Cannot rm a bundle file.")
738-
739-
# time to delete things ----
740-
entity = self.info(path)
741-
742-
if isinstance(entity, User):
743-
raise ValueError("Cannot rm a user.")
744-
if isinstance(entity, Content):
745-
if not recursive:
746-
raise ValueError("Must set recursive to true if deleting content.")
747-
748-
self.api.delete_content_item(entity["guid"])
749-
750-
elif isinstance(entity, Bundle):
751-
self.api.delete_content_bundle(entity["content_guid"], entity["id"])
752-
else:
753-
raise ValueError("Cannot entity: {type(entity)}")
754-
755-
# Utils ----
756-
757-
def parse_path(self, path):
758-
# root can be indicated by a slash
759-
if path.startswith("/"):
760-
path = path[1:]
761-
762-
parts = path.split("/")
763-
if path.strip() == "":
764-
return EmptyPath()
765-
elif len(parts) == 1:
766-
return UserPath(*parts)
767-
elif len(parts) == 2:
768-
return ContentPath(*parts)
769-
elif len(parts) == 3:
770-
return BundlePath(*parts)
771-
elif len(parts) == 4:
772-
return BundleFilePath(*parts)
773-
else:
774-
raise ValueError(f"Unable to parse path: {path}")
775-
776-
def _get_entity_from_path(self, path):
777-
parsed = self.parse_path(path)
778-
779-
# guard against empty paths
780-
if isinstance(parsed, EmptyPath):
781-
raise ValueError(f"Cannot fetch root path: {path}")
782-
783-
# note this sequence of ifs is essentially a case statement going down
784-
# a line from parent -> child -> grandchild
785-
if isinstance(parsed, UserPath):
786-
crnt = user = self._get_user_from_name(parsed.username)
787-
788-
if isinstance(parsed, ContentPath):
789-
user_guid = user["guid"]
790-
791-
# user_guid + content name should uniquely identify content, but
792-
# double check to be safe.
793-
crnt = content = self._get_content_from_name(user_guid, parsed.content)
794-
795-
if isinstance(parsed, BundlePath):
796-
content_guid = content["guid"]
797-
crnt = self._get_content_bundle(content_guid, parsed.bundle)
798-
799-
return crnt
800-
801-
def _get_content_from_name(self, user_guid, content_name):
802-
"""Fetch a single content entity."""
803-
804-
# user_guid + content name should uniquely identify content, but
805-
# double check to be safe.
806-
contents = self.api.get_content(user_guid, content_name)
807-
if len(contents) != 1:
808-
err = (
809-
RsConnectApiMissingContentError
810-
if len(contents) == 0
811-
else RsConnectApiResultError
812-
)
813-
raise err(
814-
f"Expecting 1 content entry, but found {len(contents)}: {contents}"
815-
)
816-
return contents[0]
817-
818-
def _get_content_bundle(self, content_guid, bundle_id):
819-
"""Fetch a content bundle."""
820-
821-
try:
822-
bundle = self.api.get_content_bundle(content_guid, bundle_id)
823-
except RsConnectApiRequestError as e:
824-
if e.args[0]["code"] == RSC_CODE_OBJECT_DOES_NOT_EXIST:
825-
raise RsConnectApiMissingContentError(
826-
f"No bundle {bundle_id} for content {content_guid}"
827-
)
828-
raise e
829-
830-
return bundle
831-
832-
def _get_user_from_name(self, name):
833-
"""Fetch a single user entity from user name."""
834-
users = self.api.get_users(prefix=name)
835-
try:
836-
user_guid = next(iter([x for x in users if x["username"] == name]))
837-
return user_guid
838-
except StopIteration:
839-
raise ValueError(f"No user named {name} found.")

0 commit comments

Comments
 (0)