From f77025db98490baaffc46cc3dffa6cdb92586db3 Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Thu, 9 Apr 2026 18:59:22 -0400 Subject: [PATCH 01/10] add support for sdk commands --- cterasdk/asynchronous/core/files/__init__.py | 2 +- cterasdk/asynchronous/core/files/browser.py | 46 ++++- cterasdk/asynchronous/core/files/io.py | 4 + .../asynchronous/core/invitation/__init__.py | 0 .../asynchronous/core/invitation/login.py | 18 ++ cterasdk/cio/common.py | 10 +- cterasdk/cio/core/commands.py | 61 +++++-- cterasdk/cio/core/types.py | 61 +++++-- cterasdk/cli/__init__.py | 0 cterasdk/cli/dav.py | 162 ++++++++++++++++++ cterasdk/{direct/cli.py => cli/direct.py} | 3 +- cterasdk/core/enum.py | 2 + cterasdk/core/files/__init__.py | 2 +- cterasdk/core/files/browser.py | 47 ++++- cterasdk/core/files/io.py | 4 + cterasdk/core/invitation/__init__.py | 0 cterasdk/core/invitation/login.py | 19 ++ cterasdk/core/types.py | 11 ++ cterasdk/exceptions/io/core.py | 12 ++ cterasdk/lib/session/base.py | 20 ++- cterasdk/lib/session/core.py | 61 ++++--- cterasdk/lib/session/edge.py | 6 +- cterasdk/lib/storage/asynfs.py | 24 ++- cterasdk/lib/storage/synfs.py | 19 +- cterasdk/objects/asynchronous/invitation.py | 50 ++++++ cterasdk/objects/invitation.py | 25 +++ cterasdk/objects/services.py | 1 - cterasdk/objects/synchronous/invitation.py | 50 ++++++ docs/source/UserGuides/CLI/Index.rst | 102 +++++++++++ .../DirectIO.rst | 2 +- .../UserGuides/ContentServices/Index.rst | 9 + .../NotificationService.rst | 0 docs/source/UserGuides/DataServices/Index.rst | 9 - .../cterasdk.objects.asynchronous.core.rst | 7 + .../cterasdk.objects.asynchronous.drive.rst | 7 + .../cterasdk.objects.asynchronous.edge.rst | 7 + ...erasdk.objects.asynchronous.invitation.rst | 7 + .../api/cterasdk.objects.asynchronous.rst | 17 ++ docs/source/api/cterasdk.objects.rst | 5 +- ...terasdk.objects.synchronous.invitation.rst | 7 + .../api/cterasdk.objects.synchronous.rst | 17 ++ docs/source/index.rst | 7 +- pyproject.toml | 3 +- 43 files changed, 835 insertions(+), 91 deletions(-) create mode 100644 cterasdk/asynchronous/core/invitation/__init__.py create mode 100644 cterasdk/asynchronous/core/invitation/login.py create mode 100644 cterasdk/cli/__init__.py create mode 100644 cterasdk/cli/dav.py rename cterasdk/{direct/cli.py => cli/direct.py} (95%) create mode 100644 cterasdk/core/invitation/__init__.py create mode 100644 cterasdk/core/invitation/login.py create mode 100644 cterasdk/objects/asynchronous/invitation.py create mode 100644 cterasdk/objects/invitation.py create mode 100644 cterasdk/objects/synchronous/invitation.py create mode 100644 docs/source/UserGuides/CLI/Index.rst rename docs/source/UserGuides/{DataServices => ContentServices}/DirectIO.rst (98%) create mode 100644 docs/source/UserGuides/ContentServices/Index.rst rename docs/source/UserGuides/{DataServices => ContentServices}/NotificationService.rst (100%) delete mode 100644 docs/source/UserGuides/DataServices/Index.rst create mode 100644 docs/source/api/cterasdk.objects.asynchronous.core.rst create mode 100644 docs/source/api/cterasdk.objects.asynchronous.drive.rst create mode 100644 docs/source/api/cterasdk.objects.asynchronous.edge.rst create mode 100644 docs/source/api/cterasdk.objects.asynchronous.invitation.rst create mode 100644 docs/source/api/cterasdk.objects.asynchronous.rst create mode 100644 docs/source/api/cterasdk.objects.synchronous.invitation.rst create mode 100644 docs/source/api/cterasdk.objects.synchronous.rst diff --git a/cterasdk/asynchronous/core/files/__init__.py b/cterasdk/asynchronous/core/files/__init__.py index 5550da3b..2cc40502 100644 --- a/cterasdk/asynchronous/core/files/__init__.py +++ b/cterasdk/asynchronous/core/files/__init__.py @@ -1 +1 @@ -from .browser import CloudDrive # noqa: E402, F401 +from .browser import CloudDrive, InvitationBrowser # noqa: E402, F401 diff --git a/cterasdk/asynchronous/core/files/browser.py b/cterasdk/asynchronous/core/files/browser.py index 6f09208c..dfb1a1f3 100644 --- a/cterasdk/asynchronous/core/files/browser.py +++ b/cterasdk/asynchronous/core/files/browser.py @@ -1,9 +1,10 @@ from .. import query from ....cio.core.commands import Open, OpenMany, Upload, Download, EnsureDirectory, \ DownloadMany, UnShare, CreateDirectory, GetMetadata, GetProperties, ListVersions, RecursiveIterator, \ - Delete, Recover, Rename, GetShareMetadata, Link, Copy, Move, ResourceIterator, GetPermalink -from ..base_command import BaseCommand + Delete, Recover, Rename, GetShareMetadata, Link, Copy, Move, ResourceIterator, GetPermalink, GetExternalShareInfo +from ....cio.core.types import InvitationPath from ....lib.storage import commonfs +from ..base_command import BaseCommand from . import io @@ -355,3 +356,44 @@ async def unshare(self, path): :param str path: Path of file/folder. """ return await UnShare(io.update_share, self._core, path).a_execute() + + +class InvitationBrowser: + + def __init__(self, core): + self._invitation = InvitationPath.from_context(core.invite) + self._core = core + self._file_browser = CloudDrive(core) + + def listdir(self, path=None): + return self._file_browser.listdir(self._invitation.join(path)) + + def walk(self, path=None): + return self._file_browser.walk(self._invitation.join(path)) + + async def properties(self, path): + return await self._file_browser.properties(self._invitation.join(path)) + + async def exists(self, path): + return await self._file_browser.exists(self._invitation.join(path)) + + async def mkdir(self, path): + return await self._file_browser.mkdir(self._invitation.join(path)) + + async def makedirs(self, path): + return await self._file_browser.makedirs(self._invitation.join(path)) + + async def download(self, path, destination=None): + return await self._file_browser.download(self._invitation.join(path), destination) + + async def download_many(self, directory, objects, destination=None): + return await self._file_browser.download_many(self._invitation.join(directory), objects, destination) + + async def upload(self, destination, handle, name=None, size=None): + return await self._file_browser.upload(self._invitation.join(destination), handle, name, size) + + async def upload_file(self, path, destination): + return await self._file_browser.upload_file(path, self._invitation.join(destination)) + + async def details(self): + return await GetExternalShareInfo(io.get_share_details, self._core, self._core.invite).a_execute() \ No newline at end of file diff --git a/cterasdk/asynchronous/core/files/io.py b/cterasdk/asynchronous/core/files/io.py index adead1a5..cdde10a7 100644 --- a/cterasdk/asynchronous/core/files/io.py +++ b/cterasdk/asynchronous/core/files/io.py @@ -73,3 +73,7 @@ async def add_share_recipients(core, path, members): async def public_link(core, param): return await core.v1.api.execute('', 'createShare', param) + + +async def get_share_details(core, param): + return await core.v1.api.execute('', 'getShareDetails', param) diff --git a/cterasdk/asynchronous/core/invitation/__init__.py b/cterasdk/asynchronous/core/invitation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cterasdk/asynchronous/core/invitation/login.py b/cterasdk/asynchronous/core/invitation/login.py new file mode 100644 index 00000000..f2de49bc --- /dev/null +++ b/cterasdk/asynchronous/core/invitation/login.py @@ -0,0 +1,18 @@ +import logging +from ..base_command import BaseCommand + + +logger = logging.getLogger('cterasdk.core') + + +class Login(BaseCommand): + """ + Portal Login APIs + """ + + async def login(self, key, value): #pylint: disable=unused-argument + logger.info('Creating external session. %s', {'invite': value}) + await self._core.clients.v1.ctera.get('', params={key: value}) + + async def logout(self): + """No logout for external users""" diff --git a/cterasdk/cio/common.py b/cterasdk/cio/common.py index 07c59a7d..ca7e3234 100644 --- a/cterasdk/cio/common.py +++ b/cterasdk/cio/common.py @@ -60,7 +60,7 @@ def name(self): @property def parent(self): - return self.__class__(self._reference.parent.as_posix()) # pylint: disable=no-value-for-parameter + return self.__class__(self._scope, self._reference.parent.as_posix()) # pylint: disable=no-value-for-parameter @property def absolute(self): @@ -80,7 +80,7 @@ def is_relative_to(self, p): @resolver def relative_to(self, p): - return self.__class__(self.reference.relative_to(p).as_posix()) # pylint: disable=no-value-for-parameter + return self.__class__(self._scope, self.reference.relative_to(p).as_posix()) # pylint: disable=no-value-for-parameter @property def extension(self): @@ -92,7 +92,9 @@ def join(self, p): :param str p: Path. """ - return self.__class__(self.reference.joinpath(p).as_posix()) # pylint: disable=no-value-for-parameter + if p is not None: + return self.__class__(self._scope, self.reference.joinpath(p).as_posix()) # pylint: disable=no-value-for-parameter + return self @property def parts(self): @@ -100,7 +102,7 @@ def parts(self): def __getitem__(self, key): if isinstance(key, slice): - return self.__class__(self.parts[key]) # pylint: disable=no-value-for-parameter + return self.__class__(self._scope, self.parts[key]) # pylint: disable=no-value-for-parameter if isinstance(key, int): return self.parts[key] raise TypeError("Invalid argument type") diff --git a/cterasdk/cio/core/commands.py b/cterasdk/cio/core/commands.py index 52ffaf68..7a809af0 100644 --- a/cterasdk/cio/core/commands.py +++ b/cterasdk/cio/core/commands.py @@ -8,7 +8,7 @@ from ...common import Object, DateTimeUtils from ...core.enum import ProtectionLevel, CollaboratorType, SearchType, PortalAccountType, FileAccessMode, \ UploadError, ResourceScope, ResourceError, Context, Administrators -from ...core.types import PortalAccount, UserAccount, GroupAccount, Collaborator +from ...core.types import PortalAccount, UserAccount, GroupAccount, Collaborator, PortalInvitation from ... import exceptions from ...lib.storage import synfs, asynfs, commonfs from .types import SrcDstParam, CreateShareParam, ActionResourcesParam, FetchResourcesError, \ @@ -47,15 +47,16 @@ def ensure_user_access(user_session, path): The target path to validate. """ relative = path.relative - if user_session.account.role.name in Administrators: - if user_session.context == Context.admin: - if not administrator_namespace(path): - return False, exceptions.io.core.ContextError(relative) - if not user_session.account.role.access_end_user_folders and is_password_protected(path): - return False, exceptions.io.core.PrivilegeError(relative) - elif user_session.context == Context.ServicesPortal: - if not user_session.account.role.access_end_user_folders and is_password_protected(path): - return False, exceptions.io.core.PrivilegeError(relative) + if not user_session.context == Context.Invitations: + if user_session.account.role.name in Administrators: + if user_session.context == Context.admin: + if not administrator_namespace(path): + return False, exceptions.io.core.ContextError(relative) + if not user_session.account.role.access_end_user_folders and is_password_protected(path): + return False, exceptions.io.core.PrivilegeError(relative) + elif user_session.context == Context.ServicesPortal: + if not user_session.account.role.access_end_user_folders and is_password_protected(path): + return False, exceptions.io.core.PrivilegeError(relative) return True, None @@ -412,7 +413,7 @@ class OpenMany(PortalCommand): def __init__(self, function, receiver, resource, directory, *objects): super().__init__(function, receiver) - self.uid = str(resource.cloudFolderInfo.uid) + self.uid = str(resource.cloudFolderInfo.uid) if receiver.context != Context.Invitations else f'share/{receiver.invite}' self.directory = automatic_resolution(directory, receiver.context) self.objects = objects @@ -589,7 +590,6 @@ def _generator(self): def generate(self): for path in self._generator(): try: - print('Enumerating: ', path or '.') for o in ResourceIterator(self._function, self._receiver, path, None, self.include_deleted, None, None).execute(): yield self._process_object(o) except (exceptions.io.core.ListDirectoryError, exceptions.io.core.PrivilegeError) as e: @@ -605,7 +605,7 @@ async def a_generate(self): def _process_object(self, o): if o.is_dir: - self.tree.append(o.path.relative) + self.tree.append(o.path) return o @staticmethod @@ -668,7 +668,7 @@ def _parents_generator(self): if self.parents: parts = self.path.parts for i in range(1, len(parts)): - yield automatic_resolution('/'.join(parts[:i]), self._receiver.context) + yield self.path[:i] else: yield self.path @@ -696,6 +696,7 @@ async def _a_execute(self): def _suppress_file_conflict_error(e): if not isinstance(e.__cause__, exceptions.io.core.FileConflictError): raise e + def _handle_response(self, r): path = self.path.relative @@ -712,7 +713,7 @@ def _handle_response(self, r): if r == ResourceError.InvalidName: cause = exceptions.io.core.FilenameError(path) if r == ResourceError.PermissionDenied: - cause = exceptions.io.core.ReservedNameError(path) + cause = exceptions.io.core.PrivilegeError(path) raise error from cause @@ -745,6 +746,36 @@ def _handle_exception(self, e): raise exceptions.io.core.GetShareMetadataError(path) from e +class GetExternalShareInfo(PortalCommand): + + def __init__(self, function, receiver, invite): + super().__init__(function, receiver) + self.invite = invite + + def _before_command(self): + logger.info('Querying external share details: %s', self.invite) + + def get_parameter(self): + return self.invite + + def _execute(self): + with self.trace_execution(): + return self._function(self._receiver, self.get_parameter()) + + async def _a_execute(self): + with self.trace_execution(): + return await self._function(self._receiver, self.get_parameter()) + + def _handle_response(self, r): + return PortalInvitation.from_server_object(r) + + def _handle_exception(self, e): + error = exceptions.io.core.ExternalShareError(self._receiver.uri) + if 'no longer valid' in e.error.response.error.msg: + raise error from exceptions.io.core.InvalidShareError(self.invite) + raise error + + class Link(PortalCommand): def __init__(self, function, receiver, path, access, expire_in): diff --git a/cterasdk/cio/core/types.py b/cterasdk/cio/core/types.py index 98b3a1bd..261fbaf7 100644 --- a/cterasdk/cio/core/types.py +++ b/cterasdk/cio/core/types.py @@ -3,6 +3,7 @@ from datetime import datetime from ...common import Object from ..common import BasePath, BaseResource +from ...core.enum import Context from ...lib.iterator import DefaultResponse @@ -71,11 +72,12 @@ def _parse_from_str(path): :param str path: Path """ - groups = [f'(?P<{o.__name__}>{namespace})' for namespace, o in Namespaces.items()] + groups = [f'(?P<{c.__name__}>{c.expr})' for c in [ServicesPortalPath, GlobalAdminPath, InvitationPath]] regex = re.compile(f"^{'|'.join(groups)}") match = re.match(regex, path) if match: - return Namespaces[match.group()](path[match.end():]) + scope, reference = path[:match.end()], path[match.end():] + return resolve_namespace_from_href(scope)(scope, reference) raise ValueError(f'Could not determine object path: {path}') @@ -84,9 +86,11 @@ class ServicesPortalPath(PortalPath): ServicesPortal Path Object """ Namespace = '/ServicesPortal/webdav' + expr = Namespace - def __init__(self, reference): - super().__init__(ServicesPortalPath.Namespace, reference) + @staticmethod + def from_context(reference): + return ServicesPortalPath(ServicesPortalPath.Namespace, reference) class GlobalAdminPath(PortalPath): @@ -94,15 +98,43 @@ class GlobalAdminPath(PortalPath): Global Admin Path Object """ Namespace = '/admin/webdav' + expr = Namespace + + @staticmethod + def from_context(reference): + return ServicesPortalPath(GlobalAdminPath.Namespace, reference) + + +class InvitationPath(PortalPath): + """ + Invitation Path Object + """ + Namespace = '/invitations/webdav' + expr = f'{Namespace}/share\/([a-zA-Z0-9]+)' + + @staticmethod + def from_context(reference): + return PortalPath.from_str(f'{InvitationPath.Namespace}/share/{reference}') + - def __init__(self, reference): - super().__init__(GlobalAdminPath.Namespace, reference) +def resolve_namespace_from_context(ctx): + if ctx == Context.admin: + return GlobalAdminPath + if ctx == Context.ServicesPortal: + return ServicesPortalPath + if ctx == Context.Invitations: + return InvitationPath + return None -Namespaces = { - ServicesPortalPath.Namespace: ServicesPortalPath, - GlobalAdminPath.Namespace: GlobalAdminPath -} +def resolve_namespace_from_href(href): + if href.startswith(GlobalAdminPath.Namespace): + return GlobalAdminPath + if href.startswith(ServicesPortalPath.Namespace): + return ServicesPortalPath + if href.startswith(InvitationPath.Namespace): + return InvitationPath + raise ValueError(f'Could not find namespace associated with href: {href}') def resolve(path, namespace=None): @@ -111,6 +143,7 @@ def resolve(path, namespace=None): :param object path: Path :param namespace: :class:`cterasdk.cio.core.types.ServicesPortalPath` or :class:`cterasdk.cio.core.types.GlobalAdminPath` (optional) + or :class:`cterasdk.cio.core.types.InvitationPath` (optional) """ if isinstance(path, PortalPath): return path @@ -123,7 +156,7 @@ def resolve(path, namespace=None): if namespace: if path is None or isinstance(path, str): - return namespace(path or '') + return namespace.from_context(path or '') raise ValueError(f'Error: Could not resolve path: {path}. Type: {type(path)}') @@ -144,14 +177,14 @@ def wrapper(): return wrapper() -def automatic_resolution(p, context=None): +def automatic_resolution(p, ctx=None): """ Automatic Resolution of Path Object :param object p: Path - :param str,optional context: Context (e.g. 'ServicesPortal' or 'admin') + :param str,optional ctx: Context (e.g. 'ServicesPortal', 'admin', or 'invitations') """ - namespace = Namespaces.get(f'/{context}/webdav', None) + namespace = resolve_namespace_from_context(ctx) if isinstance(p, (list, tuple)): return create_generator(p, namespace) diff --git a/cterasdk/cli/__init__.py b/cterasdk/cli/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cterasdk/cli/dav.py b/cterasdk/cli/dav.py new file mode 100644 index 00000000..2b06dbfa --- /dev/null +++ b/cterasdk/cli/dav.py @@ -0,0 +1,162 @@ +import os +import sys +import asyncio +import argparse +from pathlib import Path +from ..core.enum import FileAccessMode +from ..lib.storage import asynfs, commonfs +from ..objects.asynchronous.invitation import AsyncInvitation +from ..exceptions.io.core import ListDirectoryError, GetMetadataError + + +async def handle_ls(args): + + attributes = [ + ("ID", 12), + ("VOLUME", 7), + ("SIZE", 10), + ("MODIFIED", 15), + ("DELETED", 8), + ("DIR", 4), + ("PATH", 50) + ] + + async with AsyncInvitation.from_uri(args.endpoint) as invitation: + func = invitation.files.walk if args.recursive else invitation.files.listdir + + rows = [ + ( + o.id, + o.volume.id, + o.size, + o.last_modified.strftime("%b %#d, %H:%M"), + 'true' if o.deleted else 'false', + 'd' if o.is_dir else 'f', + o.path.relative, + ) + async for o in func() + ] + + if rows: + formatter = " ".join(f"{{:<{width}}}" for _, width in attributes) + print(formatter.format(*(name for name, _ in attributes))) + for row in rows: + print(formatter.format(*row)) + + +async def handle_download(args): + target = Path(args.dest or os.getcwd()) + + try: + + Path.mkdir(target, exist_ok=True) + + async def download(invitation, resource, objects=None, archive=False, destination=None): + if objects is not None or archive: + return await invitation.files.download_many(resource.path.relative, [o.name for o in objects], f'{destination}.zip') + return await invitation.files.download(resource.path.relative, destination.joinpath(resource.path.relative)) + + async with AsyncInvitation.from_uri(args.endpoint) as invitation: + jobs = [] + + if not invitation.details.is_dir: + if args.src: + print(f"error: object not found error: '{args.src}'", file=sys.stderr) + sys.exit(1) + resource = await anext(invitation.files.listdir()) + jobs.append(download(invitation, resource, destination=target)) + else: + try: + resource = await invitation.files.properties(args.src) + if not resource.is_dir: + jobs.append(download(invitation, resource, destination=target)) + else: + enumerator = invitation.files.walk if args.recursive else invitation.files.listdir + objects = [r async for r in enumerator(args.src)] + if args.archive: + jobs.append(download(invitation, resource, objects, True, target.joinpath(args.src or invitation.invite))) + else: + for r in objects: + if r.is_dir: + await asynfs.mkdir(target.joinpath(r.path.relative), parents=True, exist_ok=True) + else: + jobs.append(download(invitation, r, destination=target)) + except (GetMetadataError, ListDirectoryError) as e: + print(f"error: failed to obtain properties or list a directory: '{args.src}'", file=sys.stderr) + sys.exit(1) + + await asyncio.gather(*jobs) + + except PermissionError: + print(f"error: permission denied: Cannot create files and folders at '{target}'.", file=sys.stderr) + except NotADirectoryError: + print(f"error: path conflict: '{target}' is a file, but a folder was expected.", file=sys.strerr) + except OSError as e: + print(f"error: an unexpected error occurred during download. {e}", file=sys.strerr) + + +async def handle_upload(args): + + for p in args.files: + if not commonfs.exists(p): + print(f"error: path not found: '{p}'", file=sys.stderr) + sys.exit(1) + + async with AsyncInvitation.from_uri(args.endpoint) as invitation: + if not invitation.details.is_dir: + print(f"error: destination is not a directory", file=sys.stderr) + sys.exit(1) + elif invitation.details.access not in [FileAccessMode.RW, FileAccessMode.UO]: + print(f"error: insufficient permissions to write to: {args.dest}", file=sys.stderr) + sys.exit(1) + + try: + destination = args.dest or '.' + if args.dest: + properties = await invitation.files.properties(args.dest) + if not properties.is_dir: + print(f"error: destination is not a directory: '{args.dest}'", file=sys.stderr) + sys.exit(1) + destination = properties.path.relative + + jobs = [] + for p in args.files: + jobs.append(invitation.files.upload_file(p, destination)) + + await asyncio.gather(*jobs) + + except GetMetadataError: + print(f"error: failed to obtain properties for directory: '{args.dest}'", file=sys.stderr) + sys.exit(1) + + +async def main(): + parser = argparse.ArgumentParser(prog="cterasdk.io.dav") + subparsers = parser.add_subparsers(dest="command", required=True) + + ls = subparsers.add_parser("ls", help="list directory contents") + ls.add_argument("-e", "--endpoint", type=str, required=True, help="CTERA Portal endpoint, or external share") + ls.add_argument("-s", "--src", type=str, required=False, help="path to file or folder") + ls.add_argument("-R", "--recursive", action="store_true", help="list subdirectories recursively") + ls.set_defaults(func=handle_ls) + + download = subparsers.add_parser("download", help="download files or folders") + download.add_argument("-e", "--endpoint", type=str, required=True, help="CTERA Portal endpoint, or external share") + download.add_argument("-s", "--src", type=str, required=False, help="path to file or folder") + download.add_argument("-R", "--recursive", action="store_true", help="download sub-directories and files") + download.add_argument("-d", "--dest", type=str, help="destination path on local file system") + download.add_argument("-z", "--archive", action="store_true", help="download as zip archive (applicable to directories)") + download.set_defaults(func=handle_download) + + upload = subparsers.add_parser("upload", help="upload files") + upload.add_argument("files", type=str, nargs='+', help="one or more local files") + upload.add_argument("-e", "--endpoint", type=str, required=True, help="CTERA Portal endpoint, or external share") + upload.add_argument("-d", "--dest", type=str, required=False, help="destination folder") + upload.set_defaults(func=handle_upload) + + args = parser.parse_args() + await args.func(args) + + +def webdav(): + asyncio.run(main()) \ No newline at end of file diff --git a/cterasdk/direct/cli.py b/cterasdk/cli/direct.py similarity index 95% rename from cterasdk/direct/cli.py rename to cterasdk/cli/direct.py index 9c424523..c80c41f4 100644 --- a/cterasdk/direct/cli.py +++ b/cterasdk/cli/direct.py @@ -9,7 +9,7 @@ from .. import settings from ..common import utils from ..lib.storage import commonfs -from .client import DirectIO +from ..direct.client import DirectIO from ..exceptions.direct import StreamError, DirectIOError from ..exceptions.transport import TLSError @@ -22,6 +22,7 @@ def validate_endpoint(endpoint): baseurl, port = yarl.URL(endpoint), 443 + assert baseurl.scheme in ('http', 'https'), 'Error: Endpoint scheme must be http or https.' logger.debug('Validating connection to host: %s on port: %s', baseurl.host, port) utils.tcp_connect(baseurl.host, port, timeout=5) return f'{baseurl}' diff --git a/cterasdk/core/enum.py b/cterasdk/core/enum.py index 45041b65..ed6809bd 100644 --- a/cterasdk/core/enum.py +++ b/cterasdk/core/enum.py @@ -4,9 +4,11 @@ class Context: :ivar str admin: Global admin context :ivar str ServicesPortal: Services Portal context + :ivar str Invitations: Invitations context """ admin = 'admin' ServicesPortal = 'ServicesPortal' + Invitations = 'invitations' class LogTopic: diff --git a/cterasdk/core/files/__init__.py b/cterasdk/core/files/__init__.py index 72af5ab3..f9d8a7a4 100644 --- a/cterasdk/core/files/__init__.py +++ b/cterasdk/core/files/__init__.py @@ -1 +1 @@ -from .browser import CloudDrive, Backups # noqa: E402, F401 +from .browser import CloudDrive, Backups, InvitationBrowser # noqa: E402, F401 diff --git a/cterasdk/core/files/browser.py b/cterasdk/core/files/browser.py index 2202ecc5..c02da87e 100644 --- a/cterasdk/core/files/browser.py +++ b/cterasdk/core/files/browser.py @@ -1,7 +1,8 @@ from .. import query from ...cio.core.commands import Open, OpenMany, Upload, Download, EnsureDirectory, \ DownloadMany, UnShare, CreateDirectory, GetMetadata, GetProperties, ListVersions, RecursiveIterator, \ - Delete, Recover, Rename, GetShareMetadata, Link, Copy, Move, ResourceIterator, GetPermalink + Delete, Recover, Rename, GetShareMetadata, Link, Copy, Move, ResourceIterator, GetPermalink, GetExternalShareInfo +from ...cio.core.types import InvitationPath from ...lib.storage import commonfs from ..base_command import BaseCommand from . import io @@ -90,8 +91,7 @@ def properties(self, path): :rtype: cterasdk.cio.core.types.PortalResource :raises cterasdk.exceptions.io.core.GetMetadataError: Raised on error retrieving object metadata. """ - _, metadata = GetProperties(io.listdir, self._core, path, False).execute() - return metadata + return GetProperties(io.listdir, self._core, path, False).execute() def exists(self, path): """ @@ -353,3 +353,44 @@ def device_config(self, device, destination=None): """ destination = destination if destination is not None else f'{commonfs.downloads()}/{device}.xml' return Download(io.handle, self._core, f'backups/{device}/Device Configuration/db.xml', destination).execute() + + +class InvitationBrowser: + + def __init__(self, core): + self._invitation = InvitationPath.from_context(core.invite) + self._core = core + self._file_browser = CloudDrive(core) + + def listdir(self, path=None): + return self._file_browser.listdir(self._invitation.join(path)) + + def walk(self, path=None): + return self._file_browser.walk(self._invitation.join(path)) + + def properties(self, path): + return self._file_browser.properties(self._invitation.join(path)) + + def exists(self, path): + return self._file_browser.exists(self._invitation.join(path)) + + def mkdir(self, path): + return self._file_browser.mkdir(self._invitation.join(path)) + + def makedirs(self, path): + return self._file_browser.makedirs(self._invitation.join(path)) + + def download(self, path, destination=None): + return self._file_browser.download(self._invitation.join(path), destination) + + def download_many(self, directory, objects, destination=None): + return self._file_browser.download_many(self._invitation.join(directory), objects, destination) + + def upload(self, destination, handle, name=None, size=None): + return self._file_browser.upload(self._invitation.join(destination), handle, name, size) + + def upload_file(self, path, destination): + return self._file_browser.upload_file(path, self._invitation.join(destination)) + + def details(self): + return GetExternalShareInfo(io.get_share_details, self._core, self._core.invite).execute() diff --git a/cterasdk/core/files/io.py b/cterasdk/core/files/io.py index dd222faf..48af06fd 100644 --- a/cterasdk/core/files/io.py +++ b/cterasdk/core/files/io.py @@ -73,3 +73,7 @@ def add_share_recipients(core, path, members): def public_link(core, param): return core.api.execute('', 'createShare', param) + + +def get_share_details(core, param): + return core.api.execute('', 'getShareDetails', param) diff --git a/cterasdk/core/invitation/__init__.py b/cterasdk/core/invitation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/cterasdk/core/invitation/login.py b/cterasdk/core/invitation/login.py new file mode 100644 index 00000000..c0011bb8 --- /dev/null +++ b/cterasdk/core/invitation/login.py @@ -0,0 +1,19 @@ +import logging +from ..base_command import BaseCommand +from ...exceptions.transport import InternalServerError + + +logger = logging.getLogger('cterasdk.core') + + +class Login(BaseCommand): + """ + Portal Login APIs + """ + + def login(self, key, value): #pylint: disable=unused-argument + logger.info('Creating external session. %s', {'invite': value}) + self._core.clients.ctera.get('', params={key: value}) + + def logout(self): + """No logout for external users""" diff --git a/cterasdk/core/types.py b/cterasdk/core/types.py index e6b740d2..aae4d097 100644 --- a/cterasdk/core/types.py +++ b/cterasdk/core/types.py @@ -889,3 +889,14 @@ def overwrite(apply_all=True): @staticmethod def rename(apply_all=True): return ConflictResolver(ConflictHandler.Rename, apply_all) + + +class PortalInvitation(Object): + + def __init__(self, access, is_dir): + self.access = access + self.is_dir = is_dir + + @staticmethod + def from_server_object(server_object): + return PortalInvitation(server_object.mode, server_object.isDirectory) diff --git a/cterasdk/exceptions/io/core.py b/cterasdk/exceptions/io/core.py index a80df8e1..bc63ac6c 100644 --- a/cterasdk/exceptions/io/core.py +++ b/cterasdk/exceptions/io/core.py @@ -128,6 +128,18 @@ def __init__(self, filename): super().__init__(EREMOTEIO, 'Failed to retrieve collaboration-share metadata', filename) +class ExternalShareError(PathError): + + def __init__(self, filename): + super().__init__(EREMOTEIO, 'Failed to retrieve external share details', filename) + + +class InvalidShareError(PathError): + + def __init__(self, filename): + super().__init__(EREMOTEIO, 'This external share is invalid or has expired.', filename) + + class CreateLinkError(PathError): def __init__(self, filename): diff --git a/cterasdk/lib/session/base.py b/cterasdk/lib/session/base.py index 3cbfe1c5..c353ba51 100644 --- a/cterasdk/lib/session/base.py +++ b/cterasdk/lib/session/base.py @@ -6,11 +6,29 @@ from ...exceptions import CTERAException +class AccountType: + """ + Account Type + + :ivar str Internal: Internal + :ivar str External: External + """ + Internal = 'internal' + External = 'external' + + class BaseUser(Object): """Base User Account""" + def __init__(self, type_of): + self.type = type_of + + +class InternalUser(BaseUser): + """Base User Account""" + def __init__(self, name, domain=None): - super().__init__() + super().__init__(AccountType.Internal) self.name = name self.domain = domain diff --git a/cterasdk/lib/session/core.py b/cterasdk/lib/session/core.py index efbb73c4..ab37bcc4 100644 --- a/cterasdk/lib/session/core.py +++ b/cterasdk/lib/session/core.py @@ -1,11 +1,18 @@ import logging -from .base import BaseSession, BaseUser +from .base import BaseSession, BaseUser, AccountType, InternalUser from .types import Product from ...common import Object -from ...core.enum import Administrators +from ...core.enum import Administrators, Context -class PortalUser(BaseUser): +class ExternalUser(BaseUser): + """External User""" + + def __init__(self): + super().__init__(AccountType.External) + + +class PortalUser(InternalUser): """Local User""" def __init__(self, name, domain, tenant, role, authorizations): @@ -33,32 +40,34 @@ def __init__(self, address, context): self.context = context def _start_session(self, session): - logging.getLogger('cterasdk.core').debug('Starting Session.') - user_session = session.api.get('/currentSession') - current_tenant = session.api.get('/currentPortal') or Session.Administration - software_version = session.api.get('/version') - authorizations = session.roles.get(user_session.role) if user_session.role in Administrators else None - self._update_session(user_session, current_tenant, software_version, authorizations) + account = None + if session.context != Context.Invitations: + logging.getLogger('cterasdk.core').debug('Starting Session.') + user_session = session.api.get('/currentSession') + current_tenant = session.api.get('/currentPortal') or Session.Administration + software_version = session.api.get('/version') + authorizations = session.roles.get(user_session.role) if user_session.role in Administrators else None + account = PortalUser(user_session.username, user_session.domain, current_tenant or Session.Administration, + user_session.role, authorizations) + self._update_software_version(software_version) + else: + account = ExternalUser() + self._update_account(account) async def _async_start_session(self, session): logging.getLogger('cterasdk.core').debug('Starting Session.') - user_session = await session.v1.api.get('/currentSession') - current_tenant = session.v1.api.get('/currentPortal') - software_version = session.v1.api.get('/version') - authorizations = await session.roles.get(user_session.role) if user_session.role in Administrators else None - self._update_session(user_session, await current_tenant or Session.Administration, await software_version, authorizations) - - def _update_session(self, user_session, current_tenant, software_version, authorizations): - self._update_account( - PortalUser( - user_session.username, - user_session.domain, - current_tenant, - user_session.role, - authorizations - ) - ) - self._update_software_version(software_version) + account = None + if session.context != Context.Invitations: + user_session = await session.v1.api.get('/currentSession') + current_tenant = session.v1.api.get('/currentPortal') + software_version = session.v1.api.get('/version') + authorizations = await session.roles.get(user_session.role) if user_session.role in Administrators else None + account = PortalUser(user_session.username, user_session.domain, await current_tenant or Session.Administration, + user_session.role, authorizations) + self._update_software_version(await software_version) + else: + account = ExternalUser() + self._update_account(account) def _stop_session(self): # pylint: disable=no-self-use logging.getLogger('cterasdk.core').debug('Stopping Session.') diff --git a/cterasdk/lib/session/edge.py b/cterasdk/lib/session/edge.py index 4e351c92..8b9f0a87 100644 --- a/cterasdk/lib/session/edge.py +++ b/cterasdk/lib/session/edge.py @@ -1,5 +1,5 @@ import logging -from .base import BaseSession, BaseUser +from .base import BaseSession, InternalUser from .types import Product from ...common import Object @@ -15,11 +15,11 @@ def __init__(self, remote, source=None): self.source = source -class LocalUser(BaseUser): +class LocalUser(InternalUser): """Local User""" -class RemoteUser(BaseUser): +class RemoteUser(InternalUser): """Remote User""" def __init__(self, name, domain, tenant): diff --git a/cterasdk/lib/storage/asynfs.py b/cterasdk/lib/storage/asynfs.py index 48a4ad0e..536ac264 100644 --- a/cterasdk/lib/storage/asynfs.py +++ b/cterasdk/lib/storage/asynfs.py @@ -1,6 +1,7 @@ import logging import aiofiles -from .commonfs import write_new_version, ResultContext +import aiofiles.os +from .commonfs import write_new_version, ResultContext, expanduser logger = logging.getLogger('cterasdk.filesystem') @@ -36,3 +37,24 @@ async def overwrite(p, handle): async for chunk in handle.a_iter_content(chunk_size=8192): await fd.write(chunk) return p.as_posix() + + +async def mkdir(p, parents=False, exist_ok=True): + """ + Create a directory using aiofiles. + + :param str p: The path to create (string or Path object). + :param bool,optional parents: Create a path, including all parent directories + :param bool,optional exist_ok: Suppress error if directory exists + :return: Path + :rtype: str + :raises FileExistsError: If ``exist_ok`` is ``False`` and directory exists + """ + location = expanduser(p) + + if parents: + await aiofiles.os.makedirs(location, exist_ok=exist_ok) + else: + await aiofiles.os.mkdir(location, exist_ok=exist_ok) + + return location.as_posix() diff --git a/cterasdk/lib/storage/synfs.py b/cterasdk/lib/storage/synfs.py index 38814755..9f9c1f8a 100644 --- a/cterasdk/lib/storage/synfs.py +++ b/cterasdk/lib/storage/synfs.py @@ -1,5 +1,6 @@ import logging -from .commonfs import write_new_version, ResultContext +from pathlib import Path +from .commonfs import write_new_version, ResultContext, expanduser logger = logging.getLogger('cterasdk.filesystem') @@ -36,3 +37,19 @@ def overwrite(p, handle): fd.write(chunk) logger.debug('Wrote: %s', p.as_posix()) return p.as_posix() + + +def mkdir(p, parents=False, exist_ok=True): + """ + Create a directory. + + :param str p: The path to create (string or Path object). + :param bool,optional parents: Create a path, including all parent directories + :param bool,optional exist_ok: Suppress error if directory exists + :return: Path + :rtype: str + :raises FileExistsError: If ``exist_ok`` is ``False`` and directory exists + """ + location = expanduser(p) + location.mkdir(parents=parents, exist_ok=exist_ok) + return location.as_posix() diff --git a/cterasdk/objects/asynchronous/invitation.py b/cterasdk/objects/asynchronous/invitation.py new file mode 100644 index 00000000..49279ccc --- /dev/null +++ b/cterasdk/objects/asynchronous/invitation.py @@ -0,0 +1,50 @@ +from .. import invitation +from ...clients import clients +from ..endpoints import EndpointBuilder +from .core import AsyncPortal +from ..asynchronous.core import files +from ...asynchronous.core.invitation import login + + +class AsyncInvitation(AsyncPortal): + + async def __aenter__(self): + await self.login() + return self + + def __init__(self, host, port, invite): + super().__init__(host, port) + self.invite = invite + self.clients.v1.api = self.default.clone(clients.AsyncAPI, EndpointBuilder.new(self.base, self.context, f'/portalInvitation/share/{invite}')) + self.clients.io._webdav = self.default.clone(clients.AsyncWebDAV, EndpointBuilder.new(self.base, self.context, f'/webdav/share/{invite}')) + self.clients.io._upload = self.default.clone(clients.AsyncUpload, EndpointBuilder.new(self.base, self.context, f'/upload/share/{invite}')) + self.files = files.InvitationBrowser(self) + self.details = None + + @property + def context(self): + return 'invitations' + + def _authenticator(self, url): #pylint: disable=unused-argument + return True + + async def login(self): + await super().login('share', self.invite) + self.details = await self.files.details() + + @property + def uri(self): + return invitation.uri(self) + + @property + def _login_object(self): + return login.Login(self) + + @staticmethod + def from_uri(uri): + host, port, invite = invitation.validate(uri) + return AsyncInvitation(host, port, invite) + + async def __aexit__(self, exc_type, exc, tb): + await self.logout() + return await super().__aexit__(exc_type, exc, tb) \ No newline at end of file diff --git a/cterasdk/objects/invitation.py b/cterasdk/objects/invitation.py new file mode 100644 index 00000000..b7e7eb0b --- /dev/null +++ b/cterasdk/objects/invitation.py @@ -0,0 +1,25 @@ +from .uri import components, parse_qsl + + +def validate(uri): + r = components(uri) + + assert r.scheme == "https", f"Error: Expected 'https' scheme, got '{r.scheme}'." + assert r.netloc, "Error: Could not identify network location." + assert r.path == "/invitations/", f"Error: Expected '/invitations/' path, got '{r.path}'." + + parameters = parse_qsl(r.query) + + if parameters and parameters[0][0] == "share": + invite = parameters[0][1] + + assert invite, "Error: Invitation identifier not found." + assert invite.isalnum(), f"Invitation identifier '{invite}' must be alphanumeric." + + return r.hostname, r.port, invite + + raise ValueError("Error: Could not find invitation identifer.") + + +def uri(invitation): + return f'{invitation.clients.ctera.baseurl}/?share={invitation.invite}' \ No newline at end of file diff --git a/cterasdk/objects/services.py b/cterasdk/objects/services.py index 12db6dce..fe5e1571 100644 --- a/cterasdk/objects/services.py +++ b/cterasdk/objects/services.py @@ -1,5 +1,4 @@ from abc import abstractmethod - from . import endpoints, uri from .utils import URI from ..clients import clients diff --git a/cterasdk/objects/synchronous/invitation.py b/cterasdk/objects/synchronous/invitation.py new file mode 100644 index 00000000..dbb3a28f --- /dev/null +++ b/cterasdk/objects/synchronous/invitation.py @@ -0,0 +1,50 @@ +from .. import invitation +from ...clients import clients +from ..endpoints import EndpointBuilder +from .core import Portal +from ...core import files +from ...core.invitation import login + + +class Invitation(Portal): + + def __enter__(self): + self.login() + return self + + def __init__(self, host, port, invite): + super().__init__(host, port) + self.invite = invite + self.clients.api = self.default.clone(clients.API, EndpointBuilder.new(self.base, self.context, f'/portalInvitation/share/{invite}')) + self.clients.io._webdav = self.default.clone(clients.WebDAV, EndpointBuilder.new(self.base, self.context, f'/webdav/share/{invite}')) + self.clients.io._upload = self.default.clone(clients.Upload, EndpointBuilder.new(self.base, self.context, f'/upload/share/{invite}')) + self.files = files.InvitationBrowser(self) + self.details = None + + @property + def context(self): + return 'invitations' + + def _authenticator(self, url): #pylint: disable=unused-argument + return True + + def login(self): + super().login('share', self.invite) + self.details = self.files.details() + + @property + def uri(self): + return invitation.uri(self) + + @property + def _login_object(self): + return login.Login(self) + + @staticmethod + def from_uri(uri): + host, port, invite = invitation.validate(uri) + return Invitation(host, port, invite) + + def __exit__(self, exc_type, exc_value, exc_tb): + self.logout() + return super().__exit__(exc_type, exc_value, exc_tb) \ No newline at end of file diff --git a/docs/source/UserGuides/CLI/Index.rst b/docs/source/UserGuides/CLI/Index.rst new file mode 100644 index 00000000..2b6aeaff --- /dev/null +++ b/docs/source/UserGuides/CLI/Index.rst @@ -0,0 +1,102 @@ +============= +CTERA SDK CLI +============= + +The CTERA Python SDK includes a set of built-in executable commands that are +installed automatically as part of the SDK package. These commands provide +direct access to common SDK functionality through a command-line interface. + +The following page describes the supported CLI commands and their usage. + +CTERA Direct/IO Download +------------------------ + +The ``cterasdk.io.direct.download`` command downloads a file using **CTERA Direct I/O**. + +The download is performed using the file's unique numeric **File ID** and +stores the file at a specified local path. Authentication can be performed +using either an Access/Secret key pair or a Bearer token. + +Arguments +^^^^^^^^^ + +``--endpoint`` / ``-e`` + CTERA Portal address (for example: ``corp.acme.ctera.com``). + +``--path`` / ``-p`` + Local destination path where the downloaded file will be saved + (for example: ``./download.zip``). + +``--file-id`` / ``-f`` + Numeric identifier of the file to download. + +``--access`` / ``-a`` + Access key used for authentication (optional). + +``--secret`` / ``-s`` + Secret key used together with the access key (optional). + +``--bearer`` / ``-b`` + Bearer authentication token (optional). + +``--no-verify-ssl`` / ``-k`` + Disable SSL certificate verification. Intended for testing or + environments using self-signed certificates. + +``--debug`` / ``-d`` + Enable verbose debug logging output. + +Examples +^^^^^^^^ + +Download a file using access and secret keys: + +**Bash (Linux / macOS)** + +.. code-block:: bash + + cterasdk.io.direct.download \ + --endpoint corp.acme.ctera.com \ + --file-id 847362915 \ + --path "./Statement.pdf" \ + --access AKIA7F3K9X2QPLM8D4ZT \ + --secret wJalrXUtnFEMI/K7MDENG/bPxRfiCY8zEXAMPLEKEY + +**Windows Command Prompt (cmd.exe)** + +.. code-block:: bash + + cterasdk.io.direct.download ^ + --endpoint corp.acme.ctera.com ^ + --file-id 847362915 ^ + --path ".\Statement.pdf" ^ + --access AKIA7F3K9X2QPLM8D4ZT ^ + --secret wJalrXUtnFEMI/K7MDENG/bPxRfiCY8zEXAMPLEKEY + + +CTERA Portal Invitation Browser +------------------------------- + +CLI to interact with external shares on CTERA Portal. + +.. code-block:: bash + + cterasdk.io.dav {ls,download,upload} [options] + +Commands +-------- + +Listing files and folders +^^^^^^^^^^^^^^^^^^^^^^^^^ + +``cterasdk.io.dav ls -e ENDPOINT [-s SRC] [-R]`` + +Downloading files and folders +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +``cterasdk.io.dav download -e ENDPOINT [-s SRC] [-R] [-d DEST] [-z]`` + +Uploading files +^^^^^^^^^^^^^^^ + +``cterasdk.io.dav upload -e ENDPOINT [-d DEST] files [files ...]`` diff --git a/docs/source/UserGuides/DataServices/DirectIO.rst b/docs/source/UserGuides/ContentServices/DirectIO.rst similarity index 98% rename from docs/source/UserGuides/DataServices/DirectIO.rst rename to docs/source/UserGuides/ContentServices/DirectIO.rst index 0f35d6df..e8009cb7 100644 --- a/docs/source/UserGuides/DataServices/DirectIO.rst +++ b/docs/source/UserGuides/ContentServices/DirectIO.rst @@ -43,7 +43,7 @@ Getting Started =============== In this example, a file is downloaded using its unique ID (e.g., ``12345``) and written to disk as ``example.pdf``. -For more information on how to obtain the File ID, name, refer to the `Event `_ object returned by the `Notification Service `_. +For more information on how to obtain the File ID, name, refer to the `Event `_ object returned by the `Notification Service `_. .. code-block:: python diff --git a/docs/source/UserGuides/ContentServices/Index.rst b/docs/source/UserGuides/ContentServices/Index.rst new file mode 100644 index 00000000..f113d91c --- /dev/null +++ b/docs/source/UserGuides/ContentServices/Index.rst @@ -0,0 +1,9 @@ +====================== +CTERA Content Services +====================== +.. toctree:: + :caption: CTERA Content Services + :maxdepth: 5 + + NotificationService + DirectIO diff --git a/docs/source/UserGuides/DataServices/NotificationService.rst b/docs/source/UserGuides/ContentServices/NotificationService.rst similarity index 100% rename from docs/source/UserGuides/DataServices/NotificationService.rst rename to docs/source/UserGuides/ContentServices/NotificationService.rst diff --git a/docs/source/UserGuides/DataServices/Index.rst b/docs/source/UserGuides/DataServices/Index.rst deleted file mode 100644 index 2839c60d..00000000 --- a/docs/source/UserGuides/DataServices/Index.rst +++ /dev/null @@ -1,9 +0,0 @@ -=================== -CTERA Data Services -=================== -.. toctree:: - :caption: CTERA Data Services - :maxdepth: 5 - - NotificationService - DirectIO diff --git a/docs/source/api/cterasdk.objects.asynchronous.core.rst b/docs/source/api/cterasdk.objects.asynchronous.core.rst new file mode 100644 index 00000000..58988723 --- /dev/null +++ b/docs/source/api/cterasdk.objects.asynchronous.core.rst @@ -0,0 +1,7 @@ +cterasdk.objects.asynchronous.core module +========================================= + +.. automodule:: cterasdk.objects.synchronous.core + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/cterasdk.objects.asynchronous.drive.rst b/docs/source/api/cterasdk.objects.asynchronous.drive.rst new file mode 100644 index 00000000..1a7e1b62 --- /dev/null +++ b/docs/source/api/cterasdk.objects.asynchronous.drive.rst @@ -0,0 +1,7 @@ +cterasdk.objects.asynchronous.drive module +========================================== + +.. automodule:: cterasdk.objects.asynchronous.drive + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/cterasdk.objects.asynchronous.edge.rst b/docs/source/api/cterasdk.objects.asynchronous.edge.rst new file mode 100644 index 00000000..f964e1a7 --- /dev/null +++ b/docs/source/api/cterasdk.objects.asynchronous.edge.rst @@ -0,0 +1,7 @@ +cterasdk.objects.asynchronous.edge module +========================================= + +.. automodule:: cterasdk.objects.asynchronous.edge + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/cterasdk.objects.asynchronous.invitation.rst b/docs/source/api/cterasdk.objects.asynchronous.invitation.rst new file mode 100644 index 00000000..95aefcf4 --- /dev/null +++ b/docs/source/api/cterasdk.objects.asynchronous.invitation.rst @@ -0,0 +1,7 @@ +cterasdk.objects.asynchronous.invitation module +=============================================== + +.. automodule:: cterasdk.objects.asynchronous.invitation + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/cterasdk.objects.asynchronous.rst b/docs/source/api/cterasdk.objects.asynchronous.rst new file mode 100644 index 00000000..14c9ddbb --- /dev/null +++ b/docs/source/api/cterasdk.objects.asynchronous.rst @@ -0,0 +1,17 @@ +cterasdk.objects.asynchronous package +===================================== + +.. automodule:: cterasdk.objects.asynchronous + :members: + :undoc-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + + cterasdk.objects.asynchronous.drive + cterasdk.objects.asynchronous.edge + cterasdk.objects.asynchronous.core + cterasdk.objects.asynchronous.invitation diff --git a/docs/source/api/cterasdk.objects.rst b/docs/source/api/cterasdk.objects.rst index 41802078..f5c46691 100644 --- a/docs/source/api/cterasdk.objects.rst +++ b/docs/source/api/cterasdk.objects.rst @@ -11,7 +11,6 @@ Submodules .. toctree:: - cterasdk.objects.synchronous.drive - cterasdk.objects.synchronous.edge - cterasdk.objects.synchronous.core + cterasdk.objects.asynchronous + cterasdk.objects.synchronous diff --git a/docs/source/api/cterasdk.objects.synchronous.invitation.rst b/docs/source/api/cterasdk.objects.synchronous.invitation.rst new file mode 100644 index 00000000..bc2b10f3 --- /dev/null +++ b/docs/source/api/cterasdk.objects.synchronous.invitation.rst @@ -0,0 +1,7 @@ +cterasdk.objects.synchronous.invitation module +============================================== + +.. automodule:: cterasdk.objects.synchronous.invitation + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/api/cterasdk.objects.synchronous.rst b/docs/source/api/cterasdk.objects.synchronous.rst new file mode 100644 index 00000000..897591a5 --- /dev/null +++ b/docs/source/api/cterasdk.objects.synchronous.rst @@ -0,0 +1,17 @@ +cterasdk.objects.synchronous package +==================================== + +.. automodule:: cterasdk.objects.synchronous + :members: + :undoc-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + + cterasdk.objects.synchronous.drive + cterasdk.objects.synchronous.edge + cterasdk.objects.synchronous.core + cterasdk.objects.synchronous.invitation diff --git a/docs/source/index.rst b/docs/source/index.rst index 947f2608..00e0c21c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -18,8 +18,8 @@ Key Features 2. Managing `Data Discovery and Migration Jobs `_ 3. File Data Management APIs for `CTERA Edge Filer `_ and `CTERA Portal `_. 4. Data Services Framework: - 1. `Subscribing to File System Notifications `_ - 2. `CTERA Direct IO `_, enabling parallel high-performnace retrieval directly from object storage. + 1. `Subscribing to File System Notifications `_ + 2. `CTERA Direct IO `_, enabling parallel high-performnace retrieval directly from object storage. .. _cterasdk-installation: @@ -147,7 +147,8 @@ Table of Contents UserGuides/Edge/Index UserGuides/Portal/Index - UserGuides/DataServices/Index + UserGuides/ContentServices/Index + UserGuides/CLI/Index UserGuides/Miscellaneous/Index api/cterasdk diff --git a/pyproject.toml b/pyproject.toml index ee178348..73b43ef3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,8 @@ Documentation = "https://ctera-python-sdk.readthedocs.io/en/latest/" "Source Code" = "https://github.com/ctera/ctera-python-sdk" [project.scripts] -"cterasdk.io.direct.download" = "cterasdk.direct.cli:download_object" +"cterasdk.io.direct.download" = "cterasdk.cli.direct:download_object" +"cterasdk.io.dav" = "cterasdk.cli.dav:webdav" [tool.setuptools.dynamic] dependencies = {file = ["requirements.txt"]} From c1403aa0d972ee863af276e29c51036c508c0d40 Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Thu, 9 Apr 2026 19:13:15 -0400 Subject: [PATCH 02/10] flake8 --- cterasdk/asynchronous/core/invitation/login.py | 2 +- cterasdk/cio/core/commands.py | 1 - cterasdk/cio/core/types.py | 2 +- cterasdk/cli/dav.py | 8 ++++---- cterasdk/core/invitation/login.py | 3 +-- cterasdk/lib/session/core.py | 2 +- cterasdk/lib/storage/asynfs.py | 2 +- cterasdk/lib/storage/synfs.py | 1 - cterasdk/objects/asynchronous/invitation.py | 13 ++++++++----- cterasdk/objects/invitation.py | 2 +- cterasdk/objects/synchronous/invitation.py | 4 ++-- 11 files changed, 20 insertions(+), 20 deletions(-) diff --git a/cterasdk/asynchronous/core/invitation/login.py b/cterasdk/asynchronous/core/invitation/login.py index f2de49bc..988987c1 100644 --- a/cterasdk/asynchronous/core/invitation/login.py +++ b/cterasdk/asynchronous/core/invitation/login.py @@ -10,7 +10,7 @@ class Login(BaseCommand): Portal Login APIs """ - async def login(self, key, value): #pylint: disable=unused-argument + async def login(self, key, value): logger.info('Creating external session. %s', {'invite': value}) await self._core.clients.v1.ctera.get('', params={key: value}) diff --git a/cterasdk/cio/core/commands.py b/cterasdk/cio/core/commands.py index 7a809af0..bb9d0b02 100644 --- a/cterasdk/cio/core/commands.py +++ b/cterasdk/cio/core/commands.py @@ -696,7 +696,6 @@ async def _a_execute(self): def _suppress_file_conflict_error(e): if not isinstance(e.__cause__, exceptions.io.core.FileConflictError): raise e - def _handle_response(self, r): path = self.path.relative diff --git a/cterasdk/cio/core/types.py b/cterasdk/cio/core/types.py index 261fbaf7..93e8345a 100644 --- a/cterasdk/cio/core/types.py +++ b/cterasdk/cio/core/types.py @@ -110,7 +110,7 @@ class InvitationPath(PortalPath): Invitation Path Object """ Namespace = '/invitations/webdav' - expr = f'{Namespace}/share\/([a-zA-Z0-9]+)' + expr = f'{Namespace}/share/([a-zA-Z0-9]+)' @staticmethod def from_context(reference): diff --git a/cterasdk/cli/dav.py b/cterasdk/cli/dav.py index 2b06dbfa..e2a4d72e 100644 --- a/cterasdk/cli/dav.py +++ b/cterasdk/cli/dav.py @@ -48,7 +48,7 @@ async def handle_download(args): target = Path(args.dest or os.getcwd()) try: - + Path.mkdir(target, exist_ok=True) async def download(invitation, resource, objects=None, archive=False, destination=None): @@ -81,7 +81,7 @@ async def download(invitation, resource, objects=None, archive=False, destinatio await asynfs.mkdir(target.joinpath(r.path.relative), parents=True, exist_ok=True) else: jobs.append(download(invitation, r, destination=target)) - except (GetMetadataError, ListDirectoryError) as e: + except (GetMetadataError, ListDirectoryError): print(f"error: failed to obtain properties or list a directory: '{args.src}'", file=sys.stderr) sys.exit(1) @@ -104,7 +104,7 @@ async def handle_upload(args): async with AsyncInvitation.from_uri(args.endpoint) as invitation: if not invitation.details.is_dir: - print(f"error: destination is not a directory", file=sys.stderr) + print("error: destination is not a directory", file=sys.stderr) sys.exit(1) elif invitation.details.access not in [FileAccessMode.RW, FileAccessMode.UO]: print(f"error: insufficient permissions to write to: {args.dest}", file=sys.stderr) @@ -159,4 +159,4 @@ async def main(): def webdav(): - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/cterasdk/core/invitation/login.py b/cterasdk/core/invitation/login.py index c0011bb8..1afefe4a 100644 --- a/cterasdk/core/invitation/login.py +++ b/cterasdk/core/invitation/login.py @@ -1,6 +1,5 @@ import logging from ..base_command import BaseCommand -from ...exceptions.transport import InternalServerError logger = logging.getLogger('cterasdk.core') @@ -11,7 +10,7 @@ class Login(BaseCommand): Portal Login APIs """ - def login(self, key, value): #pylint: disable=unused-argument + def login(self, key, value): logger.info('Creating external session. %s', {'invite': value}) self._core.clients.ctera.get('', params={key: value}) diff --git a/cterasdk/lib/session/core.py b/cterasdk/lib/session/core.py index ab37bcc4..b1b72aee 100644 --- a/cterasdk/lib/session/core.py +++ b/cterasdk/lib/session/core.py @@ -63,7 +63,7 @@ async def _async_start_session(self, session): software_version = session.v1.api.get('/version') authorizations = await session.roles.get(user_session.role) if user_session.role in Administrators else None account = PortalUser(user_session.username, user_session.domain, await current_tenant or Session.Administration, - user_session.role, authorizations) + user_session.role, authorizations) self._update_software_version(await software_version) else: account = ExternalUser() diff --git a/cterasdk/lib/storage/asynfs.py b/cterasdk/lib/storage/asynfs.py index 536ac264..ac9c322e 100644 --- a/cterasdk/lib/storage/asynfs.py +++ b/cterasdk/lib/storage/asynfs.py @@ -56,5 +56,5 @@ async def mkdir(p, parents=False, exist_ok=True): await aiofiles.os.makedirs(location, exist_ok=exist_ok) else: await aiofiles.os.mkdir(location, exist_ok=exist_ok) - + return location.as_posix() diff --git a/cterasdk/lib/storage/synfs.py b/cterasdk/lib/storage/synfs.py index 9f9c1f8a..08bbdd12 100644 --- a/cterasdk/lib/storage/synfs.py +++ b/cterasdk/lib/storage/synfs.py @@ -1,5 +1,4 @@ import logging -from pathlib import Path from .commonfs import write_new_version, ResultContext, expanduser diff --git a/cterasdk/objects/asynchronous/invitation.py b/cterasdk/objects/asynchronous/invitation.py index 49279ccc..34f1d97d 100644 --- a/cterasdk/objects/asynchronous/invitation.py +++ b/cterasdk/objects/asynchronous/invitation.py @@ -15,9 +15,12 @@ async def __aenter__(self): def __init__(self, host, port, invite): super().__init__(host, port) self.invite = invite - self.clients.v1.api = self.default.clone(clients.AsyncAPI, EndpointBuilder.new(self.base, self.context, f'/portalInvitation/share/{invite}')) - self.clients.io._webdav = self.default.clone(clients.AsyncWebDAV, EndpointBuilder.new(self.base, self.context, f'/webdav/share/{invite}')) - self.clients.io._upload = self.default.clone(clients.AsyncUpload, EndpointBuilder.new(self.base, self.context, f'/upload/share/{invite}')) + self.clients.v1.api = self.default.clone(clients.AsyncAPI, EndpointBuilder.new(self.base, + self.context, f'/portalInvitation/share/{invite}')) + self.clients.io._webdav = self.default.clone(clients.AsyncWebDAV, EndpointBuilder.new(self.base, + self.context, f'/webdav/share/{invite}')) + self.clients.io._upload = self.default.clone(clients.AsyncUpload, EndpointBuilder.new(self.base, + self.context, f'/upload/share/{invite}')) self.files = files.InvitationBrowser(self) self.details = None @@ -25,7 +28,7 @@ def __init__(self, host, port, invite): def context(self): return 'invitations' - def _authenticator(self, url): #pylint: disable=unused-argument + def _authenticator(self, url): # pylint: disable=unused-argument return True async def login(self): @@ -47,4 +50,4 @@ def from_uri(uri): async def __aexit__(self, exc_type, exc, tb): await self.logout() - return await super().__aexit__(exc_type, exc, tb) \ No newline at end of file + return await super().__aexit__(exc_type, exc, tb) diff --git a/cterasdk/objects/invitation.py b/cterasdk/objects/invitation.py index b7e7eb0b..45444240 100644 --- a/cterasdk/objects/invitation.py +++ b/cterasdk/objects/invitation.py @@ -22,4 +22,4 @@ def validate(uri): def uri(invitation): - return f'{invitation.clients.ctera.baseurl}/?share={invitation.invite}' \ No newline at end of file + return f'{invitation.clients.ctera.baseurl}/?share={invitation.invite}' diff --git a/cterasdk/objects/synchronous/invitation.py b/cterasdk/objects/synchronous/invitation.py index dbb3a28f..8c81a010 100644 --- a/cterasdk/objects/synchronous/invitation.py +++ b/cterasdk/objects/synchronous/invitation.py @@ -25,7 +25,7 @@ def __init__(self, host, port, invite): def context(self): return 'invitations' - def _authenticator(self, url): #pylint: disable=unused-argument + def _authenticator(self, url): # pylint: disable=unused-argument return True def login(self): @@ -47,4 +47,4 @@ def from_uri(uri): def __exit__(self, exc_type, exc_value, exc_tb): self.logout() - return super().__exit__(exc_type, exc_value, exc_tb) \ No newline at end of file + return super().__exit__(exc_type, exc_value, exc_tb) From d4585fa26717c82014c8a0273fefd833c4023f9a Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Thu, 9 Apr 2026 19:15:01 -0400 Subject: [PATCH 03/10] resolve flake8 --- cterasdk/asynchronous/core/files/browser.py | 2 +- cterasdk/cli/dav.py | 2 +- cterasdk/objects/synchronous/invitation.py | 9 ++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cterasdk/asynchronous/core/files/browser.py b/cterasdk/asynchronous/core/files/browser.py index dfb1a1f3..a9751e7b 100644 --- a/cterasdk/asynchronous/core/files/browser.py +++ b/cterasdk/asynchronous/core/files/browser.py @@ -396,4 +396,4 @@ async def upload_file(self, path, destination): return await self._file_browser.upload_file(path, self._invitation.join(destination)) async def details(self): - return await GetExternalShareInfo(io.get_share_details, self._core, self._core.invite).a_execute() \ No newline at end of file + return await GetExternalShareInfo(io.get_share_details, self._core, self._core.invite).a_execute() diff --git a/cterasdk/cli/dav.py b/cterasdk/cli/dav.py index e2a4d72e..18ff600c 100644 --- a/cterasdk/cli/dav.py +++ b/cterasdk/cli/dav.py @@ -63,7 +63,7 @@ async def download(invitation, resource, objects=None, archive=False, destinatio if args.src: print(f"error: object not found error: '{args.src}'", file=sys.stderr) sys.exit(1) - resource = await anext(invitation.files.listdir()) + resource = await anext(invitation.files.listdir()) # noqa: F821 jobs.append(download(invitation, resource, destination=target)) else: try: diff --git a/cterasdk/objects/synchronous/invitation.py b/cterasdk/objects/synchronous/invitation.py index 8c81a010..69a0536d 100644 --- a/cterasdk/objects/synchronous/invitation.py +++ b/cterasdk/objects/synchronous/invitation.py @@ -15,9 +15,12 @@ def __enter__(self): def __init__(self, host, port, invite): super().__init__(host, port) self.invite = invite - self.clients.api = self.default.clone(clients.API, EndpointBuilder.new(self.base, self.context, f'/portalInvitation/share/{invite}')) - self.clients.io._webdav = self.default.clone(clients.WebDAV, EndpointBuilder.new(self.base, self.context, f'/webdav/share/{invite}')) - self.clients.io._upload = self.default.clone(clients.Upload, EndpointBuilder.new(self.base, self.context, f'/upload/share/{invite}')) + self.clients.api = self.default.clone(clients.API, EndpointBuilder.new(self.base, + self.context, f'/portalInvitation/share/{invite}')) + self.clients.io._webdav = self.default.clone(clients.WebDAV, EndpointBuilder.new(self.base, + self.context, f'/webdav/share/{invite}')) + self.clients.io._upload = self.default.clone(clients.Upload, EndpointBuilder.new(self.base, + self.context, f'/upload/share/{invite}')) self.files = files.InvitationBrowser(self) self.details = None From 9a0bcb8d331d42f5d3693d237becdb72137b6097 Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Thu, 9 Apr 2026 19:34:55 -0400 Subject: [PATCH 04/10] update to pass pylint --- cterasdk/cli/dav.py | 10 +++++----- cterasdk/core/types.py | 1 + cterasdk/lib/session/base.py | 1 + cterasdk/objects/asynchronous/invitation.py | 4 ++-- cterasdk/objects/invitation.py | 8 ++------ cterasdk/objects/synchronous/invitation.py | 4 ++-- 6 files changed, 13 insertions(+), 15 deletions(-) diff --git a/cterasdk/cli/dav.py b/cterasdk/cli/dav.py index 18ff600c..cd12615f 100644 --- a/cterasdk/cli/dav.py +++ b/cterasdk/cli/dav.py @@ -44,7 +44,7 @@ async def handle_ls(args): print(formatter.format(*row)) -async def handle_download(args): +async def handle_download(args): # pylint: disable=too-many-branches target = Path(args.dest or os.getcwd()) try: @@ -59,11 +59,11 @@ async def download(invitation, resource, objects=None, archive=False, destinatio async with AsyncInvitation.from_uri(args.endpoint) as invitation: jobs = [] - if not invitation.details.is_dir: + if not invitation.details.is_dir: # pylint: disable=too-many-nested-blocks if args.src: print(f"error: object not found error: '{args.src}'", file=sys.stderr) sys.exit(1) - resource = await anext(invitation.files.listdir()) # noqa: F821 + resource = await invitation.files.listdir().__anext__() jobs.append(download(invitation, resource, destination=target)) else: try: @@ -90,9 +90,9 @@ async def download(invitation, resource, objects=None, archive=False, destinatio except PermissionError: print(f"error: permission denied: Cannot create files and folders at '{target}'.", file=sys.stderr) except NotADirectoryError: - print(f"error: path conflict: '{target}' is a file, but a folder was expected.", file=sys.strerr) + print(f"error: path conflict: '{target}' is a file, but a folder was expected.", file=sys.stderr) except OSError as e: - print(f"error: an unexpected error occurred during download. {e}", file=sys.strerr) + print(f"error: an unexpected error occurred during download. {e}", file=sys.stderr) async def handle_upload(args): diff --git a/cterasdk/core/types.py b/cterasdk/core/types.py index aae4d097..a9827a66 100644 --- a/cterasdk/core/types.py +++ b/cterasdk/core/types.py @@ -894,6 +894,7 @@ def rename(apply_all=True): class PortalInvitation(Object): def __init__(self, access, is_dir): + super().__init__() self.access = access self.is_dir = is_dir diff --git a/cterasdk/lib/session/base.py b/cterasdk/lib/session/base.py index c353ba51..d7ca1e74 100644 --- a/cterasdk/lib/session/base.py +++ b/cterasdk/lib/session/base.py @@ -21,6 +21,7 @@ class BaseUser(Object): """Base User Account""" def __init__(self, type_of): + super().__init__() self.type = type_of diff --git a/cterasdk/objects/asynchronous/invitation.py b/cterasdk/objects/asynchronous/invitation.py index 34f1d97d..66d28033 100644 --- a/cterasdk/objects/asynchronous/invitation.py +++ b/cterasdk/objects/asynchronous/invitation.py @@ -31,13 +31,13 @@ def context(self): def _authenticator(self, url): # pylint: disable=unused-argument return True - async def login(self): + async def login(self): # pylint: disable=arguments-differ await super().login('share', self.invite) self.details = await self.files.details() @property def uri(self): - return invitation.uri(self) + return f'{self.clients.v1.ctera.baseurl}/?share={self.invite}' @property def _login_object(self): diff --git a/cterasdk/objects/invitation.py b/cterasdk/objects/invitation.py index 45444240..7667ddec 100644 --- a/cterasdk/objects/invitation.py +++ b/cterasdk/objects/invitation.py @@ -1,8 +1,8 @@ from .uri import components, parse_qsl -def validate(uri): - r = components(uri) +def validate(resource): + r = components(resource) assert r.scheme == "https", f"Error: Expected 'https' scheme, got '{r.scheme}'." assert r.netloc, "Error: Could not identify network location." @@ -19,7 +19,3 @@ def validate(uri): return r.hostname, r.port, invite raise ValueError("Error: Could not find invitation identifer.") - - -def uri(invitation): - return f'{invitation.clients.ctera.baseurl}/?share={invitation.invite}' diff --git a/cterasdk/objects/synchronous/invitation.py b/cterasdk/objects/synchronous/invitation.py index 69a0536d..35be9b8b 100644 --- a/cterasdk/objects/synchronous/invitation.py +++ b/cterasdk/objects/synchronous/invitation.py @@ -31,13 +31,13 @@ def context(self): def _authenticator(self, url): # pylint: disable=unused-argument return True - def login(self): + def login(self): # pylint: disable=arguments-differ super().login('share', self.invite) self.details = self.files.details() @property def uri(self): - return invitation.uri(self) + return f'{self.clients.ctera.baseurl}/?share={self.invite}' @property def _login_object(self): From 789ea06ce62ebe2a9fc7241d9737eb4a825d76bc Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Thu, 9 Apr 2026 19:42:35 -0400 Subject: [PATCH 05/10] gather if jobs exist --- cterasdk/cli/dav.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/cterasdk/cli/dav.py b/cterasdk/cli/dav.py index cd12615f..02f179ad 100644 --- a/cterasdk/cli/dav.py +++ b/cterasdk/cli/dav.py @@ -6,7 +6,7 @@ from ..core.enum import FileAccessMode from ..lib.storage import asynfs, commonfs from ..objects.asynchronous.invitation import AsyncInvitation -from ..exceptions.io.core import ListDirectoryError, GetMetadataError +from ..exceptions.io.core import ListDirectoryError, GetMetadataError, ExternalShareError async def handle_ls(args): @@ -83,9 +83,9 @@ async def download(invitation, resource, objects=None, archive=False, destinatio jobs.append(download(invitation, r, destination=target)) except (GetMetadataError, ListDirectoryError): print(f"error: failed to obtain properties or list a directory: '{args.src}'", file=sys.stderr) - sys.exit(1) - await asyncio.gather(*jobs) + if jobs: + await asyncio.gather(*jobs) except PermissionError: print(f"error: permission denied: Cannot create files and folders at '{target}'.", file=sys.stderr) @@ -123,9 +123,10 @@ async def handle_upload(args): for p in args.files: jobs.append(invitation.files.upload_file(p, destination)) - await asyncio.gather(*jobs) + if jobs: + await asyncio.gather(*jobs) - except GetMetadataError: + except (GetMetadataError, ExternalShareError): print(f"error: failed to obtain properties for directory: '{args.dest}'", file=sys.stderr) sys.exit(1) From 98c65320da9ff14901e032010adf929491e26472 Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Fri, 10 Apr 2026 09:12:29 -0400 Subject: [PATCH 06/10] update edge path to pass initialization --- cterasdk/cio/edge/types.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cterasdk/cio/edge/types.py b/cterasdk/cio/edge/types.py index 5251848e..69cb04e0 100644 --- a/cterasdk/cio/edge/types.py +++ b/cterasdk/cio/edge/types.py @@ -7,10 +7,9 @@ class EdgePath(BasePath): """ Edge Filer Path Object """ - Namespace = '/' - def __init__(self, reference): - super().__init__(EdgePath.Namespace, reference or '.') + def __init__(self, scope, reference): + super().__init__(scope, reference or '.') class EdgeResource(BaseResource): @@ -25,7 +24,6 @@ class EdgeResource(BaseResource): :ivar datetime.datetime last_modified: Last Modified :ivar str extension: Extension """ - Scheme = 'ctera-edge' def __init__(self, path, is_dir, size, created_at, last_modified): super().__init__(path.name, path, is_dir, size, last_modified) From aebd630d89e0384fd58a42ed70dbffb6f6443f41 Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Fri, 10 Apr 2026 09:16:23 -0400 Subject: [PATCH 07/10] update to add from_context to edge-path --- cterasdk/cio/edge/types.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/cterasdk/cio/edge/types.py b/cterasdk/cio/edge/types.py index 69cb04e0..87981fa0 100644 --- a/cterasdk/cio/edge/types.py +++ b/cterasdk/cio/edge/types.py @@ -7,9 +7,11 @@ class EdgePath(BasePath): """ Edge Filer Path Object """ + Namespace = '/' - def __init__(self, scope, reference): - super().__init__(scope, reference or '.') + @staticmethod + def from_context(reference): + return EdgePath(EdgePath.Namespace, reference) class EdgeResource(BaseResource): @@ -58,7 +60,7 @@ def resolve(path): return path.path if path is None or isinstance(path, str): - return EdgePath(path) + return EdgePath.from_context(path) raise ValueError(f'Error: Could not resolve path: {path}. Type: {type(path)}') From 407cfaa6a19ddefb8af5fddd47e8e5ae7b1b9468 Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Fri, 10 Apr 2026 09:28:40 -0400 Subject: [PATCH 08/10] resolve edge path and allow to walk and list from root --- cterasdk/cio/edge/types.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cterasdk/cio/edge/types.py b/cterasdk/cio/edge/types.py index 87981fa0..57f6e44d 100644 --- a/cterasdk/cio/edge/types.py +++ b/cterasdk/cio/edge/types.py @@ -39,7 +39,7 @@ def decode_reference(href): @staticmethod def from_server_object(server_object): return EdgeResource( - EdgePath(EdgeResource.decode_reference(server_object.href)), + EdgePath.from_context(EdgeResource.decode_reference(server_object.href)), server_object.getcontenttype == 'httpd/unix-directory', server_object.getcontentlength, datetime.fromisoformat(server_object.creationdate), @@ -60,7 +60,7 @@ def resolve(path): return path.path if path is None or isinstance(path, str): - return EdgePath.from_context(path) + return EdgePath.from_context(path or '.') raise ValueError(f'Error: Could not resolve path: {path}. Type: {type(path)}') From f4d63fb349f506e32c0d14402ab14cea436306b0 Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Fri, 10 Apr 2026 09:33:10 -0400 Subject: [PATCH 09/10] resolve doc errors --- cterasdk/core/buckets.py | 3 ++- cterasdk/core/portals.py | 3 ++- cterasdk/core/servers.py | 3 ++- docs/source/api/cterasdk.objects.asynchronous.core.rst | 2 +- docs/source/api/cterasdk.objects.asynchronous.rst | 1 - 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cterasdk/core/buckets.py b/cterasdk/core/buckets.py index 74517da1..3f6fe725 100644 --- a/cterasdk/core/buckets.py +++ b/cterasdk/core/buckets.py @@ -102,7 +102,8 @@ def modify(self, current_name, new_name=None, read_only=None, dedicated_to=None, def list_buckets(self, include=None): """ List Buckets. - Restricted to the Global Administration Portal. Browse it using :py:func:`cterasdk.core.portals.browse_global_admin`. + Restricted to the Global Administration Portal. Browse it using :py:func:`cterasdk.core.portals.browse_global_admin`. + :param list[str],optional include: List of fields to retrieve, defaults to ``['name']`` """ include = union(include or [], Buckets.default) diff --git a/cterasdk/core/portals.py b/cterasdk/core/portals.py index b4338dbf..e4fe7c53 100644 --- a/cterasdk/core/portals.py +++ b/cterasdk/core/portals.py @@ -33,7 +33,8 @@ def get(self, name, include=None): def list_tenants(self, include=None, portal_type=None): """ List tenants. - Restricted to the Global Administration Portal. Browse it using :py:func:`cterasdk.core.portals.browse_global_admin`. + Restricted to the Global Administration Portal. Browse it using :py:func:`cterasdk.core.portals.browse_global_admin`. + :param list[str],optional include: List of fields to retrieve, defaults to ['name'] :param cterasdk.core.enum.PortalType portal_type: Portal type """ diff --git a/cterasdk/core/servers.py b/cterasdk/core/servers.py index d5a60924..0a4f19e3 100644 --- a/cterasdk/core/servers.py +++ b/cterasdk/core/servers.py @@ -46,7 +46,8 @@ def get(self, name, include=None): def list_servers(self, include=None): """ Retrieve the servers that comprise CTERA Portal. - Restricted to the Global Administration Portal. Browse it using :py:func:`cterasdk.core.portals.browse_global_admin`. + Restricted to the Global Administration Portal. Browse it using :py:func:`cterasdk.core.portals.browse_global_admin`. + :param list[str],optional include: List of fields to retrieve, defaults to ['name'] """ # browse administration diff --git a/docs/source/api/cterasdk.objects.asynchronous.core.rst b/docs/source/api/cterasdk.objects.asynchronous.core.rst index 58988723..8f2dafcd 100644 --- a/docs/source/api/cterasdk.objects.asynchronous.core.rst +++ b/docs/source/api/cterasdk.objects.asynchronous.core.rst @@ -1,7 +1,7 @@ cterasdk.objects.asynchronous.core module ========================================= -.. automodule:: cterasdk.objects.synchronous.core +.. automodule:: cterasdk.objects.asynchronous.core :members: :undoc-members: :show-inheritance: diff --git a/docs/source/api/cterasdk.objects.asynchronous.rst b/docs/source/api/cterasdk.objects.asynchronous.rst index 14c9ddbb..f3639496 100644 --- a/docs/source/api/cterasdk.objects.asynchronous.rst +++ b/docs/source/api/cterasdk.objects.asynchronous.rst @@ -11,7 +11,6 @@ Submodules .. toctree:: - cterasdk.objects.asynchronous.drive cterasdk.objects.asynchronous.edge cterasdk.objects.asynchronous.core cterasdk.objects.asynchronous.invitation From e4390d1609a255857e3c0d4202fe4fb70a10d160 Mon Sep 17 00:00:00 2001 From: Saimon Michelson Date: Fri, 10 Apr 2026 09:53:27 -0400 Subject: [PATCH 10/10] update pylint rc --- .pylintrc | 2 +- cterasdk/cli/dav.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pylintrc b/.pylintrc index 5a30a827..98ea7298 100644 --- a/.pylintrc +++ b/.pylintrc @@ -38,7 +38,7 @@ persistent=yes # When enabled, pylint would attempt to guess common misconfiguration and emit # user-friendly hints instead of false-positive error messages. -suggestion-mode=yes +# suggestion-mode=yes # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. diff --git a/cterasdk/cli/dav.py b/cterasdk/cli/dav.py index 02f179ad..95e14a4b 100644 --- a/cterasdk/cli/dav.py +++ b/cterasdk/cli/dav.py @@ -63,7 +63,7 @@ async def download(invitation, resource, objects=None, archive=False, destinatio if args.src: print(f"error: object not found error: '{args.src}'", file=sys.stderr) sys.exit(1) - resource = await invitation.files.listdir().__anext__() + resource = await invitation.files.listdir().__anext__() # pylint: disable=unnecessary-dunder-call jobs.append(download(invitation, resource, destination=target)) else: try: