|
3 | 3 | import tempfile |
4 | 4 | import json |
5 | 5 |
|
6 | | -from dataclasses import dataclass, asdict, field, fields |
| 6 | +from dataclasses import dataclass |
7 | 7 | from pathlib import Path |
8 | 8 | from functools import partial |
9 | 9 | from io import IOBase |
@@ -460,380 +460,3 @@ def create_first_admin(self, user, password, email, keyname="first-key"): |
460 | 460 | ) |
461 | 461 |
|
462 | 462 | 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