From c630087823021cbb91f880d99470b8cf1a2a0a3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Juli=C3=A1n=20Espina?= Date: Mon, 13 Jan 2025 16:03:15 -0600 Subject: [PATCH] feat: setup monorepo tools --- .editorconfig | 15 + .gitignore | 27 +- .jujuignore | 1 - CONTRIBUTE.md => CONTRIBUTING.md | 0 charmcraft.yaml | 75 -- justfile | 63 +- .../filesystem_client/v0/filesystem_info.py | 673 ----------------- pyproject.toml | 18 +- repository.py | 686 ++++++++++++++++++ src/charm.py | 130 ---- src/utils/__init__.py | 4 - src/utils/manager.py | 223 ------ tests/integration/helpers.py | 2 +- uv.lock | 259 ++++--- 14 files changed, 916 insertions(+), 1260 deletions(-) create mode 100644 .editorconfig delete mode 100644 .jujuignore rename CONTRIBUTE.md => CONTRIBUTING.md (100%) delete mode 100644 charmcraft.yaml delete mode 100644 lib/charms/filesystem_client/v0/filesystem_info.py create mode 100755 repository.py delete mode 100755 src/charm.py delete mode 100644 src/utils/__init__.py delete mode 100644 src/utils/manager.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fcff922 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.{py,ini}] +indent_style = space +indent_size = 4 + +[*.{yaml,yml,tf}] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore index 25ec1c7..40e9468 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,26 @@ -venv/ -build/ +venv +build *.charm -.tox/ +.tox .coverage __pycache__/ *.py[cod] .idea -.vscode/ -requirements.txt +.vscode +version +cover + +# Managed by repository.py +.charmhub.secret +_build +external -# Ignore libs except the filesystem_client +# Managed by Charmcraft +**/lib/charms/* -lib/charms/* -!lib/charms/filesystem_client/ +# Managed by uv +.venv +.ruff_cache +.pytest_cache +requirements.txt +dev-requirements.txt diff --git a/.jujuignore b/.jujuignore deleted file mode 100644 index 2bfa6a4..0000000 --- a/.jujuignore +++ /dev/null @@ -1 +0,0 @@ -tests/ diff --git a/CONTRIBUTE.md b/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTE.md rename to CONTRIBUTING.md diff --git a/charmcraft.yaml b/charmcraft.yaml deleted file mode 100644 index c2cc389..0000000 --- a/charmcraft.yaml +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. - -name: filesystem-client - -type: charm - -title: Filesystem Client - -summary: Mount filesystems on machine charms. - -description: | - Mount filesystems on machine charms. - - Enables access to exported filesystems. - -base: ubuntu@24.04 -platforms: - amd64: - -parts: - charm: - build-packages: - - just - build-snaps: - - astral-uv - charm-requirements: ["requirements.txt"] - override-build: | - # hack to get around a weird incompatibility between destructive and non-destructive mode - cp $CRAFT_PROJECT_DIR/charmcraft.yaml $CRAFT_PART_BUILD/charmcraft.yaml - just requirements - charmcraft fetch-libs - craftctl default - -charm-libs: - - lib: operator-libs-linux.apt - version: "0.15" - - lib: operator-libs-linux.systemd - version: "1.4" - -subordinate: true - -requires: - filesystem: - interface: filesystem_info - limit: 1 - juju-info: - interface: juju-info - scope: container - -config: - options: - mountpoint: - description: Location to mount the filesystem on the machine. - type: string - noexec: - default: false - description: | - Block execution of binaries on the filesystem. - type: boolean - nosuid: - default: false - description: | - Do not honor suid and sgid bits on the filesystem. - type: boolean - nodev: - default: false - description: | - Blocking interpretation of character and/or block - devices on the filesystem. - type: boolean - read-only: - default: false - description: Mount filesystem as read-only. - type: boolean diff --git a/justfile b/justfile index 88808ed..2316cda 100644 --- a/justfile +++ b/justfile @@ -1,14 +1,5 @@ uv := `which uv` -charmcraft := `which charmcraft` -project_dir := justfile_directory() - -src := project_dir / "src" -tests := project_dir / "tests" -lib := project_dir / "lib/charms/filesystem_client" -all := src + " " + tests + " " + lib - -export PYTHONPATH := project_dir + ":" + project_dir / "lib" + ":" + src export PY_COLORS := "1" export PYTHONBREAKPOINT := "pdb.set_trace" @@ -16,59 +7,15 @@ uv_run := "uv run --frozen --extra dev" # Regenerate uv.lock. lock: - uv lock --no-cache - -# Fetch the required charm libraries. -fetch-libs: - charmcraft fetch-libs + uv lock # Create a development environment. -env: lock fetch-libs +env: lock uv sync --extra dev # Upgrade uv.lock with the latest deps upgrade: - uv lock --upgrade --no-cache - -# Generate requirements.txt from pyproject.toml -requirements: lock - uv export --frozen --no-hashes --format=requirements-txt -o requirements.txt - -# Apply coding style standards to code -fmt: lock - {{uv_run}} ruff format {{all}} - {{uv_run}} ruff check --fix {{all}} - -# Check code against coding style standards -lint: lock fetch-libs - {{uv_run}} codespell {{lib}} - {{uv_run}} codespell {{project_dir}} - {{uv_run}} ruff check {{all}} - {{uv_run}} ruff format --check --diff {{all}} - -# Run static type checks -typecheck *args: lock fetch-libs - {{uv_run}} pyright {{args}} - -# Run unit tests -unit *args: lock fetch-libs - {{uv_run}} coverage run \ - --source={{src}} \ - --source={{lib}} \ - -m pytest \ - --tb native \ - -v \ - -s \ - {{args}} \ - {{tests}}/unit - {{uv_run}} coverage report + uv lock --upgrade -# Run integration tests -integration *args: lock fetch-libs - {{uv_run}} pytest \ - -v \ - -s \ - --tb native \ - --log-cli-level=INFO \ - {{args}} \ - {{tests}}/integration \ No newline at end of file +repository *args: lock + {{uv_run}} repository.py {{args}} \ No newline at end of file diff --git a/lib/charms/filesystem_client/v0/filesystem_info.py b/lib/charms/filesystem_client/v0/filesystem_info.py deleted file mode 100644 index e28ca6f..0000000 --- a/lib/charms/filesystem_client/v0/filesystem_info.py +++ /dev/null @@ -1,673 +0,0 @@ -# Copyright 2024 Canonical Ltd. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Library to manage integrations between filesystem providers and consumers. - -This library contains the FilesystemProvides and FilesystemRequires classes for managing an -integration between a filesystem server operator and a filesystem client operator. - -## FilesystemInfo (filesystem mount information) - -This abstract class defines the methods that a filesystem type must expose for providers and -consumers. Any subclass of this class will be compatible with the other methods exposed -by the interface library, but the server and the client are the ones responsible for deciding which -filesystems to support. - -## FilesystemRequires (filesystem client) - -This class provides a uniform interface for charms that need to mount or unmount filesystems, -and convenience methods for consuming data sent by a filesystem server charm. - -### Defined events - -- `mount_filesystem`: Event emitted when the filesystem is ready to be mounted. -- `umount_filesystem`: Event emitted when the filesystem needs to be unmounted. - -### Example - -``python -import ops -from charms.filesystem_client.v0.filesystem_info import ( - FilesystemRequires, - MountFilesystemEvent, -) - -class StorageClientCharm(ops.CharmBase): - # Application charm that needs to mount filesystems. - - def __init__(self, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - - # Charm events defined in the FilesystemRequires class. - self._fs = FilesystemRequires(self, "filesystem") - self.framework.observe( - self._fs.on.mount_filesystem, - self._on_mount_filesystem, - ) - - def _on_mount_filesystem(self, event: MountFilesystemEvent) -> None: - # Handle when new filesystem server is connected. - - endpoint = event.endpoint - - self.mount("/mnt", endpoint.info) - - self.unit.status = ops.ActiveStatus("Mounted filesystem at `/mnt`.") -``` - -## FilesystemProvides (filesystem server) - -This library provides a uniform interface for charms that expose filesystems. - -> __Note:__ It is the responsibility of the provider charm to have -> the implementation for creating a new filesystem share. FilesystemProvides just exposes -> the interface for the integration. - -### Example - -```python -import ops -from charms.filesystem_client.v0.filesystem_info import ( - FilesystemProvides, - NfsInfo, -) - -class StorageServerCharm(ops.CharmBase): - def __init__(self, framework: ops.Framework) -> None: - super().__init__(framework) - self._filesystem = FilesystemProvides(self, "filesystem", "server-peers") - framework.observe(self.on.start, self._on_start) - - def _on_start(self, event: ops.StartEvent) -> None: - # Handle start event. - self._filesystem.set_info(NfsInfo("192.168.1.254", 65535, "/srv")) - self.unit.status = ops.ActiveStatus() -``` -""" - -import logging -from abc import ABC, abstractmethod -from dataclasses import dataclass -from ipaddress import AddressValueError, IPv6Address -from typing import List, Optional, TypeVar -from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse, urlunsplit - -import ops -from ops.charm import ( - CharmBase, - CharmEvents, - RelationChangedEvent, - RelationDepartedEvent, - RelationEvent, - RelationJoinedEvent, -) -from ops.framework import EventSource, Object -from ops.model import Model, Relation - -__all__ = [ - "FilesystemInfoError", - "ParseUriError", - "FilesystemInfo", - "NfsInfo", - "CephfsInfo", - "Endpoint", - "FilesystemEvent", - "MountFilesystemEvent", - "UmountFilesystemEvent", - "FilesystemRequiresEvents", - "FilesystemRequires", - "FilesystemProvides", -] - -# The unique Charmhub library identifier, never change it -LIBID = "7e11f60a31a441aaa70ada2f41c75580" - -# Increment this major API version when introducing breaking changes -LIBAPI = 0 - -# Increment this PATCH version before using `charmcraft publish-lib` or reset -# to 0 if you are raising the major API version -LIBPATCH = 2 - -_logger = logging.getLogger(__name__) - - -class FilesystemInfoError(Exception): - """Exception raised when an operation failed.""" - - -class ParseUriError(FilesystemInfoError): - """Exception raised when a parse operation from an URI failed.""" - - -# Design-wise, this class represents the grammar that relations use to -# share data between providers and requirers: -# -# key = 1*( unreserved ) -# value = 1*( unreserved / ":" / "/" / "?" / "#" / "[" / "]" / "@" / "!" / "$" -# / "'" / "(" / ")" / "*" / "+" / "," / ";" ) -# options = key "=" value ["&" options] -# host-port = host [":" port] -# hosts = host-port [',' hosts] -# authority = [userinfo "@"] "(" hosts ")" -# URI = scheme "://" authority path-absolute ["?" options] -# -# Unspecified grammar rules are given by [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986#appendix-A). -# -# This essentially leaves 5 components that the library can use to share data: -# - scheme: representing the type of filesystem. -# - hosts: representing the list of hosts where the filesystem lives. For NFS it should be a single element, -# but CephFS and Lustre use more than one endpoint. -# - user: Any kind of authentication user that the client must specify to mount the filesystem. -# - path: The internally exported path of each filesystem. Could be optional if a filesystem exports its -# whole tree, but at the very least NFS, CephFS and Lustre require an export path. -# - options: Some filesystems will require additional options for its specific mount command (e.g. Ceph). -# -# Putting all together, this allows sharing the required data using simple URI strings: -# ``` -# ://@(,*)//? -# -# nfs://(192.168.1.1:65535)/export -# ceph://fsuser@(192.168.1.1,192.168.1.2,192.168.1.3)/export?fsid=asdf1234&auth=plain:QWERTY1234&filesystem=fs_name -# ceph://fsuser@(192.168.1.1,192.168.1.2,192.168.1.3)/export?fsid=asdf1234&auth=secret:YXNkZnF3ZXJhc2RmcXdlcmFzZGZxd2Vy&filesystem=fs_name -# lustre://(192.168.227.11%40tcp1,192.168.227.12%40tcp1)/export -# ``` -# -# Note how in the Lustre URI we needed to escape the `@` symbol on the hosts to conform with the URI syntax. -@dataclass(init=False, frozen=True) -class _UriData: - """Raw data from the endpoint URI of a relation.""" - - scheme: str - """Scheme used to identify a filesystem. - - This will mostly correspond to the option `fstype` for the `mount` command. - """ - - hosts: [str] - """List of hosts where the filesystem is deployed on.""" - - user: str - """User to connect to the filesystem.""" - - path: str - """Path exported by the filesystem.""" - - options: dict[str, str] - """Additional options that could be required to mount the filesystem.""" - - def __init__( - self, - scheme: str, - hosts: [str], - user: str = "", - path: str = "/", - options: dict[str, str] = {}, - ) -> None: - if not scheme: - raise FilesystemInfoError("scheme cannot be empty") - if not hosts: - raise FilesystemInfoError("list of hosts cannot be empty") - path = path or "/" - - object.__setattr__(self, "scheme", scheme) - object.__setattr__(self, "hosts", hosts) - object.__setattr__(self, "user", user) - object.__setattr__(self, "path", path) - object.__setattr__(self, "options", options) - - @classmethod - def from_uri(cls, uri: str) -> "_UriData": - """Convert an URI string into a `_UriData`.""" - _logger.debug(f"_UriData.from_uri: parsing `{uri}`") - - result = urlparse(uri, allow_fragments=False) - scheme = str(result.scheme or "") - user = unquote(result.username or "") - hostname = unquote(result.hostname or "") - - if not hostname or hostname[0] != "(" or hostname[-1] != ")": - raise ParseUriError(f"invalid list of hosts for endpoint `{uri}`") - - hosts = hostname[1:-1].split(",") - path = unquote(result.path or "") - try: - options = ( - { - key: ",".join(values) - for key, values in parse_qs(result.query, strict_parsing=True).items() - } - if result.query - else {} - ) - except ValueError: - raise ParseUriError(f"invalid options for endpoint `{uri}`") - try: - return _UriData(scheme=scheme, user=user, hosts=hosts, path=path, options=options) - except FilesystemInfoError as e: - raise ParseUriError(*e.args) - - def __str__(self) -> str: - user = quote(self.user) - hostname = quote(",".join(self.hosts)) - path = quote(self.path) - netloc = f"{user}@({hostname})" if user else f"({hostname})" - query = urlencode(self.options) - return urlunsplit((self.scheme, netloc, path, query, None)) - - -def _hostinfo(host: str) -> tuple[str, Optional[int]]: - """Parse a host string into the hostname and the port.""" - _logger.debug(f"_hostinfo: parsing `{host}`") - if len(host) == 0: - raise ParseUriError("invalid empty host") - - pos = 0 - if host[pos] == "[": - # IPv6 - pos = host.find("]", pos) - if pos == -1: - raise ParseUriError("unclosed bracket for host") - hostname = host[1:pos] - pos = pos + 1 - else: - # IPv4 or DN - pos = host.find(":", pos) - if pos == -1: - pos = len(host) - hostname = host[:pos] - - if pos == len(host): - return hostname, None - - # more characters after the hostname <==> port - - if host[pos] != ":": - raise ParseUriError("expected `:` after IPv6 address") - try: - port = int(host[pos + 1 :]) - except ValueError: - raise ParseUriError("expected int after `:` in host") - - return hostname, port - - -T = TypeVar("T", bound="FilesystemInfo") - - -class FilesystemInfo(ABC): - """Information to mount a filesystem. - - This is an abstract class that exposes a set of required methods. All filesystems that - can be handled by this library must derive this abstract class. - """ - - @classmethod - @abstractmethod - def from_uri(cls: type[T], uri: str, model: Model) -> T: - """Convert an URI string into a `FilesystemInfo` object.""" - - @abstractmethod - def to_uri(self, model: Model) -> str: - """Convert this `FilesystemInfo` object into an URI string.""" - - def grant(self, model: Model, relation: ops.Relation) -> None: - """Grant permissions for a certain relation to any secrets that this `FilesystemInfo` has. - - This is an optional method because not all filesystems will require secrets to - be mounted on the client. - """ - - @classmethod - @abstractmethod - def filesystem_type(cls) -> str: - """Get the string identifier of this filesystem type.""" - - -@dataclass(frozen=True) -class NfsInfo(FilesystemInfo): - """Information required to mount an NFS share.""" - - hostname: str - """Hostname where the NFS server can be reached.""" - - port: Optional[int] - """Port where the NFS server can be reached.""" - - path: str - """Path exported by the NFS server.""" - - @classmethod - def from_uri(cls, uri: str, _model: Model) -> "NfsInfo": - """See :py:meth:`FilesystemInfo.from_uri` for documentation on this method.""" - _logger.debug(f"NfsInfo.from_uri: parsing `{uri}`") - - info = _UriData.from_uri(uri) - - if info.scheme != cls.filesystem_type(): - raise ParseUriError("could not parse uri with incompatible scheme into `NfsInfo`") - - path = info.path - - if info.user: - _logger.warning("ignoring user info on nfs endpoint info") - - if len(info.hosts) > 1: - _logger.info("multiple hosts specified. selecting the first one") - - if info.options: - _logger.warning("ignoring endpoint options on nfs endpoint info") - - hostname, port = _hostinfo(info.hosts[0]) - return NfsInfo(hostname=hostname, port=port, path=path) - - def to_uri(self, _model: Model) -> str: - """See :py:meth:`FilesystemInfo.to_uri` for documentation on this method.""" - try: - IPv6Address(self.hostname) - host = f"[{self.hostname}]" - except AddressValueError: - host = self.hostname - - hosts = [f"{host}:{self.port}" if self.port else host] - - return str(_UriData(scheme=self.filesystem_type(), hosts=hosts, path=self.path)) - - @classmethod - def filesystem_type(cls) -> str: - """See :py:meth:`FilesystemInfo.fs_type` for documentation on this method.""" - return "nfs" - - -@dataclass(frozen=True) -class CephfsInfo(FilesystemInfo): - """Information required to mount a CephFS share.""" - - fsid: str - """Cluster identifier.""" - - name: str - """Name of the exported filesystem.""" - - path: str - """Path exported within the filesystem.""" - - monitor_hosts: [str] - """List of reachable monitor hosts.""" - - user: str - """Ceph user authorized to access the filesystem.""" - - key: str - """Cephx key for the authorized user.""" - - @classmethod - def from_uri(cls, uri: str, model: Model) -> "CephfsInfo": - """See :py:meth:`FilesystemInfo.from_uri` for documentation on this method.""" - _logger.debug(f"CephfsInfo.from_uri: parsing `{uri}`") - info = _UriData.from_uri(uri) - - if info.scheme != cls.filesystem_type(): - raise ParseUriError("could not parse uri with incompatible scheme into `CephfsInfo`") - - path = info.path - - if not (user := info.user): - raise ParseUriError("missing user in uri for `CephfsInfo") - - if not (name := info.options.get("name")): - raise ParseUriError("missing name in uri for `CephfsInfo`") - - if not (fsid := info.options.get("fsid")): - raise ParseUriError("missing fsid in uri for `CephfsInfo`") - - monitor_hosts = info.hosts - - if not (auth := info.options.get("auth")): - raise ParseUriError("missing auth info in uri for `CephsInfo`") - - try: - kind, data = auth.split(":", 1) - except ValueError: - raise ParseUriError("could not get the kind of auth info") - - if kind == "secret": - key = model.get_secret(id=auth).get_content(refresh=True)["key"] - elif kind == "plain": - # Enables being able to pass data from reactive charms (such as `ceph-fs`), since - # they don't support secrets. - key = data - else: - raise ParseUriError(f"invalid kind `{kind}` for auth info") - - return CephfsInfo( - fsid=fsid, name=name, path=path, monitor_hosts=monitor_hosts, user=user, key=key - ) - - def to_uri(self, model: Model) -> str: - """See :py:meth:`FilesystemInfo.to_uri` for documentation on this method.""" - secret = self._get_or_create_auth_secret(model) - - options = { - "fsid": self.fsid, - "name": self.name, - "auth": secret.id, - "auth-rev": str(secret.get_info().revision), - } - - return str( - _UriData( - scheme=self.filesystem_type(), - hosts=self.monitor_hosts, - path=self.path, - user=self.user, - options=options, - ) - ) - - def grant(self, model: Model, relation: Relation) -> None: - """See :py:meth:`FilesystemInfo.grant` for documentation on this method.""" - secret = self._get_or_create_auth_secret(model) - - secret.grant(relation) - - @classmethod - def filesystem_type(cls) -> str: - """See :py:meth:`FilesystemInfo.fs_type` for documentation on this method.""" - return "cephfs" - - def _get_or_create_auth_secret(self, model: Model) -> ops.Secret: - try: - secret = model.get_secret(label="auth") - secret.set_content({"key": self.key}) - except ops.SecretNotFoundError: - secret = model.app.add_secret( - {"key": self.key}, - label="auth", - description="Cephx key to authenticate against the CephFS share", - ) - return secret - - -@dataclass -class Endpoint: - """Endpoint data exposed by a filesystem server.""" - - info: FilesystemInfo - """Filesystem information required to mount this endpoint.""" - - uri: str - """Raw URI exposed by this endpoint.""" - # Right now this is unused on the client, but having the raw uri - # available was useful on a previous version of the charm, so leaving - # this exposed just in case we need it in the future. - - -def _uri_to_fs_info(uri: str, model: Model) -> FilesystemInfo: - scheme = uri.split("://", maxsplit=1)[0] - if scheme == NfsInfo.filesystem_type(): - return NfsInfo.from_uri(uri, model) - elif scheme == CephfsInfo.filesystem_type(): - return CephfsInfo.from_uri(uri, model) - else: - raise FilesystemInfoError(f"unsupported filesystem type `{scheme}`") - - -class FilesystemEvent(RelationEvent): - """Base event for filesystem-related events.""" - - @property - def endpoint(self) -> Optional[Endpoint]: - """Get endpoint info.""" - if not (uri := self.relation.data[self.relation.app].get("endpoint")): - return - return Endpoint(_uri_to_fs_info(uri, self.framework.model), uri) - - -class MountFilesystemEvent(FilesystemEvent): - """Emit when a filesystem is ready to be mounted.""" - - -class UmountFilesystemEvent(FilesystemEvent): - """Emit when a filesystem needs to be unmounted.""" - - -class FilesystemRequiresEvents(CharmEvents): - """Events that FS servers can emit.""" - - mount_filesystem = EventSource(MountFilesystemEvent) - umount_filesystem = EventSource(UmountFilesystemEvent) - - -class _BaseInterface(Object): - """Base methods required for filesystem integration interfaces.""" - - def __init__(self, charm: CharmBase, relation_name) -> None: - super().__init__(charm, relation_name) - self.charm = charm - self.app = charm.model.app - self.unit = charm.unit - self.relation_name = relation_name - - @property - def relations(self) -> List[Relation]: - """Get list of active relations associated with the relation name.""" - result = [] - for relation in self.charm.model.relations[self.relation_name]: - try: - # Exclude relations that don't have any data yet - _ = repr(relation.data) - result.append(relation) - except RuntimeError: - continue - return result - - -class FilesystemRequires(_BaseInterface): - """Consumer-side interface of filesystem integrations.""" - - on = FilesystemRequiresEvents() - - def __init__(self, charm: CharmBase, relation_name: str) -> None: - super().__init__(charm, relation_name) - self.framework.observe(charm.on[relation_name].relation_changed, self._on_relation_changed) - self.framework.observe(charm.on[relation_name].relation_broken, self._on_relation_broken) - - def _on_relation_changed(self, event: RelationChangedEvent) -> None: - """Handle when the databag between client and server has been updated.""" - _logger.debug("emitting `MountFilesystem` event from `RelationChanged` hook") - self.on.mount_filesystem.emit(event.relation, app=event.app, unit=event.unit) - - def _on_relation_broken(self, event: RelationDepartedEvent) -> None: - """Handle when server departs integration.""" - _logger.debug("emitting `UmountFilesystem` event from `RelationDeparted` hook") - self.on.umount_filesystem.emit(event.relation, app=event.app, unit=event.unit) - - @property - def endpoints(self) -> List[Endpoint]: - """List of endpoints exposed by all the relations of this charm.""" - result = [] - for relation in self.relations: - if not (uri := relation.data[relation.app].get("endpoint")): - continue - result.append(Endpoint(info=_uri_to_fs_info(uri, self.model), uri=uri)) - return result - - -class FilesystemProvides(_BaseInterface): - """Provider-side interface of filesystem integrations.""" - - def __init__(self, charm: CharmBase, relation_name: str, peer_relation_name: str) -> None: - super().__init__(charm, relation_name) - self._peer_relation_name = peer_relation_name - self.framework.observe(charm.on[relation_name].relation_joined, self._update_relation) - - def set_info(self, info: FilesystemInfo) -> None: - """Set information to mount a filesystem. - - Args: - info: Information required to mount the filesystem. - - Notes: - Only the application leader unit can set the filesystem data. - """ - if not self.unit.is_leader(): - return - - uri = info.to_uri(self.model) - - self._endpoint = uri - - for relation in self.relations: - info.grant(self.model, relation) - relation.data[self.app]["endpoint"] = uri - - def _update_relation(self, event: RelationJoinedEvent) -> None: - if not self.unit.is_leader() or not (endpoint := self._endpoint): - return - - fs_info = _uri_to_fs_info(endpoint, self.model) - fs_info.grant(self.model, event.relation) - - event.relation.data[self.app]["endpoint"] = endpoint - - @property - def _peers(self) -> Optional[ops.Relation]: - """Fetch the peer relation.""" - return self.model.get_relation(self._peer_relation_name) - - @property - def _endpoint(self) -> str: - endpoint = self._get_state("endpoint") - return "" if endpoint is None else endpoint - - @_endpoint.setter - def _endpoint(self, endpoint: str) -> None: - self._set_state("endpoint", endpoint) - - def _get_state(self, key: str) -> Optional[str]: - """Get a value from the global state.""" - if not self._peers: - return None - - return self._peers.data[self.app].get(key) - - def _set_state(self, key: str, data: str) -> None: - """Insert a value into the global state.""" - if not self._peers: - raise FilesystemInfoError( - "peer relation can only be accessed after the relation is established" - ) - - self._peers.data[self.app][key] = data diff --git a/pyproject.toml b/pyproject.toml index 71008d7..324b6ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,9 @@ [project] -name = "filesystem-client" +name = "filesystems" version = "0.0" requires-python = "==3.12.*" dependencies = [ - "ops ~= 2.8" + "ops ~= 2.17" ] [project.optional-dependencies] @@ -20,10 +20,17 @@ dev = [ "ops[testing]", # Integration - "juju", - "pytest-operator", + "juju ~= 3.3", + "pylxd ~= 2.3", + "pytest ~= 7.2", + "pytest-operator ~= 0.34", + "pytest-order ~= 1.1", + "tenacity ~= 8.2", + "requests ~= 2.31.0", # TODO: track https://github.com/psf/requests/issues/6707 ] +[tool.uv.workspace] +members = ["charms/*"] # Testing tools configuration [tool.coverage.run] @@ -32,6 +39,9 @@ branch = true [tool.coverage.report] show_missing = true +[tool.coverage.paths] +source = ["charms", "*/_build"] + [tool.pytest.ini_options] minversion = "6.0" log_cli_level = "INFO" diff --git a/repository.py b/repository.py new file mode 100755 index 0000000..99ea857 --- /dev/null +++ b/repository.py @@ -0,0 +1,686 @@ +#!/usr/bin/env python + +# Taken from https://opendev.org/openstack/sunbeam-charms/src/commit/5b37e0a6919668f23b8c7b148717714889fd4381/repository.py + +"""CLI tool to execute an action on any charm managed by this repository.""" + +import argparse +import glob +import logging +import os +import pathlib +import shutil +import subprocess +import tomllib +import fnmatch +import sys +import io +from threading import Thread +from dataclasses import dataclass +from collections.abc import Iterable +from typing import Any + +import yaml + +ROOT_DIR = pathlib.Path(__file__).parent +BUILD_PATH = ROOT_DIR / "_build" +PYPROJECT_FILE = "pyproject.toml" +CHARMCRAFT_FILE = "charmcraft.yaml" +EXTERNAL_LIB_DIR = ROOT_DIR / "external" / "lib" +CURRENT_DIRECTORY = pathlib.Path(__file__).parent.resolve() + + +logger = logging.getLogger(__name__) +logging.basicConfig(level=logging.INFO) + + +class RepositoryError(Exception): + """Raise if the tool could not execute correctly.""" + + +############################################### +# Utility functions +############################################### +@dataclass(init=False) +class BuildTool: + path: str + + def __init__(self, tool: str) -> None: + if not (tool_path := shutil.which(tool)): + raise RepositoryError(f"Binary `{tool}` not installed or not in the PATH") + + logger.debug(f"Using {tool} from `{tool_path}`") + + self.path = tool_path + + def run_command(self, args: [str], *popenargs, **kwargs): + def reader(pipe): + with pipe: + for line in pipe: + line.replace( + str(CURRENT_DIRECTORY / "_build"), str(CURRENT_DIRECTORY / "charms") + ) + print(line, end="") + + kwargs["text"] = True + args.insert(0, self.path) + env = kwargs.pop("env", os.environ) + env["COLOR"] = "1" + with subprocess.Popen(args, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, **kwargs) as process: + Thread(target=reader, args=[process.stdout]).start() + Thread(target=reader, args=[process.stderr]).start() + return_code = process.wait() + + if return_code != 0: + raise subprocess.CalledProcessError(returncode=return_code, cmd=args) + + +UV = BuildTool("uv") +CHARMCRAFT = BuildTool("charmcraft") + + +@dataclass(init=False) +class Charm: + """Information used to build a charm.""" + + metadata: dict[str, Any] + path: pathlib.Path + internal_libraries: list[str] + templates: list[str] + + def __init__(self, charm: str) -> "Charm": + """Load this charm from a path.""" + path = ROOT_DIR / "charms" / charm + + try: + with (path / PYPROJECT_FILE).open(mode="rb") as f: + project = tomllib.load(f) + except OSError: + raise RepositoryError(f"Failed to read file `{path / PYPROJECT_FILE}`.") + + try: + with (path / CHARMCRAFT_FILE).open(mode="rb") as f: + metadata = dict(yaml.safe_load(f)) + except OSError: + raise RepositoryError(f"Failed to read file `{path / CHARMCRAFT_FILE}`.") + + try: + internal_libraries = project["tool"]["repository"]["internal-libraries"] + except KeyError: + internal_libraries = [] + + try: + templates = project["tool"]["repository"]["templates"] + except KeyError: + templates = [] + + self.path = path + self.internal_libraries = internal_libraries + self.templates = templates + self.metadata = metadata + + @property + def name(self) -> str: + """Get the name of the charm.""" + return str(self.path.name) + + @property + def build_path(self) -> pathlib.Path: + """Get the directory path that the staged charm must have on the output build directory.""" + return BUILD_PATH / self.path.name + + @property + def charm_path(self) -> pathlib.Path: + """Get the file path that the built charm must have on the output build directory.""" + return BUILD_PATH / f"{self.path.name}.charm" + + +def _library_to_path(library: str) -> pathlib.Path: + split = library.split(".") + if len(split) != 4: + raise RepositoryError(f"Invalid library: {library}") + return pathlib.Path("/".join(split) + ".py") + + +def validate_charm( + charm: str, + internal_libraries: dict[str, pathlib.Path], + templates: dict[str, pathlib.Path], +) -> Charm: + """Validate the charm.""" + charm_build = Charm(charm) + + for library in charm_build.internal_libraries: + if library not in internal_libraries: + raise RepositoryError( + f"Charm {charm} has invalid internal library: {library} not found." + ) + for template in charm_build.templates: + if template not in templates: + raise RepositoryError(f"Charm {charm} has invalid template: {template} not found.") + return charm_build + + +def load_internal_libraries() -> dict[str, pathlib.Path]: + """Load the internal libraries.""" + charms = list((ROOT_DIR / "charms").iterdir()) + libraries = {} + for charm in charms: + path = charm / "lib" + search_path = path / "charms" / charm.name.replace("-", "_") + libraries.update( + { + str(p.relative_to(path))[:-3].replace("/", "."): p + for p in search_path.glob("**/*.py") + } + ) + return libraries + + +def load_templates() -> dict[str, pathlib.Path]: + """Load the templates.""" + path = ROOT_DIR / "templates" + return {str(p.relative_to(path)): p for p in path.glob("**/*")} + + +def list_charms() -> list[str]: + """List the available charms.""" + return [p.name for p in (ROOT_DIR / "charms").iterdir() if p.is_dir()] + + +def copy(src: pathlib.Path, dest: pathlib.Path): + """Copy the src to dest. + + Only supports files. + """ + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(src, dest) + + +def remove_dir_if_exists(dir: pathlib.Path): + """Removes the directory `dir` if it exists and it's a directory.""" + try: + shutil.rmtree(dir) + except FileNotFoundError: + # Directory doesn't exist, so skip. + pass + + +def stage_charm( + charm: Charm, + internal_libraries: dict[str, pathlib.Path], + templates: dict[str, pathlib.Path], + dry_run: bool = False, +): + """Copy the necessary files. + + Will copy internal libraries and templates. + """ + logger.info(f"Staging charm {charm.path.name}.") + if not dry_run: + remove_dir_if_exists(charm.build_path) + shutil.copytree(charm.path, charm.build_path, dirs_exist_ok=True) + if charm.metadata.get("charm-libs"): + CHARMCRAFT.run_command(["fetch-libs"], cwd=charm.build_path) + + for library in charm.internal_libraries: + path = internal_libraries[library] + library_path = _library_to_path(library) + dest = charm.build_path / "lib" / library_path + if not dest.exists(): + logger.debug(f"Copying {library} to {dest}") + if dry_run: + continue + copy(path, dest) + for template in charm.templates: + path = templates[template] + dest = charm.build_path / "src" / "templates" / template + if not dest.exists(): + logger.debug(f"Copying {template} to {dest}") + if dry_run: + continue + copy(path, dest) + logger.info(f"Charm {charm.path.name} staged at {charm.build_path}.") + UV.run_command( + [ + "export", + "--package", + charm.name, + "--frozen", + "--no-hashes", + "--format=requirements-txt", + "-o", + str(charm.build_path / "requirements.txt"), + ] + ) + + +def clean_charm( + charm: Charm, + dry_run: bool = False, +): + """Clean charm directory.""" + logger.debug(f"Removing {charm.build_path}") + if not dry_run: + shutil.rmtree(charm.build_path, ignore_errors=True) + charm.charm_path.unlink(missing_ok=True) + + +def get_source_dirs(charms: [str], include_tests: bool = True) -> [str]: + """Get all the source directories for the specified charms.""" + charms_dir = ROOT_DIR / "charms" + files = [ + file + for charm in charms + for file in ( + str(charms_dir / charm / "src"), + str(charms_dir / charm / "tests") if include_tests else "", + ) + if file + ] + return files + +def pythonpath(internal_libraries: dict[str, pathlib.Path]) -> str: + """Get the PYTHONPATH of the project.""" + parent_dirs = set() + for path in internal_libraries.values(): + parent_dirs.add(path.parents[3]) + return ":".join(str(p) for p in parent_dirs) + + +def uv_run(args: [str], *popenargs, **kwargs) -> str: + """Run a command using the uv runner.""" + args = ["run", "--frozen", "--extra", "dev"] + args + return UV.run_command(args, *popenargs, **kwargs) + + +############################################### +# Cli Definitions +############################################### +def _add_charm_argument(parser: argparse.ArgumentParser): + parser.add_argument("charm", type=str, nargs="*", help="The charm to operate on.") + + +def main_cli(): + """Run the main CLI tool.""" + main_parser = argparse.ArgumentParser(description="Repository utilities.") + main_parser.add_argument( + "-v", "--verbose", action="store_true", help="Enable verbose logging." + ) + subparsers = main_parser.add_subparsers(required=True, help="sub-command help") + + stage_parser = subparsers.add_parser("stage", help="Stage charm(s).") + _add_charm_argument(stage_parser) + stage_parser.add_argument( + "--clean", + action="store_true", + default=False, + help="Clean the charm(s) first.", + ) + stage_parser.add_argument("--dry-run", action="store_true", default=False, help="Dry run.") + stage_parser.set_defaults(func=stage_cli) + + gen_token_parser = subparsers.add_parser( + "generate-token", help="Generate Charmhub token to publish charms." + ) + gen_token_parser.set_defaults(func=gen_token_cli) + + clean_parser = subparsers.add_parser("clean", help="Clean charm(s).") + _add_charm_argument(clean_parser) + clean_parser.add_argument("--dry-run", action="store_true", default=False, help="Dry run.") + clean_parser.set_defaults(func=clean_cli) + + validate_parser = subparsers.add_parser("validate", help="Validate charm(s).") + _add_charm_argument(validate_parser) + validate_parser.set_defaults(func=validate_cli) + + pythonpath_parser = subparsers.add_parser("pythonpath", help="Print the pythonpath.") + pythonpath_parser.set_defaults(func=pythonpath_cli) + + fmt_parser = subparsers.add_parser("fmt", help="Apply formatting standards to code.") + fmt_parser.set_defaults(func=fmt_cli) + + lint_parser = subparsers.add_parser("lint", help="Check code against coding style standards") + lint_parser.add_argument( + "--fix", action="store_true", default=False, help="Try to fix the lint err ors" + ) + lint_parser.set_defaults(func=lint_cli) + + type_parser = subparsers.add_parser("typecheck", help="Type checking with pyright.") + _add_charm_argument(type_parser) + type_parser.set_defaults(func=typecheck_cli) + + unit_test_parser = subparsers.add_parser("unit", help="Run unit tests.") + _add_charm_argument(unit_test_parser) + unit_test_parser.set_defaults(func=unit_test_cli) + + build_parser = subparsers.add_parser("build", help="Build all the specified charms.") + _add_charm_argument(build_parser) + build_parser.set_defaults(func=build_cli) + + integration_test_parser = subparsers.add_parser("integration", help="Run integration tests.") + integration_test_parser.add_argument( + "rest", type=str, nargs="*", help="Arguments forwarded to pytest" + ) + _add_charm_argument(integration_test_parser) + integration_test_parser.set_defaults(func=integration_tests_cli) + + fetch_libs_parser = subparsers.add_parser("fetch-libs", help="Fetch external charm libraries.") + fetch_libs_parser.add_argument( + "rest", type=str, nargs="*", help="Arguments forwarded to charmcraft fetch-libs" + ) + _add_charm_argument(fetch_libs_parser) + fetch_libs_parser.set_defaults(func=fetch_libs_cli) + + args = main_parser.parse_args() + level = logging.INFO + if args.verbose: + level = logging.DEBUG + logger.setLevel(level) + context = vars(args) + context["internal_libraries"] = load_internal_libraries() + context["templates"] = load_templates() + context["charms"] = list_charms() + if "charm" in context: + charms = context.pop("charm") + if not charms: + charms = context["charms"] + context["charms"] = [ + validate_charm( + charm, + context["internal_libraries"], + context["templates"], + ) + for charm in charms + ] + args.func(**context) + + +def stage_cli( + charms: list[Charm], + internal_libraries: dict[str, pathlib.Path], + templates: dict[str, pathlib.Path], + clean: bool = False, + dry_run: bool = False, + **kwargs, +): + """Stage the specified charms into the build directory.""" + for charm in charms: + logger.info("Preparing the charm %s", charm.path.name) + if clean: + clean_charm(charm, dry_run=dry_run) + stage_charm( + charm, + internal_libraries, + templates, + dry_run=dry_run, + ) + + +def gen_token_cli( + charms: [str], + **kwargs, +): + """Generate Charmhub token to publish charms.""" + CHARMCRAFT.run_command( + ["login", "--export=.charmhub.secret"] + + [f"--charm={charm}" for charm in charms] + + [ + "--permission=package-manage-metadata", + "--permission=package-manage-releases", + "--permission=package-manage-revisions", + "--permission=package-view-metadata", + "--permission=package-view-releases", + "--permission=package-view-revisions", + "--ttl=31536000", # 365 days + ] + ) + + +def clean_cli( + charms: list[Charm], + internal_libraries: dict[str, pathlib.Path], + templates: dict[str, pathlib.Path], + dry_run: bool = False, + **kwargs, +): + """Clean all the build artifacts for the specified charms.""" + for charm in charms: + logger.info("Cleaning the charm %s", charm.path.name) + clean_charm(charm, dry_run=dry_run) + if not dry_run: + try: + BUILD_PATH.rmdir() + logger.info(f"Deleted empty build directory {BUILD_PATH}") + except OSError as e: + # ENOENT (2) - No such file or directory + # ENOEMPTY (39) - Directory not empty + if e.errno != 39 and e.errno != 2: + raise e + + +def validate_cli( + charms: list[Charm], + internal_libraries: dict[str, pathlib.Path], + templates: dict[str, pathlib.Path], + **kwargs, +): + """Validate all the specified charms. + + Currently a no op because this is done in the main_cli. + """ + for charm in charms: + logging.info("Charm %s is valid.", charm.path.name) + + +def pythonpath_cli(internal_libraries: dict[str, pathlib.Path], **kwargs): + """Print the pythonpath.""" + + parent_dirs = set() + for path in internal_libraries.values(): + parent_dirs.add(path.parents[3]) + print(":".join(str(p) for p in parent_dirs)) + + +def fmt_cli( + charms: [str], + **kwargs, +): + """Apply formatting standards to code.""" + files = get_source_dirs(charms) + files.append(str(ROOT_DIR / "tests")) + logging.info(f"Formatting directories {files} with ruff...") + uv_run(["ruff", "format"] + files, cwd=ROOT_DIR) + + +def lint_cli( + charms: [str], + fix: bool, + **kwargs, +): + """Check code against coding style standards.""" + files = get_source_dirs(charms) + files.append(str(ROOT_DIR / "tests")) + logging.info("Target directories: {files}") + if fix: + logging.info("Trying to automatically fix the lint errors.") + logging.info("Running codespell...") + uv_run(["codespell"] + (["-w"] if fix else []) + files, cwd=ROOT_DIR) + logging.info("Running ruff...") + uv_run(["ruff", "check"] + (["--fix"] if fix else []) + files, cwd=ROOT_DIR) + + +def typecheck_cli( + charms: [Charm], + internal_libraries: dict[str, pathlib.Path], + templates: dict[str, pathlib.Path], + **kwargs, +): + """Type checking with pyright.""" + for charm in charms: + logger.info("Staging the charm %s", charm.path.name) + stage_charm( + charm, + internal_libraries, + templates, + dry_run=False, + ) + logger.info("Running pyright...") + uv_run( + ["pyright", str(charm.build_path / "src")], + env={**os.environ, "PYTHONPATH": f"{charm.build_path}/src:{charm.build_path}/lib"}, + ) + + +def unit_test_cli( + charms: [Charm], + internal_libraries: dict[str, pathlib.Path], + templates: dict[str, pathlib.Path], + **kwargs, +): + """Run unit tests.""" + UV.run_command(["lock"]) + uv_run(["coverage", "erase"]) + + files = [] + + for charm in charms: + logger.info("Staging the charm %s", charm.path.name) + stage_charm( + charm, + internal_libraries, + templates, + dry_run=False, + ) + logger.info("Running unit tests for %s", charm.path.name) + coverage_file = charm.build_path / ".coverage" + uv_run(["coverage", "erase"], env={**os.environ, "COVERAGE_FILE": str(coverage_file)}) + uv_run( + [ + "coverage", + "run", + "--source", + str(charm.build_path / "src"), + "-m", + "pytest", + "-v", + "--tb", + "native", + "-s", + str(charm.build_path / "tests" / "unit"), + ], + env={ + **os.environ, + "PYTHONPATH": f"{charm.build_path}/src:{charm.build_path}/lib", + "COVERAGE_FILE": str(coverage_file), + }, + ) + if coverage_file.is_file(): + files.append(str(coverage_file)) + + logger.info("Generating global results...") + uv_run(["coverage", "combine"] + files) + uv_run(["coverage", "report"]) + uv_run(["coverage", "xml", "-o", "cover/coverage.xml"]) + logger.info(f"XML report generated at {ROOT_DIR}/cover/coverage.xml") + + +def build_cli( + charms: [Charm], + internal_libraries: dict[str, pathlib.Path], + templates: dict[str, pathlib.Path], + **kwargs, +): + """Build all the specified charms.""" + UV.run_command(["lock"]) + + for charm in charms: + logger.info("Staging the charm %s", charm.name) + stage_charm( + charm, + internal_libraries, + templates, + dry_run=False, + ) + logger.info("Building the charm %s", charm.name) + subprocess.run( + "charmcraft -v pack".split(), + cwd=charm.build_path, + check=True, + ) + + charm_long_path = ( + charm.build_path + / glob.glob(f"{charm.path.name}_*.charm", root_dir=charm.build_path)[0] + ) + logger.info("Moving charm %s to %s", charm_long_path, charm.charm_path) + + charm.charm_path.unlink(missing_ok=True) + copy(charm_long_path, charm.charm_path) + charm_long_path.unlink() + logger.info("Built charm %s", charm.charm_path) + + +def integration_tests_cli( + charms: [Charm], + internal_libraries: dict[str, pathlib.Path], + templates: dict[str, pathlib.Path], + rest: [str], + **kwargs, +): + """Run integration tests.""" + local_charms = {} + path = pythonpath(internal_libraries=internal_libraries) + + fetch_libs_cli(charms=charms, internal_libraries=internal_libraries, templates=templates, rest=rest, **kwargs) + + for charm in charms: + local_charms[f"{charm.name.upper().replace("-", "_")}_DIR"] = charm.build_path + + uv_run( + ["pytest", "-v", "-s", "--tb", "native", "--log-cli-level=INFO", "./tests/integration"] + + rest, + env={"PYTHONPATH": path, **os.environ, **local_charms}, + ) + + +def fetch_libs_cli( + charms: [Charm], + internal_libraries: dict[str, pathlib.Path], + templates: dict[str, pathlib.Path], + rest: [str], + **kwargs, +): + """Fetch external charm libraries.""" + patterns = [f"{lib.replace('.', '/')}.py" for lib in internal_libraries.keys()] + + def ignore_internal_libs(dir: str, items: [str]) -> Iterable[str]: + ignored = [] + for item in items: + path = pathlib.Path(dir) / item + for pattern in patterns: + if path.match(pattern): + ignored.append(item) + break + return ignored + + remove_dir_if_exists(EXTERNAL_LIB_DIR) + for charm in charms: + stage_charm(charm, internal_libraries, templates) + shutil.copytree( + charm.build_path / "lib", + EXTERNAL_LIB_DIR, + dirs_exist_ok=True, + ignore=ignore_internal_libs, + ) + for dirpath, dirnames, filenames in EXTERNAL_LIB_DIR.walk(top_down=False): + for dirname in dirnames: + try: + (dirpath / dirname).rmdir() + except OSError as e: + if e.errno != 39: # Directory not empty + raise e + + +if __name__ == "__main__": + main_cli() diff --git a/src/charm.py b/src/charm.py deleted file mode 100755 index 648ac34..0000000 --- a/src/charm.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Charm for the filesystem client.""" - -import logging -from dataclasses import dataclass - -import ops -from charms.filesystem_client.v0.filesystem_info import FilesystemRequires - -from utils.manager import MountsManager - -_logger = logging.getLogger(__name__) - - -class StopCharmError(Exception): - """Exception raised when a method needs to finish the execution of the charm code.""" - - def __init__(self, status: ops.StatusBase) -> None: - self.status = status - - -@dataclass(frozen=True) -class CharmConfig: - """Configuration for the charm.""" - - mountpoint: str - """Location to mount the filesystem on the machine.""" - noexec: bool - """Block execution of binaries on the filesystem.""" - nosuid: bool - """Do not honor suid and sgid bits on the filesystem.""" - nodev: bool - """Blocking interpretation of character and/or block devices on the filesystem.""" - read_only: bool - """Mount filesystem as read-only.""" - - -# Trying to use a delta charm (one method per event) proved to be a bit unwieldy, since -# we would have to handle multiple updates at once: -# - mount requests -# - umount requests -# - config changes -# -# Additionally, we would need to wait until the correct configuration -# was provided, so we would have to somehow keep track of the pending -# mount requests. -# -# A holistic charm (one method for all events) was a lot easier to deal with, -# simplifying the code to handle all the events. -class FilesystemClientCharm(ops.CharmBase): - """Charm the application.""" - - def __init__(self, framework: ops.Framework) -> None: - super().__init__(framework) - - self._filesystems = FilesystemRequires(self, "filesystem") - self._mounts_manager = MountsManager(self) - framework.observe(self.on.upgrade_charm, self._handle_event) - framework.observe(self.on.update_status, self._handle_event) - framework.observe(self.on.config_changed, self._handle_event) - framework.observe(self._filesystems.on.mount_filesystem, self._handle_event) - framework.observe(self._filesystems.on.umount_filesystem, self._handle_event) - - def _handle_event(self, event: ops.EventBase) -> None: - """Handle a Juju event.""" - try: - self.unit.status = ops.MaintenanceStatus("Updating status.") - - # CephFS is not supported on LXD containers. - if not self._mounts_manager.supported(): - self.unit.status = ops.BlockedStatus("Cannot mount filesystems on LXD containers.") - return - - self._ensure_installed() - config = self._get_config() - self._mount_filesystems(config) - except StopCharmError as e: - # This was the cleanest way to ensure the inner methods can still return prematurely - # when an error occurs. - self.unit.status = e.status - return - - self.unit.status = ops.ActiveStatus(f"Mounted filesystem at `{config.mountpoint}`.") - - def _ensure_installed(self) -> None: - """Ensure the required packages are installed into the unit.""" - if not self._mounts_manager.installed: - self.unit.status = ops.MaintenanceStatus("Installing required packages.") - self._mounts_manager.install() - - def _get_config(self) -> CharmConfig: - """Get and validate the configuration of the charm.""" - if not (mountpoint := self.config.get("mountpoint")): - raise StopCharmError(ops.BlockedStatus("Missing `mountpoint` in config.")) - - return CharmConfig( - mountpoint=str(mountpoint), - noexec=bool(self.config.get("noexec")), - nosuid=bool(self.config.get("nosuid")), - nodev=bool(self.config.get("nodev")), - read_only=bool(self.config.get("read-only")), - ) - - def _mount_filesystems(self, config: CharmConfig) -> None: - """Mount the filesystem for the charm.""" - endpoints = self._filesystems.endpoints - if not endpoints: - raise StopCharmError( - ops.BlockedStatus("Waiting for an integration with a filesystem provider.") - ) - - # This is limited to 1 relation. - endpoint = endpoints[0] - - self.unit.status = ops.MaintenanceStatus("Mounting filesystem.") - - with self._mounts_manager.mounts() as mounts: - opts = [] - opts.append("noexec" if config.noexec else "exec") - opts.append("nosuid" if config.nosuid else "suid") - opts.append("nodev" if config.nodev else "dev") - opts.append("ro" if config.read_only else "rw") - mounts.add(info=endpoint.info, mountpoint=config.mountpoint, options=opts) - - -if __name__ == "__main__": # pragma: nocover - ops.main(FilesystemClientCharm) # type: ignore diff --git a/src/utils/__init__.py b/src/utils/__init__.py deleted file mode 100644 index 97fd359..0000000 --- a/src/utils/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Internal utilities used through Storage client operator.""" diff --git a/src/utils/manager.py b/src/utils/manager.py deleted file mode 100644 index a542b2d..0000000 --- a/src/utils/manager.py +++ /dev/null @@ -1,223 +0,0 @@ -# Copyright 2024 Canonical Ltd. -# See LICENSE file for licensing details. - -"""Manage machine mounts and dependencies.""" - -import contextlib -import logging -import os -import pathlib -import subprocess -from collections.abc import Iterable -from dataclasses import dataclass -from ipaddress import AddressValueError, IPv6Address - -import charms.operator_libs_linux.v0.apt as apt -import charms.operator_libs_linux.v1.systemd as systemd -import ops -from charms.filesystem_client.v0.filesystem_info import CephfsInfo, FilesystemInfo, NfsInfo - -_logger = logging.getLogger(__name__) - - -class Error(Exception): - """Raise if Storage client manager encounters an error.""" - - @property - def name(self) -> str: - """Get a string representation of the error plus class name.""" - return f"<{type(self).__module__}.{type(self).__name__}>" - - @property - def message(self) -> str: - """Return the message passed as an argument.""" - return self.args[0] - - def __repr__(self) -> str: - """Return the string representation of the error.""" - return f"<{type(self).__module__}.{type(self).__name__} {self.args}>" - - -@dataclass(frozen=True) -class MountInfo: - """Mount information. - - Notes: - See `man fstab` for description of field types. - """ - - endpoint: str - mountpoint: str - fstype: str - options: str - freq: str - passno: str - - -@dataclass -class _MountInfo: - endpoint: str - options: [str] - - -class Mounts: - """Collection of mounts that need to be managed by the `MountsManager`.""" - - def __init__(self) -> None: - self._mounts: dict[str, _MountInfo] = {} - - def add( - self, - info: FilesystemInfo, - mountpoint: str | os.PathLike, - options: list[str] | None = None, - ) -> None: - """Add a mount to the list of managed mounts. - - Args: - info: Share information required to mount the share. - mountpoint: System location to mount the share. - options: Mount options to pass when mounting the share. - - Raises: - Error: Raised if the mount operation fails. - """ - if options is None: - options = [] - - endpoint, additional_opts = _get_endpoint_and_opts(info) - options = sorted(options + additional_opts) - - self._mounts[str(mountpoint)] = _MountInfo(endpoint=endpoint, options=options) - - -class MountsManager: - """Manager for mounted filesystems in the current system.""" - - def __init__(self, charm: ops.CharmBase) -> None: - # Lazily initialized - self._pkgs = None - self._master_file = pathlib.Path(f"/etc/auto.master.d/{charm.app.name}.autofs") - self._autofs_file = pathlib.Path(f"/etc/auto.{charm.app.name}") - - @property - def _packages(self) -> list[apt.DebianPackage]: - """List of packages required by the client.""" - if not self._pkgs: - self._pkgs = [ - apt.DebianPackage.from_system(pkg) - for pkg in ["ceph-common", "nfs-common", "autofs"] - ] - return self._pkgs - - @property - def installed(self) -> bool: - """Check if the required packages are installed.""" - for pkg in self._packages: - if not pkg.present: - return False - - if not self._master_file.exists() or not self._autofs_file.exists(): - return False - - return True - - def install(self) -> None: - """Install the required mount packages. - - Raises: - Error: Raised if this failed to change the state of any of the required packages. - """ - try: - for pkg in self._packages: - pkg.ensure(apt.PackageState.Present) - except (apt.PackageError, apt.PackageNotFoundError) as e: - _logger.error("failed to change the state of the required packages", exc_info=e) - raise Error(e.message) - - try: - self._master_file.touch(mode=0o600) - self._autofs_file.touch(mode=0o600) - self._master_file.write_text(f"/- {self._autofs_file}") - except IOError as e: - _logger.error("failed to create the required autofs files", exc_info=e) - raise Error("failed to create the required autofs files") - - def supported(self) -> bool: - """Check if underlying base supports mounting shares.""" - try: - result = subprocess.run( - ["systemd-detect-virt"], stdout=subprocess.PIPE, check=True, text=True - ) - if "lxc" in result.stdout: - # Cannot mount shares inside LXD containers. - return False - else: - return True - except subprocess.CalledProcessError: - _logger.warning("could not detect execution in virtualized environment") - return True - - @contextlib.contextmanager - def mounts(self, force_mount=False) -> Iterable[Mounts]: - """Get the list of `Mounts` that need to be managed by the `MountsManager`. - - It will initially contain no mounts, and any mount that is added to - `Mounts` will be mounted by the manager. Mounts that were - added on previous executions will get removed if they're not added again - to the `Mounts` object. - """ - mounts = Mounts() - yield mounts - # This will not resume if the caller raised an exception, which - # should be enough to ensure the file is not written if the charm entered - # an error state. - new_autofs = "\n".join( - ( - f"{mountpoint} -{','.join(info.options)} {info.endpoint}" - for mountpoint, info in sorted(mounts._mounts.items()) - ) - ) - - old_autofs = self._autofs_file.read_text() - - # Avoid restarting autofs if the config didn't change. - if not force_mount and new_autofs == old_autofs: - return - - try: - for mount in mounts._mounts.keys(): - pathlib.Path(mount).mkdir(parents=True, exist_ok=True) - self._autofs_file.write_text(new_autofs) - systemd.service_reload("autofs", restart_on_failure=True) - except systemd.SystemdError as e: - _logger.error("failed to mount filesystems", exc_info=e) - raise Error("failed to mount filesystems") - - -def _get_endpoint_and_opts(info: FilesystemInfo) -> tuple[str, [str]]: - match info: - case NfsInfo(hostname=hostname, port=port, path=path): - try: - IPv6Address(hostname) - # Need to add brackets if the hostname is IPv6 - hostname = f"[{hostname}]" - except AddressValueError: - pass - - endpoint = f"{hostname}:{path}" - options = [f"port={port}"] if port else [] - case CephfsInfo( - fsid=fsid, name=name, path=path, monitor_hosts=mons, user=user, key=secret - ): - mon_addr = "/".join(mons) - endpoint = f"{user}@{fsid}.{name}={path}" - options = [ - "fstype=ceph", - f"mon_addr={mon_addr}", - f"secret={secret}", - ] - case _: - raise Error(f"unsupported filesystem type `{info.filesystem_type()}`") - - return endpoint, options diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index b38c577..2a4e272 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -100,7 +100,7 @@ def bootstrap_nfs_server() -> NfsInfo: ["systemctl", "restart", "nfs-kernel-server"], ], ) - for i in ["1", "2", "3"]: + for i in [1, 2, 3]: _exec_command(instance, ["touch", f"/data/test-{i}"]) address = instance.state().network["enp5s0"]["addresses"][0]["address"] _logger.info(f"NFS share endpoint is nfs://{address}/data") diff --git a/uv.lock b/uv.lock index 7e10692..30732e5 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,14 @@ version = 1 requires-python = "==3.12.*" +[manifest] +members = [ + "cephfs-server-proxy", + "filesystem-client", + "filesystems", + "nfs-server-proxy", +] + [[package]] name = "asttokens" version = "3.0.0" @@ -49,6 +57,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/07/14f8ad37f2d12a5ce41206c21820d8cb6561b728e51fad4530dff0552a67/cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292", size = 9524 }, ] +[[package]] +name = "cephfs-server-proxy" +version = "0.0" +source = { virtual = "charms/cephfs-server-proxy" } +dependencies = [ + { name = "ops" }, +] + +[package.metadata] +requires-dist = [{ name = "ops" }] + [[package]] name = "certifi" version = "2024.12.14" @@ -82,26 +101,24 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.0" +version = "3.4.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/0b/4b7a70987abf9b8196845806198975b6aab4ce016632f817ad758a5aa056/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6", size = 194445 }, - { url = "https://files.pythonhosted.org/packages/50/89/354cc56cf4dd2449715bc9a0f54f3aef3dc700d2d62d1fa5bbea53b13426/charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf", size = 125275 }, - { url = "https://files.pythonhosted.org/packages/fa/44/b730e2a2580110ced837ac083d8ad222343c96bb6b66e9e4e706e4d0b6df/charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db", size = 119020 }, - { url = "https://files.pythonhosted.org/packages/9d/e4/9263b8240ed9472a2ae7ddc3e516e71ef46617fe40eaa51221ccd4ad9a27/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1", size = 139128 }, - { url = "https://files.pythonhosted.org/packages/6b/e3/9f73e779315a54334240353eaea75854a9a690f3f580e4bd85d977cb2204/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03", size = 149277 }, - { url = "https://files.pythonhosted.org/packages/1a/cf/f1f50c2f295312edb8a548d3fa56a5c923b146cd3f24114d5adb7e7be558/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284", size = 142174 }, - { url = "https://files.pythonhosted.org/packages/16/92/92a76dc2ff3a12e69ba94e7e05168d37d0345fa08c87e1fe24d0c2a42223/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15", size = 143838 }, - { url = "https://files.pythonhosted.org/packages/a4/01/2117ff2b1dfc61695daf2babe4a874bca328489afa85952440b59819e9d7/charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8", size = 146149 }, - { url = "https://files.pythonhosted.org/packages/f6/9b/93a332b8d25b347f6839ca0a61b7f0287b0930216994e8bf67a75d050255/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2", size = 140043 }, - { url = "https://files.pythonhosted.org/packages/ab/f6/7ac4a01adcdecbc7a7587767c776d53d369b8b971382b91211489535acf0/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719", size = 148229 }, - { url = "https://files.pythonhosted.org/packages/9d/be/5708ad18161dee7dc6a0f7e6cf3a88ea6279c3e8484844c0590e50e803ef/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631", size = 151556 }, - { url = "https://files.pythonhosted.org/packages/5a/bb/3d8bc22bacb9eb89785e83e6723f9888265f3a0de3b9ce724d66bd49884e/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b", size = 149772 }, - { url = "https://files.pythonhosted.org/packages/f7/fa/d3fc622de05a86f30beea5fc4e9ac46aead4731e73fd9055496732bcc0a4/charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565", size = 144800 }, - { url = "https://files.pythonhosted.org/packages/9a/65/bdb9bc496d7d190d725e96816e20e2ae3a6fa42a5cac99c3c3d6ff884118/charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7", size = 94836 }, - { url = "https://files.pythonhosted.org/packages/3e/67/7b72b69d25b89c0b3cea583ee372c43aa24df15f0e0f8d3982c57804984b/charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9", size = 102187 }, - { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, ] [[package]] @@ -124,20 +141,20 @@ wheels = [ [[package]] name = "coverage" -version = "7.6.9" +version = "7.6.10" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5b/d2/c25011f4d036cf7e8acbbee07a8e09e9018390aee25ba085596c4b83d510/coverage-7.6.9.tar.gz", hash = "sha256:4a8d8977b0c6ef5aeadcb644da9e69ae0dcfe66ec7f368c89c72e058bd71164d", size = 801710 } +sdist = { url = "https://files.pythonhosted.org/packages/84/ba/ac14d281f80aab516275012e8875991bb06203957aa1e19950139238d658/coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23", size = 803868 } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/52/b16af8989a2daf0f80a88522bd8e8eed90b5fcbdecf02a6888f3e80f6ba7/coverage-7.6.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:99e266ae0b5d15f1ca8d278a668df6f51cc4b854513daab5cae695ed7b721cf8", size = 207325 }, - { url = "https://files.pythonhosted.org/packages/0f/79/6b7826fca8846c1216a113227b9f114ac3e6eacf168b4adcad0cb974aaca/coverage-7.6.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9901d36492009a0a9b94b20e52ebfc8453bf49bb2b27bca2c9706f8b4f5a554a", size = 207563 }, - { url = "https://files.pythonhosted.org/packages/a7/07/0bc73da0ccaf45d0d64ef86d33b7d7fdeef84b4c44bf6b85fb12c215c5a6/coverage-7.6.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abd3e72dd5b97e3af4246cdada7738ef0e608168de952b837b8dd7e90341f015", size = 240580 }, - { url = "https://files.pythonhosted.org/packages/71/8a/9761f409910961647d892454687cedbaccb99aae828f49486734a82ede6e/coverage-7.6.9-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff74026a461eb0660366fb01c650c1d00f833a086b336bdad7ab00cc952072b3", size = 237613 }, - { url = "https://files.pythonhosted.org/packages/8b/10/ee7d696a17ac94f32f2dbda1e17e730bf798ae9931aec1fc01c1944cd4de/coverage-7.6.9-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65dad5a248823a4996724a88eb51d4b31587aa7aa428562dbe459c684e5787ae", size = 239684 }, - { url = "https://files.pythonhosted.org/packages/16/60/aa1066040d3c52fff051243c2d6ccda264da72dc6d199d047624d395b2b2/coverage-7.6.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22be16571504c9ccea919fcedb459d5ab20d41172056206eb2994e2ff06118a4", size = 239112 }, - { url = "https://files.pythonhosted.org/packages/4e/e5/69f35344c6f932ba9028bf168d14a79fedb0dd4849b796d43c81ce75a3c9/coverage-7.6.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f957943bc718b87144ecaee70762bc2bc3f1a7a53c7b861103546d3a403f0a6", size = 237428 }, - { url = "https://files.pythonhosted.org/packages/32/20/adc895523c4a28f63441b8ac645abd74f9bdd499d2d175bef5b41fc7f92d/coverage-7.6.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ae1387db4aecb1f485fb70a6c0148c6cdaebb6038f1d40089b1fc84a5db556f", size = 239098 }, - { url = "https://files.pythonhosted.org/packages/a9/a6/e0e74230c9bb3549ec8ffc137cfd16ea5d56e993d6bffed2218bff6187e3/coverage-7.6.9-cp312-cp312-win32.whl", hash = "sha256:1a330812d9cc7ac2182586f6d41b4d0fadf9be9049f350e0efb275c8ee8eb692", size = 209940 }, - { url = "https://files.pythonhosted.org/packages/3e/18/cb5b88349d4aa2f41ec78d65f92ea32572b30b3f55bc2b70e87578b8f434/coverage-7.6.9-cp312-cp312-win_amd64.whl", hash = "sha256:b12c6b18269ca471eedd41c1b6a1065b2f7827508edb9a7ed5555e9a56dcfc97", size = 210726 }, + { url = "https://files.pythonhosted.org/packages/86/77/19d09ea06f92fdf0487499283b1b7af06bc422ea94534c8fe3a4cd023641/coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853", size = 208281 }, + { url = "https://files.pythonhosted.org/packages/b6/67/5479b9f2f99fcfb49c0d5cf61912a5255ef80b6e80a3cddba39c38146cf4/coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078", size = 208514 }, + { url = "https://files.pythonhosted.org/packages/15/d1/febf59030ce1c83b7331c3546d7317e5120c5966471727aa7ac157729c4b/coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0", size = 241537 }, + { url = "https://files.pythonhosted.org/packages/4b/7e/5ac4c90192130e7cf8b63153fe620c8bfd9068f89a6d9b5f26f1550f7a26/coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50", size = 238572 }, + { url = "https://files.pythonhosted.org/packages/dc/03/0334a79b26ecf59958f2fe9dd1f5ab3e2f88db876f5071933de39af09647/coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022", size = 240639 }, + { url = "https://files.pythonhosted.org/packages/d7/45/8a707f23c202208d7b286d78ad6233f50dcf929319b664b6cc18a03c1aae/coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b", size = 240072 }, + { url = "https://files.pythonhosted.org/packages/66/02/603ce0ac2d02bc7b393279ef618940b4a0535b0868ee791140bda9ecfa40/coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0", size = 238386 }, + { url = "https://files.pythonhosted.org/packages/04/62/4e6887e9be060f5d18f1dd58c2838b2d9646faf353232dec4e2d4b1c8644/coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852", size = 240054 }, + { url = "https://files.pythonhosted.org/packages/5c/74/83ae4151c170d8bd071924f212add22a0e62a7fe2b149edf016aeecad17c/coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359", size = 210904 }, + { url = "https://files.pythonhosted.org/packages/c3/54/de0893186a221478f5880283119fc40483bc460b27c4c71d1b8bba3474b9/coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247", size = 211692 }, ] [[package]] @@ -192,6 +209,17 @@ wheels = [ [[package]] name = "filesystem-client" version = "0.0" +source = { virtual = "charms/filesystem-client" } +dependencies = [ + { name = "ops" }, +] + +[package.metadata] +requires-dist = [{ name = "ops" }] + +[[package]] +name = "filesystems" +version = "0.0" source = { virtual = "." } dependencies = [ { name = "ops" }, @@ -203,25 +231,33 @@ dev = [ { name = "coverage" }, { name = "juju" }, { name = "ops", extra = ["testing"] }, + { name = "pylxd" }, { name = "pyright" }, { name = "pytest" }, { name = "pytest-operator" }, { name = "pytest-order" }, + { name = "requests" }, { name = "ruff" }, + { name = "tenacity" }, ] [package.metadata] requires-dist = [ { name = "codespell", marker = "extra == 'dev'" }, { name = "coverage", extras = ["toml"], marker = "extra == 'dev'" }, - { name = "juju", marker = "extra == 'dev'" }, - { name = "ops", specifier = "~=2.8" }, + { name = "juju", marker = "extra == 'dev'", specifier = "~=3.3" }, + { name = "ops", specifier = "~=2.17" }, { name = "ops", extras = ["testing"], marker = "extra == 'dev'" }, + { name = "pylxd", marker = "extra == 'dev'", specifier = "~=2.3" }, { name = "pyright", marker = "extra == 'dev'" }, { name = "pytest", marker = "extra == 'dev'" }, - { name = "pytest-operator", marker = "extra == 'dev'" }, + { name = "pytest", marker = "extra == 'dev'", specifier = "~=7.2" }, + { name = "pytest-operator", marker = "extra == 'dev'", specifier = "~=0.34" }, { name = "pytest-order", marker = "extra == 'dev'" }, + { name = "pytest-order", marker = "extra == 'dev'", specifier = "~=1.1" }, + { name = "requests", marker = "extra == 'dev'", specifier = "~=2.31.0" }, { name = "ruff", marker = "extra == 'dev'" }, + { name = "tenacity", marker = "extra == 'dev'", specifier = "~=8.2" }, ] [[package]] @@ -283,7 +319,7 @@ wheels = [ [[package]] name = "ipython" -version = "8.30.0" +version = "8.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -296,9 +332,9 @@ dependencies = [ { name = "stack-data" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d8/8b/710af065ab8ed05649afa5bd1e07401637c9ec9fb7cfda9eac7e91e9fbd4/ipython-8.30.0.tar.gz", hash = "sha256:cb0a405a306d2995a5cbb9901894d240784a9f341394c6ba3f4fe8c6eb89ff6e", size = 5592205 } +sdist = { url = "https://files.pythonhosted.org/packages/01/35/6f90fdddff7a08b7b715fccbd2427b5212c9525cd043d26fdc45bee0708d/ipython-8.31.0.tar.gz", hash = "sha256:b6a2274606bec6166405ff05e54932ed6e5cfecaca1fc05f2cacde7bb074d70b", size = 5501011 } wheels = [ - { url = "https://files.pythonhosted.org/packages/1d/f3/1332ba2f682b07b304ad34cad2f003adcfeb349486103f4b632335074a7c/ipython-8.30.0-py3-none-any.whl", hash = "sha256:85ec56a7e20f6c38fce7727dcca699ae4ffc85985aa7b23635a8008f918ae321", size = 820765 }, + { url = "https://files.pythonhosted.org/packages/04/60/d0feb6b6d9fe4ab89fe8fe5b47cbf6cd936bfd9f1e7ffa9d0015425aeed6/ipython-8.31.0-py3-none-any.whl", hash = "sha256:46ec58f8d3d076a61d128fe517a51eb730e3aaf0c184ea8c17d16e366660c6a6", size = 821583 }, ] [[package]] @@ -315,19 +351,19 @@ wheels = [ [[package]] name = "jinja2" -version = "3.1.4" +version = "3.1.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/55/39036716d19cab0747a5020fc7e907f362fbf48c984b14e62127f7e68e5d/jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369", size = 240245 } +sdist = { url = "https://files.pythonhosted.org/packages/af/92/b3130cbbf5591acf9ade8708c365f3238046ac7cb8ccba6e81abccb0ccff/jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", size = 244674 } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/80/3a54838c3fb461f6fec263ebf3a3a41771bd05190238de3486aae8540c36/jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d", size = 133271 }, + { url = "https://files.pythonhosted.org/packages/bd/0f/2ba5fbcd631e3e88689309dbe978c5769e883e4b84ebfe7da30b43275c5a/jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb", size = 134596 }, ] [[package]] name = "juju" -version = "3.6.0.0" +version = "3.6.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hvac" }, @@ -343,9 +379,9 @@ dependencies = [ { name = "typing-inspect" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1b/78/9f3ee2b300c1e22096c787ca0b4c7a8668ecafcbac1174bc68494541e559/juju-3.6.0.0.tar.gz", hash = "sha256:72d76fe69fce5ce59b2ad0461911c42f4f41760893cf241709f4f118fa33751d", size = 288400 } +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/86e724a37ce2167a5145bd122a10ea36e5a1a3b2b28a27017d46b6fdc306/juju-3.6.1.0.tar.gz", hash = "sha256:59cfde55185bb53877a2bddc2855f3c48471537e130653d77984681676a448bc", size = 291387 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/55/edfc3716ebd2ae458ff86d249d2e9e839176f6a3cb0b2efb8e134468b2d5/juju-3.6.0.0-py3-none-any.whl", hash = "sha256:0e925183c62de6ad46316c0ff1814d67ff4bd7374e3dde297f457d443e8a9651", size = 283789 }, + { url = "https://files.pythonhosted.org/packages/70/68/f70c41ab95990b610798809aed792d0efebd79cc322d54e5a3c5685fa4f0/juju-3.6.1.0-py3-none-any.whl", hash = "sha256:28b6a10093f2e0243ad0ddd5ef25a3f59d710e9da5a188456ba704142819fbb3", size = 287063 }, ] [[package]] @@ -425,6 +461,17 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, ] +[[package]] +name = "nfs-server-proxy" +version = "0.0" +source = { virtual = "charms/nfs-server-proxy" } +dependencies = [ + { name = "ops" }, +] + +[package.metadata] +requires-dist = [{ name = "ops" }] + [[package]] name = "nodeenv" version = "1.9.1" @@ -541,16 +588,16 @@ wheels = [ [[package]] name = "protobuf" -version = "5.29.2" +version = "5.29.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a5/73/4e6295c1420a9d20c9c351db3a36109b4c9aa601916cb7c6871e3196a1ca/protobuf-5.29.2.tar.gz", hash = "sha256:b2cc8e8bb7c9326996f0e160137b0861f1a82162502658df2951209d0cb0309e", size = 424901 } +sdist = { url = "https://files.pythonhosted.org/packages/f7/d1/e0a911544ca9993e0f17ce6d3cc0932752356c1b0a834397f28e63479344/protobuf-5.29.3.tar.gz", hash = "sha256:5da0f41edaf117bde316404bad1a486cb4ededf8e4a54891296f648e8e076620", size = 424945 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/42/6db5387124708d619ffb990a846fb123bee546f52868039f8fa964c5bc54/protobuf-5.29.2-cp310-abi3-win32.whl", hash = "sha256:c12ba8249f5624300cf51c3d0bfe5be71a60c63e4dcf51ffe9a68771d958c851", size = 422697 }, - { url = "https://files.pythonhosted.org/packages/6c/38/2fcc968b377b531882d6ab2ac99b10ca6d00108394f6ff57c2395fb7baff/protobuf-5.29.2-cp310-abi3-win_amd64.whl", hash = "sha256:842de6d9241134a973aab719ab42b008a18a90f9f07f06ba480df268f86432f9", size = 434495 }, - { url = "https://files.pythonhosted.org/packages/cb/26/41debe0f6615fcb7e97672057524687ed86fcd85e3da3f031c30af8f0c51/protobuf-5.29.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a0c53d78383c851bfa97eb42e3703aefdc96d2036a41482ffd55dc5f529466eb", size = 417812 }, - { url = "https://files.pythonhosted.org/packages/e4/20/38fc33b60dcfb380507b99494aebe8c34b68b8ac7d32808c4cebda3f6f6b/protobuf-5.29.2-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:494229ecd8c9009dd71eda5fd57528395d1eacdf307dbece6c12ad0dd09e912e", size = 319562 }, - { url = "https://files.pythonhosted.org/packages/90/4d/c3d61e698e0e41d926dbff6aa4e57428ab1a6fc3b5e1deaa6c9ec0fd45cf/protobuf-5.29.2-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:b6b0d416bbbb9d4fbf9d0561dbfc4e324fd522f61f7af0fe0f282ab67b22477e", size = 319662 }, - { url = "https://files.pythonhosted.org/packages/f3/fd/c7924b4c2a1c61b8f4b64edd7a31ffacf63432135a2606f03a2f0d75a750/protobuf-5.29.2-py3-none-any.whl", hash = "sha256:fde4554c0e578a5a0bcc9a276339594848d1e89f9ea47b4427c80e5d72f90181", size = 172539 }, + { url = "https://files.pythonhosted.org/packages/dc/7a/1e38f3cafa022f477ca0f57a1f49962f21ad25850c3ca0acd3b9d0091518/protobuf-5.29.3-cp310-abi3-win32.whl", hash = "sha256:3ea51771449e1035f26069c4c7fd51fba990d07bc55ba80701c78f886bf9c888", size = 422708 }, + { url = "https://files.pythonhosted.org/packages/61/fa/aae8e10512b83de633f2646506a6d835b151edf4b30d18d73afd01447253/protobuf-5.29.3-cp310-abi3-win_amd64.whl", hash = "sha256:a4fa6f80816a9a0678429e84973f2f98cbc218cca434abe8db2ad0bffc98503a", size = 434508 }, + { url = "https://files.pythonhosted.org/packages/dd/04/3eaedc2ba17a088961d0e3bd396eac764450f431621b58a04ce898acd126/protobuf-5.29.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a8434404bbf139aa9e1300dbf989667a83d42ddda9153d8ab76e0d5dcaca484e", size = 417825 }, + { url = "https://files.pythonhosted.org/packages/4f/06/7c467744d23c3979ce250397e26d8ad8eeb2bea7b18ca12ad58313c1b8d5/protobuf-5.29.3-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:daaf63f70f25e8689c072cfad4334ca0ac1d1e05a92fc15c54eb9cf23c3efd84", size = 319573 }, + { url = "https://files.pythonhosted.org/packages/a8/45/2ebbde52ad2be18d3675b6bee50e68cd73c9e0654de77d595540b5129df8/protobuf-5.29.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:c027e08a08be10b67c06bf2370b99c811c466398c357e615ca88c91c07f0910f", size = 319672 }, + { url = "https://files.pythonhosted.org/packages/fd/b2/ab07b09e0f6d143dfb839693aa05765257bceaa13d03bf1a696b78323e7a/protobuf-5.29.3-py3-none-any.whl", hash = "sha256:0a18ed4a24198528f2333802eb075e59dea9d679ab7a6c5efb017a59004d849f", size = 172550 }, ] [[package]] @@ -603,11 +650,27 @@ wheels = [ [[package]] name = "pygments" -version = "2.18.0" +version = "2.19.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8e/62/8336eff65bcbc8e4cb5d05b55faf041285951b6e80f33e2bff2024788f31/pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199", size = 4891905 } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/3f/01c8b82017c199075f8f788d0d906b9ffbbc5a47dc9918a945e13d5a2bda/pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a", size = 1205513 }, + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + +[[package]] +name = "pylxd" +version = "2.3.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "python-dateutil" }, + { name = "requests" }, + { name = "requests-toolbelt" }, + { name = "ws4py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/8e/6a31a694560adaba20df521c3102bdecec06a0fea9c73ff1466834e2df30/pylxd-2.3.5.tar.gz", hash = "sha256:d67973dd2dc1728e3e1b41cc973e11e6cbceae87878d193ac04cc2b65a7158ef", size = 71781 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/44/ea48223c7fd42376cc9c6cdff3a8bf3cd19045a3c3722a2fcc9655cfd8a3/pylxd-2.3.5-py3-none-any.whl", hash = "sha256:f74affdb8a852c6241593c6bc022be1d6e2e700d9bc5efb180aeb7e7697a268d", size = 98084 }, ] [[package]] @@ -670,7 +733,7 @@ wheels = [ [[package]] name = "pytest" -version = "8.3.4" +version = "7.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -678,9 +741,9 @@ dependencies = [ { name = "packaging" }, { name = "pluggy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/05/35/30e0d83068951d90a01852cb1cef56e5d8a09d20c7f511634cc2f7e0372a/pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761", size = 1445919 } +sdist = { url = "https://files.pythonhosted.org/packages/80/1f/9d8e98e4133ffb16c90f3b405c43e38d3abb715bb5d7a63a5a684f7e46a3/pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", size = 1357116 } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/92/76a1c94d3afee238333bc0a42b82935dd8f9cf8ce9e336ff87ee14d9e1cf/pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6", size = 343083 }, + { url = "https://files.pythonhosted.org/packages/51/ff/f6e8b8f39e08547faece4bd80f89d5a8de68a38b2d179cc1c4490ffa3286/pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8", size = 325287 }, ] [[package]] @@ -764,7 +827,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.3" +version = "2.31.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -772,9 +835,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +sdist = { url = "https://files.pythonhosted.org/packages/9d/be/10918a2eac4ae9f02f6cfe6414b7a155ccd8f7f9d4380d62fd5b955065c3/requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1", size = 110794 } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, + { url = "https://files.pythonhosted.org/packages/70/8e/0e2d847013cb52cd35b38c009bb167a1a26b2ce6cd6965bf26b47bc0bf44/requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", size = 62574 }, ] [[package]] @@ -790,6 +853,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, ] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, +] + [[package]] name = "rsa" version = "4.9" @@ -804,27 +879,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.8.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bf/5e/683c7ef7a696923223e7d95ca06755d6e2acbc5fd8382b2912a28008137c/ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3", size = 3378522 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/c4/bfdbb8b9c419ff3b52479af8581026eeaac3764946fdb463dec043441b7d/ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6", size = 10535860 }, - { url = "https://files.pythonhosted.org/packages/ef/c5/0aabdc9314b4b6f051168ac45227e2aa8e1c6d82718a547455e40c9c9faa/ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939", size = 10346327 }, - { url = "https://files.pythonhosted.org/packages/1a/78/4843a59e7e7b398d6019cf91ab06502fd95397b99b2b858798fbab9151f5/ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d", size = 9942585 }, - { url = "https://files.pythonhosted.org/packages/91/5a/642ed8f1ba23ffc2dd347697e01eef3c42fad6ac76603be4a8c3a9d6311e/ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13", size = 10797597 }, - { url = "https://files.pythonhosted.org/packages/30/25/2e654bc7226da09a49730a1a2ea6e89f843b362db80b4b2a7a4f948ac986/ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18", size = 10307244 }, - { url = "https://files.pythonhosted.org/packages/c0/2d/a224d56bcd4383583db53c2b8f410ebf1200866984aa6eb9b5a70f04e71f/ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502", size = 11362439 }, - { url = "https://files.pythonhosted.org/packages/82/01/03e2857f9c371b8767d3e909f06a33bbdac880df17f17f93d6f6951c3381/ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d", size = 12078538 }, - { url = "https://files.pythonhosted.org/packages/af/ae/ff7f97b355da16d748ceec50e1604a8215d3659b36b38025a922e0612e9b/ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82", size = 11616172 }, - { url = "https://files.pythonhosted.org/packages/6a/d0/6156d4d1e53ebd17747049afe801c5d7e3014d9b2f398b9236fe36ba4320/ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452", size = 12919886 }, - { url = "https://files.pythonhosted.org/packages/4e/84/affcb30bacb94f6036a128ad5de0e29f543d3f67ee42b490b17d68e44b8a/ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd", size = 11212599 }, - { url = "https://files.pythonhosted.org/packages/60/b9/5694716bdefd8f73df7c0104334156c38fb0f77673d2966a5a1345bab94d/ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20", size = 10784637 }, - { url = "https://files.pythonhosted.org/packages/24/7e/0e8f835103ac7da81c3663eedf79dec8359e9ae9a3b0d704bae50be59176/ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc", size = 10390591 }, - { url = "https://files.pythonhosted.org/packages/27/da/180ec771fc01c004045962ce017ca419a0281f4bfaf867ed0020f555b56e/ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060", size = 10894298 }, - { url = "https://files.pythonhosted.org/packages/6d/f8/29f241742ed3954eb2222314b02db29f531a15cab3238d1295e8657c5f18/ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea", size = 11275965 }, - { url = "https://files.pythonhosted.org/packages/79/e9/5b81dc9afc8a80884405b230b9429efeef76d04caead904bd213f453b973/ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964", size = 8807651 }, - { url = "https://files.pythonhosted.org/packages/ea/67/7291461066007617b59a707887b90e319b6a043c79b4d19979f86b7a20e7/ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9", size = 9625289 }, - { url = "https://files.pythonhosted.org/packages/03/8f/e4fa95288b81233356d9a9dcaed057e5b0adc6399aa8fd0f6d784041c9c3/ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936", size = 9078754 }, +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/67/3e/e89f736f01aa9517a97e2e7e0ce8d34a4d8207087b3cfdec95133fee13b5/ruff-0.9.1.tar.gz", hash = "sha256:fd2b25ecaf907d6458fa842675382c8597b3c746a2dde6717fe3415425df0c17", size = 3498844 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/05/c3a2e0feb3d5d394cdfd552de01df9d3ec8a3a3771bbff247fab7e668653/ruff-0.9.1-py3-none-linux_armv6l.whl", hash = "sha256:84330dda7abcc270e6055551aca93fdde1b0685fc4fd358f26410f9349cf1743", size = 10645241 }, + { url = "https://files.pythonhosted.org/packages/dd/da/59f0a40e5f88ee5c054ad175caaa2319fc96571e1d29ab4730728f2aad4f/ruff-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3cae39ba5d137054b0e5b472aee3b78a7c884e61591b100aeb544bcd1fc38d4f", size = 10391066 }, + { url = "https://files.pythonhosted.org/packages/b7/fe/85e1c1acf0ba04a3f2d54ae61073da030f7a5dc386194f96f3c6ca444a78/ruff-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50c647ff96f4ba288db0ad87048257753733763b409b2faf2ea78b45c8bb7fcb", size = 10012308 }, + { url = "https://files.pythonhosted.org/packages/6f/9b/780aa5d4bdca8dcea4309264b8faa304bac30e1ce0bcc910422bfcadd203/ruff-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0c8b149e9c7353cace7d698e1656ffcf1e36e50f8ea3b5d5f7f87ff9986a7ca", size = 10881960 }, + { url = "https://files.pythonhosted.org/packages/12/f4/dac4361afbfe520afa7186439e8094e4884ae3b15c8fc75fb2e759c1f267/ruff-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:beb3298604540c884d8b282fe7625651378e1986c25df51dec5b2f60cafc31ce", size = 10414803 }, + { url = "https://files.pythonhosted.org/packages/f0/a2/057a3cb7999513cb78d6cb33a7d1cc6401c82d7332583786e4dad9e38e44/ruff-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39d0174ccc45c439093971cc06ed3ac4dc545f5e8bdacf9f067adf879544d969", size = 11464929 }, + { url = "https://files.pythonhosted.org/packages/eb/c6/1ccfcc209bee465ced4874dcfeaadc88aafcc1ea9c9f31ef66f063c187f0/ruff-0.9.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69572926c0f0c9912288915214ca9b2809525ea263603370b9e00bed2ba56dbd", size = 12170717 }, + { url = "https://files.pythonhosted.org/packages/84/97/4a524027518525c7cf6931e9fd3b2382be5e4b75b2b61bec02681a7685a5/ruff-0.9.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:937267afce0c9170d6d29f01fcd1f4378172dec6760a9f4dface48cdabf9610a", size = 11708921 }, + { url = "https://files.pythonhosted.org/packages/a6/a4/4e77cf6065c700d5593b25fca6cf725b1ab6d70674904f876254d0112ed0/ruff-0.9.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:186c2313de946f2c22bdf5954b8dd083e124bcfb685732cfb0beae0c47233d9b", size = 13058074 }, + { url = "https://files.pythonhosted.org/packages/f9/d6/fcb78e0531e863d0a952c4c5600cc5cd317437f0e5f031cd2288b117bb37/ruff-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f94942a3bb767675d9a051867c036655fe9f6c8a491539156a6f7e6b5f31831", size = 11281093 }, + { url = "https://files.pythonhosted.org/packages/e4/3b/7235bbeff00c95dc2d073cfdbf2b871b5bbf476754c5d277815d286b4328/ruff-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:728d791b769cc28c05f12c280f99e8896932e9833fef1dd8756a6af2261fd1ab", size = 10882610 }, + { url = "https://files.pythonhosted.org/packages/2a/66/5599d23257c61cf038137f82999ca8f9d0080d9d5134440a461bef85b461/ruff-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f312c86fb40c5c02b44a29a750ee3b21002bd813b5233facdaf63a51d9a85e1", size = 10489273 }, + { url = "https://files.pythonhosted.org/packages/78/85/de4aa057e2532db0f9761e2c2c13834991e087787b93e4aeb5f1cb10d2df/ruff-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae017c3a29bee341ba584f3823f805abbe5fe9cd97f87ed07ecbf533c4c88366", size = 11003314 }, + { url = "https://files.pythonhosted.org/packages/00/42/afedcaa089116d81447347f76041ff46025849fedb0ed2b187d24cf70fca/ruff-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5dc40a378a0e21b4cfe2b8a0f1812a6572fc7b230ef12cd9fac9161aa91d807f", size = 11342982 }, + { url = "https://files.pythonhosted.org/packages/39/c6/fe45f3eb27e3948b41a305d8b768e949bf6a39310e9df73f6c576d7f1d9f/ruff-0.9.1-py3-none-win32.whl", hash = "sha256:46ebf5cc106cf7e7378ca3c28ce4293b61b449cd121b98699be727d40b79ba72", size = 8819750 }, + { url = "https://files.pythonhosted.org/packages/38/8d/580db77c3b9d5c3d9479e55b0b832d279c30c8f00ab0190d4cd8fc67831c/ruff-0.9.1-py3-none-win_amd64.whl", hash = "sha256:342a824b46ddbcdddd3abfbb332fa7fcaac5488bf18073e841236aadf4ad5c19", size = 9701331 }, + { url = "https://files.pythonhosted.org/packages/b2/94/0498cdb7316ed67a1928300dd87d659c933479f44dec51b4f62bfd1f8028/ruff-0.9.1-py3-none-win_arm64.whl", hash = "sha256:1cd76c7f9c679e6e8f2af8f778367dca82b95009bc7b1a85a47f1521ae524fa7", size = 9145708 }, ] [[package]] @@ -850,6 +925,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, ] +[[package]] +name = "tenacity" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/4d/6a19536c50b849338fcbe9290d562b52cbdcf30d8963d3588a68a4107df1/tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", size = 47309 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165 }, +] + [[package]] name = "toposort" version = "1.10" @@ -892,11 +976,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.2.3" +version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, ] [[package]] @@ -936,3 +1020,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/74/27/28f07df09f2983178db7bf6c9cccc847205d2b92ced986cd79565d68af4f/websockets-14.1-cp312-cp312-win_amd64.whl", hash = "sha256:90f4c7a069c733d95c308380aae314f2cb45bd8a904fb03eb36d1a4983a4993f", size = 163277 }, { url = "https://files.pythonhosted.org/packages/b0/0b/c7e5d11020242984d9d37990310520ed663b942333b83a033c2f20191113/websockets-14.1-py3-none-any.whl", hash = "sha256:4d4fc827a20abe6d544a119896f6b78ee13fe81cbfef416f3f2ddf09a03f0e2e", size = 156277 }, ] + +[[package]] +name = "ws4py" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/55/dd8a5e1f975d1549494fe8692fc272602f17e475fe70de910cdd53aec902/ws4py-0.6.0.tar.gz", hash = "sha256:9f87b19b773f0a0744a38f3afa36a803286dd3197f0bb35d9b75293ec7002d19", size = 53288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/62/dad08725f855f9695d1c2b34466cf0d1482d1168bb54d73d22b6b0124ba3/ws4py-0.6.0-py3-none-any.whl", hash = "sha256:1499c3fc103a65eb12d7b1ead7566f88487f6f678ab10ee4e53cf2411c068752", size = 45806 }, +]