Repository: canonical/operator
Pull request #505 (2021-04-14 04:12:33)

Pebble files API client functions
This PR adds Python Operator Framework support for the new Pebble files API (https://github.com/canonical/pebble/pull/25). Two things to note:

* Per the [spec](https://docs.google.com/document/d/1NPPZZB_qCwtTru-YsKkRSAb6mXJzBiE0fG3GTZnx4fM/edit#), for now we're only supporting single-file operations in the client APIs, even though the HTTP API itself supports bulk operations.
* In addition, Python's standard library doesn't have good support for streaming writing/parsing of multipart, so the current code reads the whole file into memory. However, the API is such that we can fix this at a later date without changing the `read_file` and `write_file` signatures.

benhoyt: One thing I'm not enthusiastic about is the copied docstrings in the model.Container versions of the APIs. We could just say `read_content.__doc__ = pebble.Client.read_content.__doc__`, but what our doc system (and RTD) handle that?

Patch:
diff --git a/ops/model.py b/ops/model.py
index ef49f71..6be1b93 100644
--- a/ops/model.py
+++ b/ops/model.py
@@ -1091,6 +1091,84 @@ class Container:
             raise RuntimeError('expected 1 service, got {}'.format(len(services)))
         return services[service_name]
 
+    def read_content(self, path: str, *, encoding: str = 'utf-8') -> typing.Union[bytes, str]:
+        """Read a file's content from the remote system to a string.
+
+        Args:
+            path: Path of the file to read from the remote system.
+            encoding: Encoding to use for decoding file's bytes to a text string
+                (or None to return raw bytes).
+
+        Returns:
+            File's content, decoded and returned as str if encoding is not None,
+            or returned as raw bytes if encoding is None.
+        """
+        return self._pebble.read_content(path, encoding=encoding)
+
+    def write_content(
+            self, path: str, content: typing.Union[bytes, str], *, encoding: str = 'utf-8',
+            make_dirs: bool = False, permissions: int = None, user_id: int = None,
+            user: str = None, group_id: int = None, group: str = None):
+        """Write content to a given file path on the remote system.
+
+        Args:
+            path: Path of the file to write to on the remote system.
+            content: Content to write (str or bytes).
+            encoding: Encoding to use for encoding content str to bytes.
+                Ignored if content is bytes.
+            make_dirs: If True, create parent directories if they don't exist.
+            permissions: Permissions (mode) to create file with (Pebble default
+                is 0o644).
+            user_id: UID for file.
+            user: Username for file (user_id takes precedence).
+            group_id: GID for file.
+            group: Group name for file (group_id takes precedence).
+        """
+        self._pebble.write_content(path, content, encoding=encoding, make_dirs=make_dirs,
+                                   permissions=permissions, user_id=user_id, user=user,
+                                   group_id=group_id, group=group)
+
+    def list_files(self, path: str, *, pattern: str = None,
+                   itself: bool = False) -> typing.List['pebble.FileInfo']:
+        """Return list of file information from given path on remote system.
+
+        Args:
+            path: Path of the directory to list, or path of the file to return
+                information about.
+            pattern: If specified, filter the list to just the files that match,
+                for example "*.txt".
+            itself: If path refers to a directory, return information about the
+                directory itself, rather than its contents.
+        """
+        return self._pebble.list_files(path, pattern=pattern, itself=itself)
+
+    def make_dir(
+            self, path: str, *, make_parents: bool = False, permissions: int = None,
+            user_id: int = None, user: str = None, group_id: int = None, group: str = None):
+        """Create a directory on the remote system with the given attributes.
+
+        Args:
+            path: Path of the directory to create on the remote system.
+            make_parents: If True, create parent directories if they don't exist.
+            permissions: Permissions (mode) to create directory with (Pebble
+                default is 0o755).
+            user_id: UID for directory.
+            user: Username for directory (user_id takes precedence).
+            group_id: GID for directory.
+            group: Group name for directory (group_id takes precedence).
+        """
+        self._pebble.make_dir(path, make_parents=make_parents, permissions=permissions,
+                              user_id=user_id, user=user, group_id=group_id, group=group)
+
+    def remove_path(self, path: str, *, recursive: bool = False):
+        """Remove a file or directory on the remote system.
+
+        Args:
+            path: Path of the file or directory to delete from the remote system.
+            recursive: If True, recursively delete path and everything under it.
+        """
+        self._pebble.remove_path(path, recursive=recursive)
+
 
 class ContainerMapping(Mapping):
     """Map of container names to Container objects.
diff --git a/ops/pebble.py b/ops/pebble.py
index 0e8ada7..78e3abe 100644
--- a/ops/pebble.py
+++ b/ops/pebble.py
@@ -17,18 +17,21 @@
 For a command-line interface for local testing, see test/pebble_cli.py.
 """
 
-from typing import Dict, Iterable, List, Optional, Union
+from email.mime.multipart import MIMEBase, MIMEMultipart
+import cgi
 import datetime
+import email.parser
 import enum
 import http.client
 import json
 import re
 import socket
+import sys
 import time
+import typing
 import urllib.error
 import urllib.parse
 import urllib.request
-import sys
 
 from ops._private import yaml
 
@@ -107,7 +110,7 @@ def _parse_timestamp(s):
                              microsecond=microsecond, tzinfo=tz)
 
 
-def _json_loads(s: Union[str, bytes]) -> Dict:
+def _json_loads(s: typing.Union[str, bytes]) -> typing.Dict:
     """Like json.loads(), but handle str or bytes.
 
     This is needed because an HTTP response's read() method returns bytes on
@@ -130,10 +133,29 @@ class ConnectionError(Error):
     """Raised when the Pebble client can't connect to the socket."""
 
 
+class ProtocolError(Error):
+    """Raised when there's a higher-level protocol error talking to Pebble."""
+
+
+class PathError(Error):
+    """Raised when there's an error with a specific path."""
+
+    def __init__(self, kind: str, message: str):
+        """This shouldn't be instantiated directly."""
+        self.kind = kind
+        self.message = message
+
+    def __str__(self):
+        return '{} - {}'.format(self.kind, self.message)
+
+    def __repr__(self):
+        return 'PathError({!r}, {!r})'.format(self.kind, self.message)
+
+
 class APIError(Error):
     """Raised when an HTTP API error occurs talking to the Pebble server."""
 
-    def __init__(self, body: Dict, code: int, status: str, message: str):
+    def __init__(self, body: typing.Dict, code: int, status: str, message: str):
         """This shouldn't be instantiated directly."""
         super().__init__(message)  # Makes str(e) return message
         self.body = body
@@ -188,8 +210,8 @@ class SystemInfo:
         self.version = version
 
     @classmethod
-    def from_dict(cls, d: Dict) -> 'SystemInfo':
-        """Create new object from dict parsed from JSON."""
+    def from_dict(cls, d: typing.Dict) -> 'SystemInfo':
+        """Create new SystemInfo object from dict parsed from JSON."""
         return cls(version=d['version'])
 
     def __repr__(self):
@@ -204,7 +226,7 @@ class Warning:
         message: str,
         first_added: datetime.datetime,
         last_added: datetime.datetime,
-        last_shown: Optional[datetime.datetime],
+        last_shown: typing.Optional[datetime.datetime],
         expire_after: str,
         repeat_after: str,
     ):
@@ -216,8 +238,8 @@ class Warning:
         self.repeat_after = repeat_after
 
     @classmethod
-    def from_dict(cls, d: Dict) -> 'Warning':
-        """Create new object from dict parsed from JSON."""
+    def from_dict(cls, d: typing.Dict) -> 'Warning':
+        """Create new Warning object from dict parsed from JSON."""
         return cls(
             message=d['message'],
             first_added=_parse_timestamp(d['first-added']),
@@ -252,8 +274,8 @@ class TaskProgress:
         self.total = total
 
     @classmethod
-    def from_dict(cls, d: Dict) -> 'TaskProgress':
-        """Create new object from dict parsed from JSON."""
+    def from_dict(cls, d: typing.Dict) -> 'TaskProgress':
+        """Create new TaskProgress object from dict parsed from JSON."""
         return cls(
             label=d['label'],
             done=d['done'],
@@ -284,10 +306,10 @@ class Task:
         kind: str,
         summary: str,
         status: str,
-        log: List[str],
+        log: typing.List[str],
         progress: TaskProgress,
         spawn_time: datetime.datetime,
-        ready_time: Optional[datetime.datetime],
+        ready_time: typing.Optional[datetime.datetime],
     ):
         self.id = id
         self.kind = kind
@@ -299,8 +321,8 @@ class Task:
         self.ready_time = ready_time
 
     @classmethod
-    def from_dict(cls, d: Dict) -> 'Task':
-        """Create new object from dict parsed from JSON."""
+    def from_dict(cls, d: typing.Dict) -> 'Task':
+        """Create new Task object from dict parsed from JSON."""
         return cls(
             id=TaskID(d['id']),
             kind=d['kind'],
@@ -341,11 +363,11 @@ class Change:
         kind: str,
         summary: str,
         status: str,
-        tasks: List[Task],
+        tasks: typing.List[Task],
         ready: bool,
-        err: Optional[str],
+        err: typing.Optional[str],
         spawn_time: datetime.datetime,
-        ready_time: Optional[datetime.datetime],
+        ready_time: typing.Optional[datetime.datetime],
     ):
         self.id = id
         self.kind = kind
@@ -358,8 +380,8 @@ class Change:
         self.ready_time = ready_time
 
     @classmethod
-    def from_dict(cls, d: Dict) -> 'Change':
-        """Create new object from dict parsed from JSON."""
+    def from_dict(cls, d: typing.Dict) -> 'Change':
+        """Create new Change object from dict parsed from JSON."""
         return cls(
             id=ChangeID(d['id']),
             kind=d['kind'],
@@ -417,7 +439,7 @@ class Layer:
     https://github.com/canonical/pebble/blob/master/internal/plan/plan.go
     """
 
-    def __init__(self, raw: Union[str, Dict] = None):
+    def __init__(self, raw: typing.Union[str, typing.Dict] = None):
         if isinstance(raw, str):
             d = yaml.safe_load(raw) or {}
         else:
@@ -431,7 +453,7 @@ class Layer:
         """Convert this layer to its YAML representation."""
         return yaml.safe_dump(self.to_dict())
 
-    def to_dict(self) -> Dict:
+    def to_dict(self) -> typing.Dict:
         """Convert this layer to its dict representation."""
         fields = [
             ('summary', self.summary),
@@ -449,7 +471,7 @@ class Layer:
 class Service:
     """Represents a service description in a Pebble configuration layer."""
 
-    def __init__(self, name: str, raw: Dict = None):
+    def __init__(self, name: str, raw: typing.Dict = None):
         self.name = name
         raw = raw or {}
         self.summary = raw.get('summary', '')
@@ -462,7 +484,7 @@ class Service:
         self.requires = list(raw.get('requires', []))
         self.environment = dict(raw.get('environment', {}))
 
-    def to_dict(self) -> Dict:
+    def to_dict(self) -> typing.Dict:
         """Convert this service object to its dict representation."""
         fields = [
             ('summary', self.summary),
@@ -502,8 +524,8 @@ class ServiceInfo:
     def __init__(
         self,
         name: str,
-        startup: Union[ServiceStartup, str],
-        current: Union[ServiceStatus, str],
+        startup: typing.Union[ServiceStartup, str],
+        current: typing.Union[ServiceStatus, str],
     ):
         self.name = name
         self.startup = startup
@@ -514,8 +536,8 @@ class ServiceInfo:
         return self.current == ServiceStatus.ACTIVE
 
     @classmethod
-    def from_dict(cls, d: Dict) -> 'ServiceInfo':
-        """Create new object from dict parsed from JSON."""
+    def from_dict(cls, d: typing.Dict) -> 'ServiceInfo':
+        """Create new ServiceInfo object from dict parsed from JSON."""
         try:
             startup = ServiceStartup(d['startup'])
         except ValueError:
@@ -538,6 +560,80 @@ class ServiceInfo:
                 ).format(self=self)
 
 
+class FileType(enum.Enum):
+    """Enum of file types."""
+
+    FILE = 'file'
+    DIRECTORY = 'directory'
+    SYMLINK = 'symlink'
+    SOCKET = 'socket'
+    NAMED_PIPE = 'named-pipe'
+    DEVICE = 'device'
+    UNKNOWN = 'unknown'
+
+
+class FileInfo:
+    """Stat-like information about a single file."""
+
+    def __init__(
+        self,
+        path: str,
+        name: str,
+        type: typing.Union['FileType', str],
+        size: typing.Optional[int],
+        permissions: int,
+        last_modified: datetime.datetime,
+        user_id: typing.Optional[int],
+        user: typing.Optional[str],
+        group_id: typing.Optional[int],
+        group: typing.Optional[str],
+    ):
+        self.path = path
+        self.name = name
+        self.type = type
+        self.size = size
+        self.permissions = permissions
+        self.last_modified = last_modified
+        self.user_id = user_id
+        self.user = user
+        self.group_id = group_id
+        self.group = group
+
+    @classmethod
+    def from_dict(cls, d: typing.Dict) -> 'FileInfo':
+        """Create new FileInfo object from dict parsed from JSON."""
+        try:
+            file_type = FileType(d['type'])
+        except ValueError:
+            file_type = d['type']
+        return cls(
+            path=d['path'],
+            name=d['name'],
+            type=file_type,
+            size=d.get('size'),
+            permissions=int(d['permissions'], 8),
+            last_modified=_parse_timestamp(d['last-modified']),
+            user_id=d.get('user-id'),
+            user=d.get('user'),
+            group_id=d.get('group-id'),
+            group=d.get('group'),
+        )
+
+    def __repr__(self):
+        return ('FileInfo('
+                'path={self.path!r}, '
+                'name={self.name!r}, '
+                'type={self.type}, '
+                'size={self.size}, '
+                'permissions=0o{self.permissions:o}, '
+                'last_modified={self.last_modified!r}, '
+                'user_id={self.user_id}, '
+                'user={self.user!r}, '
+                'group_id={self.group_id}, '
+                'group={self.group!r})'
+                ).format(self=self)
+
+
 class Client:
     """Pebble API client."""
 
@@ -565,23 +661,48 @@ class Client:
         opener.add_handler(urllib.request.HTTPErrorProcessor())
         return opener
 
-    def _request(self, method: str, path: str, query: Dict = None, body: Dict = None) -> Dict:
-        """Make a request with the given HTTP method and path to the Pebble client.
+    def _request(
+        self, method: str, path: str, query: typing.Dict = None, body: typing.Dict = None,
+    ) -> typing.Dict:
+        """Make a JSON request to the Pebble server with the given HTTP method and path.
 
         If query dict is provided, it is encoded and appended as a query string
         to the URL. If body dict is provided, it is serialied as JSON and used
-        as the HTTP body (with Content-Type "application/json").
+        as the HTTP body (with Content-Type: "application/json"). The resulting
+        body is decoded from JSON.
         """
-        url = self.base_url + path
-        if query:
-            url = url + '?' + urllib.parse.urlencode(query)
-
         headers = {'Accept': 'application/json'}
         data = None
         if body is not None:
             data = json.dumps(body).encode('utf-8')
             headers['Content-Type'] = 'application/json'
 
+        response = self._request_raw(method, path, query, headers, data)
+        self._ensure_content_type(response.headers, 'application/json')
+        return _json_loads(response.read())
+
+    @staticmethod
+    def _ensure_content_type(headers, expected):
+        """Parse Content-Type header from headers and ensure it's equal to expected.
+
+        Return a dict of any options in the header, e.g., {'boundary': ...}.
+        """
+        ctype, options = cgi.parse_header(headers.get('Content-Type', ''))
+        if ctype != expected:
+            raise ProtocolError('expected Content-Type {!r}, got {!r}'.format(expected, ctype))
+        return options
+
+    def _request_raw(
+        self, method: str, path: str, query: typing.Dict = None, headers: typing.Dict = None,
+        data: bytes = None,
+    ) -> http.client.HTTPResponse:
+        """Make a request to the Pebble server; return the raw HTTPResponse object."""
+        url = self.base_url + path
+        if query:
+            url = url + '?' + urllib.parse.urlencode(query)
+
+        if headers is None:
+            headers = {}
         request = urllib.request.Request(url, method=method, data=data, headers=headers)
 
         try:
@@ -600,47 +721,45 @@ class Client:
         except urllib.error.URLError as e:
             raise ConnectionError(e.reason)
 
-        response_data = response.read()
-        result = _json_loads(response_data)
-        return result
+        return response
 
     def get_system_info(self) -> SystemInfo:
         """Get system info."""
-        result = self._request('GET', '/v1/system-info')
-        return SystemInfo.from_dict(result['result'])
+        resp = self._request('GET', '/v1/system-info')
+        return SystemInfo.from_dict(resp['result'])
 
-    def get_warnings(self, select: WarningState = WarningState.PENDING) -> List[Warning]:
+    def get_warnings(self, select: WarningState = WarningState.PENDING) -> typing.List[Warning]:
         """Get list of warnings in given state (pending or all)."""
         query = {'select': select.value}
-        result = self._request('GET', '/v1/warnings', query)
-        return [Warning.from_dict(w) for w in result['result']]
+        resp = self._request('GET', '/v1/warnings', query)
+        return [Warning.from_dict(w) for w in resp['result']]
 
     def ack_warnings(self, timestamp: datetime.datetime) -> int:
         """Acknowledge warnings up to given timestamp, return number acknowledged."""
         body = {'action': 'okay', 'timestamp': timestamp.isoformat()}
-        result = self._request('POST', '/v1/warnings', body=body)
-        return result['result']
+        resp = self._request('POST', '/v1/warnings', body=body)
+        return resp['result']
 
     def get_changes(
         self, select: ChangeState = ChangeState.IN_PROGRESS, service: str = None,
-    ) -> List[Change]:
+    ) -> typing.List[Change]:
         """Get list of changes in given state, filter by service name if given."""
         query = {'select': select.value}
         if service is not None:
             query['for'] = service
-        result = self._request('GET', '/v1/changes', query)
-        return [Change.from_dict(c) for c in result['result']]
+        resp = self._request('GET', '/v1/changes', query)
+        return [Change.from_dict(c) for c in resp['result']]
 
     def get_change(self, change_id: ChangeID) -> Change:
         """Get single change by ID."""
-        result = self._request('GET', '/v1/changes/{}'.format(change_id))
-        return Change.from_dict(result['result'])
+        resp = self._request('GET', '/v1/changes/{}'.format(change_id))
+        return Change.from_dict(resp['result'])
 
     def abort_change(self, change_id: ChangeID) -> Change:
         """Abort change with given ID."""
         body = {'action': 'abort'}
-        result = self._request('POST', '/v1/changes/{}'.format(change_id), body=body)
-        return Change.from_dict(result['result'])
+        resp = self._request('POST', '/v1/changes/{}'.format(change_id), body=body)
+        return Change.from_dict(resp['result'])
 
     def autostart_services(self, timeout: float = 30.0, delay: float = 0.1) -> ChangeID:
         """Start the startup-enabled services and wait (poll) for them to be started.
@@ -652,7 +771,7 @@ class Client:
         return self._services_action('autostart', [], timeout, delay)
 
     def start_services(
-        self, services: List[str], timeout: float = 30.0, delay: float = 0.1,
+        self, services: typing.List[str], timeout: float = 30.0, delay: float = 0.1,
     ) -> ChangeID:
         """Start services by name and wait (poll) for them to be started.
 
@@ -663,7 +782,7 @@ class Client:
         return self._services_action('start', services, timeout, delay)
 
     def stop_services(
-        self, services: List[str], timeout: float = 30.0, delay: float = 0.1,
+        self, services: typing.List[str], timeout: float = 30.0, delay: float = 0.1,
     ) -> ChangeID:
         """Stop services by name and wait (poll) for them to be started.
 
@@ -674,7 +793,7 @@ class Client:
         return self._services_action('stop', services, timeout, delay)
 
     def _services_action(
-        self, action: str, services: Iterable[str], timeout: float, delay: float,
+        self, action: str, services: typing.Iterable[str], timeout: float, delay: float,
     ) -> ChangeID:
         if not isinstance(services, (list, tuple)):
             raise TypeError('services must be a list of str, not {}'.format(
@@ -684,8 +803,8 @@ class Client:
                 raise TypeError('service names must be str, not {}'.format(type(s).__name__))
 
         body = {'action': action, 'services': services}
-        result = self._request('POST', '/v1/services', body=body)
-        change_id = ChangeID(result['change'])
+        resp = self._request('POST', '/v1/services', body=body)
+        change_id = ChangeID(resp['change'])
         if timeout:
             change = self.wait_change(change_id, timeout=timeout, delay=delay)
             if change.err:
@@ -708,7 +827,8 @@ class Client:
         raise TimeoutError(
             'timed out waiting for change {} ({} seconds)'.format(change_id, timeout))
 
-    def add_layer(self, label: str, layer: Union[str, dict, Layer], *, combine: bool = False):
+    def add_layer(
+            self, label: str, layer: typing.Union[str, dict, Layer], *, combine: bool = False):
         """Dynamically add a new layer onto the Pebble configuration layers.
 
         If combine is False (the default), append the new layer as the top
@@ -740,10 +860,10 @@ class Client:
 
     def get_plan(self) -> Plan:
         """Get the Pebble plan (currently contains only combined services)."""
-        result = self._request('GET', '/v1/plan', {'format': 'yaml'})
-        return Plan(result['result'])
+        resp = self._request('GET', '/v1/plan', {'format': 'yaml'})
+        return Plan(resp['result'])
 
-    def get_services(self, names: List[str] = None) -> List[ServiceInfo]:
+    def get_services(self, names: typing.List[str] = None) -> typing.List[ServiceInfo]:
         """Get the service status for the configured services.
 
         If names is specified, only fetch the service status for the services
@@ -752,5 +872,211 @@ class Client:
         query = None
         if names is not None:
             query = {'names': ','.join(names)}
-        result = self._request('GET', '/v1/services', query)
-        return [ServiceInfo.from_dict(info) for info in result['result']]
+        resp = self._request('GET', '/v1/services', query)
+        return [ServiceInfo.from_dict(info) for info in resp['result']]
+
+    def read_content(self, path: str, *, encoding: str = 'utf-8') -> typing.Union[bytes, str]:
+        """Read a file's content from the remote system to a string.
+
+        Args:
+            path: Path of the file to read from the remote system.
+            encoding: Encoding to use for decoding file's bytes to a text string
+                (or None to return raw bytes).
+
+        Returns:
+            File's content, decoded and returned as str if encoding is not None,
+            or returned as raw bytes if encoding is None.
+        """
+        query = {
+            'action': 'read',
+            'path': path,
+        }
+        headers = {'Accept': 'multipart/form-data'}
+        response = self._request_raw('GET', '/v1/files', query, headers)
+
+        options = self._ensure_content_type(response.headers, 'multipart/form-data')
+        boundary = options.get('boundary', '')
+        if not boundary:
+            raise ProtocolError('invalid boundary {!r}'.format(boundary))
+
+        # We have to manually write the Content-Type with boundary, because
+        # email.parser expects the entire multipart message with headers.
+        parser = email.parser.BytesFeedParser()
+        parser.feed(b'Content-Type: multipart/form-data; boundary=' +
+                    boundary.encode('utf-8') + b'\r\n\r\n')
+
+        # Then read the rest of the response and feed it to the parser.
+        while True:
+            chunk = response.read(8192)
+            if not chunk:
+                break
+            parser.feed(chunk)
+        message = parser.close()
+
+        # Walk over the multipart parts and read content and metadata.
+        resp = None
+        content = None
+        for part in message.walk():
+            name = part.get_param('name', header='Content-Disposition')
+            if name == 'response':
+                resp = _json_loads(part.get_payload())
+            elif name == 'files':
+                filename = part.get_filename()
+                if filename != path:
+                    raise ProtocolError('path not expected: {}'.format(filename))
+                # decode=True, ironically, avoids decoding bytes to str
+                content = part.get_payload(decode=True)
+
+        if resp is None:
+            raise ProtocolError('no "response" field in multipart body')
+        self._raise_on_path_error(resp, path)
+
+        if content is None:
+            raise ProtocolError('no file content in multipart response')
+        if encoding is not None:
+            content = content.decode(encoding)
+        return content
+
+    @staticmethod
+    def _raise_on_path_error(resp, path):
+        result = resp['result'] or []  # in case it's null instead of []
+        paths = {item['path']: item for item in result}
+        if path not in paths:
+            raise ProtocolError('path not found in response metadata: {}'.format(resp))
+        error = paths[path].get('error')
+        if error:
+            raise PathError(error['kind'], error['message'])
+
+    def write_content(
+            self, path: str, content: typing.Union[bytes, str], *, encoding: str = 'utf-8',
+            make_dirs: bool = False, permissions: int = None, user_id: int = None,
+            user: str = None, group_id: int = None, group: str = None):
+        """Write content to a given file path on the remote system.
+
+        Args:
+            path: Path of the file to write to on the remote system.
+            content: Content to write (str or bytes).
+            encoding: Encoding to use for encoding content str to bytes.
+                Ignored if content is bytes.
+            make_dirs: If True, create parent directories if they don't exist.
+            permissions: Permissions (mode) to create file with (Pebble default
+                is 0o644).
+            user_id: UID for file.
+            user: Username for file (user_id takes precedence).
+            group_id: GID for file.
+            group: Group name for file (group_id takes precedence).
+        """
+        info = self._make_auth_dict(permissions, user_id, user, group_id, group)
+        info['path'] = path
+        if make_dirs:
+            info['make-dirs'] = True
+        metadata = {
+            'action': 'write',
+            'files': [info],
+        }
+
+        multipart = MIMEMultipart('form-data')
+
+        part = MIMEBase('application', 'json')
+        part.add_header('Content-Disposition', 'form-data', name='request')
+        part.set_payload(json.dumps(metadata))
+        multipart.attach(part)
+
+        part = MIMEBase('application', 'octet-stream')
+        part.add_header('Content-Disposition', 'form-data', name='files', filename=path)
+        if isinstance(content, str):
+            content = content.encode(encoding)
+        part.set_payload(content)
+        multipart.attach(part)
+
+        data = multipart.as_bytes()  # must be called before accessing multipart['Content-Type']
+        headers = {
+            'Accept': 'application/json',
+            'Content-Type': multipart['Content-Type'],
+        }
+        response = self._request_raw('POST', '/v1/files', None, headers, data)
+        self._ensure_content_type(response.headers, 'application/json')
+        resp = _json_loads(response.read())
+        self._raise_on_path_error(resp, path)
+
+    @staticmethod
+    def _make_auth_dict(permissions, user_id, user, group_id, group) -> typing.Dict:
+        d = {}
+        if permissions is not None:
+            d['permissions'] = format(permissions, '03o')
+        if user_id is not None:
+            d['user-id'] = user_id
+        if user is not None:
+            d['user'] = user
+        if group_id is not None:
+            d['group-id'] = group_id
+        if group is not None:
+            d['group'] = group
+        return d
+
+    def list_files(self, path: str, *, pattern: str = None,
+                   itself: bool = False) -> typing.List[FileInfo]:
+        """Return list of file information from given path on remote system.
+
+        Args:
+            path: Path of the directory to list, or path of the file to return
+                information about.
+            pattern: If specified, filter the list to just the files that match,
+                for example "*.txt".
+            itself: If path refers to a directory, return information about the
+                directory itself, rather than its contents.
+        """
+        query = {
+            'action': 'list',
+            'path': path,
+        }
+        if pattern:
+            query['pattern'] = pattern
+        if itself:
+            query['itself'] = 'true'
+        resp = self._request('GET', '/v1/files', query)
+        result = resp['result'] or []  # in case it's null instead of []
+        return [FileInfo.from_dict(d) for d in result]
+
+    def make_dir(
+            self, path: str, *, make_parents: bool = False, permissions: int = None,
+            user_id: int = None, user: str = None, group_id: int = None, group: str = None):
+        """Create a directory on the remote system with the given attributes.
+
+        Args:
+            path: Path of the directory to create on the remote system.
+            make_parents: If True, create parent directories if they don't exist.
+            permissions: Permissions (mode) to create directory with (Pebble
+                default is 0o755).
+            user_id: UID for directory.
+            user: Username for directory (user_id takes precedence).
+            group_id: GID for directory.
+            group: Group name for directory (group_id takes precedence).
+        """
+        info = self._make_auth_dict(permissions, user_id, user, group_id, group)
+        info['path'] = path
+        if make_parents:
+            info['make-parents'] = True
+        body = {
+            'action': 'make-dirs',
+            'dirs': [info],
+        }
+        resp = self._request('POST', '/v1/files', None, body)
+        self._raise_on_path_error(resp, path)
+
+    def remove_path(self, path: str, *, recursive: bool = False):
+        """Remove a file or directory on the remote system.
+
+        Args:
+            path: Path of the file or directory to delete from the remote system.
+            recursive: If True, recursively delete path and everything under it.
+        """
+        info = {'path': path}
+        if recursive:
+            info['recursive'] = True
+        body = {
+            'action': 'remove',
+            'paths': [info],
+        }
+        resp = self._request('POST', '/v1/files', None, body)
+        self._raise_on_path_error(resp, path)


Test patch:
diff --git a/test/pebble_cli.py b/test/pebble_cli.py
index 954d285..bbfd3b1 100644
--- a/test/pebble_cli.py
+++ b/test/pebble_cli.py
@@ -57,8 +57,36 @@ def main():
                    choices=[s.value for s in pebble.ChangeState], default='all')
     p.add_argument('--service', help='optional service name to filter on')
 
+    p = subparsers.add_parser('ls', help='list files')
+    p.add_argument('-d', '--directory', action='store_true',
+                   help='list directories themselves, not their contents')
+    p.add_argument('-p', '--pattern', help='glob pattern to filter results')
+    p.add_argument('path', help='name of directory or file')
+
+    p = subparsers.add_parser('mkdir', help='create directory')
+    p.add_argument('-p', '--parents', action='store_true',
+                   help='create parent directories if needed')
+    p.add_argument('path', help='path to create')
+
     p = subparsers.add_parser('plan', help='show configuration plan (combined layers)')
 
+    p = subparsers.add_parser('pull', help='copy file from remote system')
+    p.add_argument('remote_path', help='path of remote file')
+    p.add_argument('local_path', help='path of local file to copy to')
+
+    p = subparsers.add_parser('push', help='copy file to remote system')
+    p.add_argument('-d', '--dirs', action='store_true', help='create parent directories')
+    p.add_argument('-m', '--mode', help='3-digit octal permissions')
+    p.add_argument('-u', '--user', help='user to set')
+    p.add_argument('-g', '--group', help='group to set')
+    p.add_argument('local_path', help='path of local file')
+    p.add_argument('remote_path', help='path of remote file to copy to')
+
+    p = subparsers.add_parser('rm', help='remove path')
+    p.add_argument('-r', '--recursive', action='store_true',
+                   help='recursively delete directory contents')
+    p.add_argument('path', help='path to remove')
+
     p = subparsers.add_parser('services', help='show service status')
     p.add_argument('service', help='name of service (none means all; multiple ok)', nargs='*')
 
@@ -111,8 +139,33 @@ def main():
         elif args.command == 'changes':
             result = client.get_changes(select=pebble.ChangeState(args.select),
                                         service=args.service)
+        elif args.command == 'ls':
+            result = client.list_files(args.path, pattern=args.pattern, itself=args.directory)
+        elif args.command == 'mkdir':
+            client.make_dir(args.path, make_parents=bool(args.parents))
+            result = 'created remote directory {}'.format(args.path)
         elif args.command == 'plan':
             result = client.get_plan().to_yaml()
+        elif args.command == 'pull':
+            content = client.read_content(args.remote_path, encoding=None)
+            if args.local_path != '-':
+                with open(args.local_path, 'wb') as f:
+                    f.write(content)
+                result = 'wrote remote file {} to {}'.format(args.remote_path, args.local_path)
+            else:
+                sys.stdout.buffer.write(content)
+                return
+        elif args.command == 'push':
+            with open(args.local_path, 'rb') as f:
+                content = f.read()
+            client.write_content(
+                args.remote_path, content, make_dirs=args.dirs,
+                permissions=int(args.mode, 8) if args.mode is not None else None,
+                user=args.user, group=args.group)
+            result = 'wrote {} to remote file {}'.format(args.local_path, args.remote_path)
+        elif args.command == 'rm':
+            client.remove_path(args.path, recursive=bool(args.recursive))
+            result = 'removed remote path {}'.format(args.path)
         elif args.command == 'services':
             result = client.get_services(args.service)
         elif args.command == 'start':
diff --git a/test/test_model.py b/test/test_model.py
index 768c971..7f7e1c3 100755
--- a/test/test_model.py
+++ b/test/test_model.py
@@ -932,6 +932,78 @@ containers:
         with self.assertRaises(RuntimeError):
             self.container.get_service('s1')
 
+    def test_read_content(self):
+        self.pebble.responses.append('content1')
+        got = self.container.read_content('/path/1')
+        self.assertEqual(got, 'content1')
+        self.assertEqual(self.pebble.requests, [
+            ('read_content', '/path/1', 'utf-8'),
+        ])
+        self.pebble.requests = []
+
+        self.pebble.responses.append(b'content2')
+        got = self.container.read_content('/path/2', encoding=None)
+        self.assertEqual(got, b'content2')
+        self.assertEqual(self.pebble.requests, [
+            ('read_content', '/path/2', None),
+        ])
+
+    def test_write_content(self):
+        self.container.write_content('/path/1', 'content1')
+        self.assertEqual(self.pebble.requests, [
+            ('write_content', '/path/1', 'content1', 'utf-8', False, None,
+             None, None, None, None),
+        ])
+        self.pebble.requests = []
+
+        self.container.write_content('/path/2', b'content2', encoding=None, make_dirs=True,
+                                     permissions=0o600, user_id=12, user='bob', group_id=34,
+                                     group='staff')
+        self.assertEqual(self.pebble.requests, [
+            ('write_content', '/path/2', b'content2', None, True, 0o600, 12, 'bob', 34, 'staff'),
+        ])
+
+    def test_list_files(self):
+        self.pebble.responses.append('dummy1')
+        ret = self.container.list_files('/path/1')
+        self.assertEqual(ret, 'dummy1')
+        self.assertEqual(self.pebble.requests, [
+            ('list_files', '/path/1', None, False),
+        ])
+        self.pebble.requests = []
+
+        self.pebble.responses.append('dummy2')
+        ret = self.container.list_files('/path/2', pattern='*.txt', itself=True)
+        self.assertEqual(ret, 'dummy2')
+        self.assertEqual(self.pebble.requests, [
+            ('list_files', '/path/2', '*.txt', True),
+        ])
+
+    def test_make_dir(self):
+        self.container.make_dir('/path/1')
+        self.assertEqual(self.pebble.requests, [
+            ('make_dir', '/path/1', False, None, None, None, None, None),
+        ])
+        self.pebble.requests = []
+
+        self.container.make_dir('/path/2', make_parents=True, permissions=0o700,
+                                user_id=12, user='bob', group_id=34, group='staff')
+        self.assertEqual(self.pebble.requests, [
+            ('make_dir', '/path/2', True, 0o700, 12, 'bob', 34, 'staff'),
+        ])
+
+    def test_remove_path(self):
+        self.container.remove_path('/path/1')
+        self.assertEqual(self.pebble.requests, [
+            ('remove_path', '/path/1', False),
+        ])
+        self.pebble.requests = []
+
+        self.container.remove_path('/path/2', recursive=True)
+        self.assertEqual(self.pebble.requests, [
+            ('remove_path', '/path/2', True),
+        ])
+
 
 class MockPebbleBackend(ops.model._ModelBackend):
     def get_pebble(self, socket_path):
@@ -968,6 +1040,27 @@ class MockPebbleClient:
         self.requests.append(('get_services', names))
         return self.responses.pop(0)
 
+    def read_content(self, path, *, encoding='utf-8'):
+        self.requests.append(('read_content', path, encoding))
+        return self.responses.pop(0)
+
+    def write_content(self, path, content, *, encoding='utf-8', make_dirs=False, permissions=None,
+                      user_id=None, user=None, group_id=None, group=None):
+        self.requests.append(('write_content', path, content, encoding, make_dirs, permissions,
+                              user_id, user, group_id, group))
+
+    def list_files(self, path, *, pattern=None, itself=False):
+        self.requests.append(('list_files', path, pattern, itself))
+        return self.responses.pop(0)
+
+    def make_dir(self, path, *, make_parents=False, permissions=None, user_id=None, user=None,
+                 group_id=None, group=None):
+        self.requests.append(('make_dir', path, make_parents, permissions, user_id, user,
+                              group_id, group))
+
+    def remove_path(self, path, *, recursive=False):
+        self.requests.append(('remove_path', path, recursive))
+
 
 class TestModelBindings(unittest.TestCase):
 
diff --git a/test/test_pebble.py b/test/test_pebble.py
index aab2345..3aca792 100644
--- a/test/test_pebble.py
+++ b/test/test_pebble.py
@@ -13,7 +13,11 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+import cgi
 import datetime
+import email.parser
+import io
+import json
 import unittest
 import unittest.mock
 import unittest.util
@@ -99,6 +103,18 @@ class TestTypes(unittest.TestCase):
         self.assertIsInstance(error, pebble.Error)
         self.assertEqual(str(error), 'connerr!')
 
+    def test_protocol_error(self):
+        error = pebble.ProtocolError('protoerr!')
+        self.assertIsInstance(error, pebble.Error)
+        self.assertEqual(str(error), 'protoerr!')
+
+    def test_path_error(self):
+        error = pebble.PathError('not-found', 'thing not found')
+        self.assertIsInstance(error, pebble.Error)
+        self.assertEqual(error.kind, 'not-found')
+        self.assertEqual(error.message, 'thing not found')
+        self.assertEqual(str(error), 'not-found - thing not found')
+
     def test_api_error(self):
         body = {
             "result": {
@@ -337,6 +353,72 @@ class TestTypes(unittest.TestCase):
         self.assertEqual(change.ready_time, datetime_utc(2021, 1, 28, 14, 37, 4, 291518))
         self.assertEqual(change.spawn_time, datetime_utc(2021, 1, 28, 14, 37, 2, 247202))
 
+    def test_file_type(self):
+        self.assertEqual(list(pebble.FileType), [
+            pebble.FileType.FILE,
+            pebble.FileType.DIRECTORY,
+            pebble.FileType.SYMLINK,
+            pebble.FileType.SOCKET,
+            pebble.FileType.NAMED_PIPE,
+            pebble.FileType.DEVICE,
+            pebble.FileType.UNKNOWN,
+        ])
+        self.assertEqual(pebble.FileType.FILE.value, 'file')
+        self.assertEqual(pebble.FileType.DIRECTORY.value, 'directory')
+        self.assertEqual(pebble.FileType.SYMLINK.value, 'symlink')
+        self.assertEqual(pebble.FileType.SOCKET.value, 'socket')
+        self.assertEqual(pebble.FileType.NAMED_PIPE.value, 'named-pipe')
+        self.assertEqual(pebble.FileType.DEVICE.value, 'device')
+        self.assertEqual(pebble.FileType.UNKNOWN.value, 'unknown')
+
+    def test_file_info_init(self):
+        info = pebble.FileInfo('/etc/hosts', 'hosts', pebble.FileType.FILE, 123, 0o644,
+                               datetime_nzdt(2021, 1, 28, 14, 37, 4, 291518),
+                               12, 'bob', 34, 'staff')
+        self.assertEqual(info.path, '/etc/hosts')
+        self.assertEqual(info.name, 'hosts')
+        self.assertEqual(info.type, pebble.FileType.FILE)
+        self.assertEqual(info.size, 123)
+        self.assertEqual(info.permissions, 0o644)
+        self.assertEqual(info.last_modified, datetime_nzdt(2021, 1, 28, 14, 37, 4, 291518))
+        self.assertEqual(info.user_id, 12)
+        self.assertEqual(info.user, 'bob')
+        self.assertEqual(info.group_id, 34)
+        self.assertEqual(info.group, 'staff')
+
+    def test_file_info_from_dict(self):
+        d = {
+            'path': '/etc',
+            'name': 'etc',
+            'type': 'directory',
+            'permissions': '644',
+            'last-modified': '2021-01-28T14:37:04.291517768+13:00',
+        }
+        info = pebble.FileInfo.from_dict(d)
+        self.assertEqual(info.path, '/etc')
+        self.assertEqual(info.name, 'etc')
+        self.assertEqual(info.type, pebble.FileType.DIRECTORY)
+        self.assertEqual(info.permissions, 0o644)
+        self.assertEqual(info.last_modified, datetime_nzdt(2021, 1, 28, 14, 37, 4, 291518))
+        self.assertIs(info.user_id, None)
+        self.assertIs(info.user, None)
+        self.assertIs(info.group_id, None)
+        self.assertIs(info.group, None)
+
+        d['type'] = 'foobar'
+        d['size'] = 123
+        d['user-id'] = 12
+        d['user'] = 'bob'
+        d['group-id'] = 34
+        d['group'] = 'staff'
+        info = pebble.FileInfo.from_dict(d)
+        self.assertEqual(info.type, 'foobar')
+        self.assertEqual(info.size, 123)
+        self.assertEqual(info.user_id, 12)
+        self.assertEqual(info.user, 'bob')
+        self.assertEqual(info.group_id, 34)
+        self.assertEqual(info.group, 'staff')
+
 
 class TestPlan(unittest.TestCase):
     def test_no_args(self):
@@ -566,6 +648,18 @@ class MockClient(pebble.Client):
         self.requests.append((method, path, query, body))
         return self.responses.pop(0)
 
+    def _request_raw(self, method, path, query=None, headers=None, data=None):
+        self.requests.append((method, path, query, headers, data))
+        headers, body = self.responses.pop(0)
+        return MockHTTPResponse(headers, body)
+
+
+class MockHTTPResponse:
+    def __init__(self, headers, body):
+        self.headers = headers
+        reader = io.BytesIO(body)
+        self.read = reader.read
+
 
 class MockTime:
     """Mocked versions of time.time() and time.sleep().
@@ -1076,6 +1170,525 @@ services:
             ('GET', '/v1/services', {'names': 'svc2'}, None),
         ])
 
+    def test_read_content_str(self):
+        self.client.responses.append((
+            {'Content-Type': 'multipart/form-data; boundary=01234567890123456789012345678901'},
+            b"""
+--01234567890123456789012345678901
+Content-Disposition: form-data; name="files"; filename="/etc/hosts"
+
+127.0.0.1 localhost  # """ + b'\xf0\x9f\x98\x80' + b"""
+--01234567890123456789012345678901
+Content-Disposition: form-data; name="response"
+
+{
+    "result": [{"path": "/etc/hosts"}],
+    "status": "OK",
+    "status-code": 200,
+    "type": "sync"
+}
+--01234567890123456789012345678901--
+""",
+        ))
+
+        content = self.client.read_content('/etc/hosts')
+        self.assertEqual(content, '127.0.0.1 localhost  # 😀')
+
+        self.assertEqual(self.client.requests, [
+            ('GET', '/v1/files', {'action': 'read', 'path': '/etc/hosts'},
+                {'Accept': 'multipart/form-data'}, None),
+        ])
+
+    def test_read_content_bytes(self):
+        self.client.responses.append((
+            {'Content-Type': 'multipart/form-data; boundary=01234567890123456789012345678901'},
+            b"""
+--01234567890123456789012345678901
+Content-Disposition: form-data; name="files"; filename="/etc/hosts"
+
+127.0.0.1 localhost  # """ + b'\xf0\x9f\x98\x80' + b"""
+--01234567890123456789012345678901
+Content-Disposition: form-data; name="response"
+
+{
+    "result": [{"path": "/etc/hosts"}],
+    "status": "OK",
+    "status-code": 200,
+    "type": "sync"
+}
+--01234567890123456789012345678901--
+""",
+        ))
+
+        content = self.client.read_content('/etc/hosts', encoding=None)
+        self.assertEqual(content, b'127.0.0.1 localhost  # \xf0\x9f\x98\x80')
+
+        self.assertEqual(self.client.requests, [
+            ('GET', '/v1/files', {'action': 'read', 'path': '/etc/hosts'},
+                {'Accept': 'multipart/form-data'}, None),
+        ])
+
+    def test_read_content_path_error(self):
+        self.client.responses.append((
+            {'Content-Type': 'multipart/form-data; boundary=01234567890123456789012345678901'},
+            b"""
+--01234567890123456789012345678901
+Content-Disposition: form-data; name="response"
+
+{
+    "result": [
+        {"path": "/etc/hosts", "error": {"kind": "not-found", "message": "not found"}}
+    ],
+    "status": "OK",
+    "status-code": 200,
+    "type": "sync"
+}
+--01234567890123456789012345678901--
+""",
+        ))
+
+        with self.assertRaises(pebble.PathError) as cm:
+            self.client.read_content('/etc/hosts')
+        self.assertIsInstance(cm.exception, pebble.Error)
+        self.assertEqual(cm.exception.kind, 'not-found')
+        self.assertEqual(cm.exception.message, 'not found')
+
+        self.assertEqual(self.client.requests, [
+            ('GET', '/v1/files', {'action': 'read', 'path': '/etc/hosts'},
+                {'Accept': 'multipart/form-data'}, None),
+        ])
+
+    def test_read_content_protocol_errors(self):
+        self.client.responses.append(({'Content-Type': 'ct'}, b''))
+        with self.assertRaises(pebble.ProtocolError) as cm:
+            self.client.read_content('/etc/hosts')
+        self.assertIsInstance(cm.exception, pebble.Error)
+        self.assertEqual(str(cm.exception),
+                         "expected Content-Type 'multipart/form-data', got 'ct'")
+
+        self.client.responses.append(({'Content-Type': 'multipart/form-data'}, b''))
+        with self.assertRaises(pebble.ProtocolError) as cm:
+            self.client.read_content('/etc/hosts')
+        self.assertEqual(str(cm.exception), "invalid boundary ''")
+
+        self.client.responses.append((
+            {'Content-Type': 'multipart/form-data; boundary=01234567890123456789012345678901'},
+            b"""
+--01234567890123456789012345678901
+Content-Disposition: form-data; name="files"; filename="/bad"
+
+bad path
+--01234567890123456789012345678901--
+""",
+        ))
+        with self.assertRaises(pebble.ProtocolError) as cm:
+            self.client.read_content('/etc/hosts')
+        self.assertEqual(str(cm.exception), "path not expected: /bad")
+
+        self.client.responses.append((
+            {'Content-Type': 'multipart/form-data; boundary=01234567890123456789012345678901'},
+            b"""
+--01234567890123456789012345678901
+Content-Disposition: form-data; name="files"; filename="/etc/hosts"
+
+bad path
+--01234567890123456789012345678901--
+""",
+        ))
+        with self.assertRaises(pebble.ProtocolError) as cm:
+            self.client.read_content('/etc/hosts')
+        self.assertEqual(str(cm.exception), 'no "response" field in multipart body')
+
+    def test_write_content_str(self):
+        self.client.responses.append((
+            {'Content-Type': 'application/json'},
+            b"""
+{
+    "result": [
+        {"path": "/foo/bar"}
+    ],
+    "status": "OK",
+    "status-code": 200,
+    "type": "sync"
+}
+""",
+        ))
+
+        self.client.write_content('/foo/bar', 'content 😀')
+
+        self.assertEqual(len(self.client.requests), 1)
+        request = self.client.requests[0]
+        self.assertEqual(request[:3], ('POST', '/v1/files', None))
+
+        headers, body = request[3:]
+        content_type = headers['Content-Type']
+        req, filename, content = self._parse_write_multipart(content_type, body)
+        self.assertEqual(filename, '/foo/bar')
+        self.assertEqual(content, b'content \xf0\x9f\x98\x80')
+        self.assertEqual(req, {
+            'action': 'write',
+            'files': [{'path': '/foo/bar'}],
+        })
+
+    def test_write_content_bytes(self):
+        self.client.responses.append((
+            {'Content-Type': 'application/json'},
+            b"""
+{
+    "result": [
+        {"path": "/foo/bar"}
+    ],
+    "status": "OK",
+    "status-code": 200,
+    "type": "sync"
+}
+""",
+        ))
+
+        self.client.write_content('/foo/bar', b'content \xf0\x9f\x98\x80')
+
+        self.assertEqual(len(self.client.requests), 1)
+        request = self.client.requests[0]
+        self.assertEqual(request[:3], ('POST', '/v1/files', None))
+
+        headers, body = request[3:]
+        content_type = headers['Content-Type']
+        req, filename, content = self._parse_write_multipart(content_type, body)
+        self.assertEqual(filename, '/foo/bar')
+        self.assertEqual(content, b'content \xf0\x9f\x98\x80')
+        self.assertEqual(req, {
+            'action': 'write',
+            'files': [{'path': '/foo/bar'}],
+        })
+
+    def test_write_content_all_options(self):
+        self.client.responses.append((
+            {'Content-Type': 'application/json'},
+            b"""
+{
+    "result": [
+        {"path": "/foo/bar"}
+    ],
+    "status": "OK",
+    "status-code": 200,
+    "type": "sync"
+}
+""",
+        ))
+
+        self.client.write_content('/foo/bar', 'content', make_dirs=True, permissions=0o600,
+                                  user_id=12, user='bob', group_id=34, group='staff')
+
+        self.assertEqual(len(self.client.requests), 1)
+        request = self.client.requests[0]
+        self.assertEqual(request[:3], ('POST', '/v1/files', None))
+
+        headers, body = request[3:]
+        content_type = headers['Content-Type']
+        req, filename, content = self._parse_write_multipart(content_type, body)
+        self.assertEqual(filename, '/foo/bar')
+        self.assertEqual(content, b'content')
+        self.assertEqual(req, {
+            'action': 'write',
+            'files': [{
+                'path': '/foo/bar',
+                'make-dirs': True,
+                'permissions': '600',
+                'user-id': 12,
+                'user': 'bob',
+                'group-id': 34,
+                'group': 'staff',
+            }],
+        })
+
+    def test_write_content_uid_gid(self):
+        self.client.responses.append((
+            {'Content-Type': 'application/json'},
+            b"""
+{
+    "result": [
+        {"path": "/foo/bar"}
+    ],
+    "status": "OK",
+    "status-code": 200,
+    "type": "sync"
+}
+""",
+        ))
+
+        self.client.write_content('/foo/bar', 'content', user_id=12, group_id=34)
+
+        self.assertEqual(len(self.client.requests), 1)
+        request = self.client.requests[0]
+        self.assertEqual(request[:3], ('POST', '/v1/files', None))
+
+        headers, body = request[3:]
+        content_type = headers['Content-Type']
+        req, filename, content = self._parse_write_multipart(content_type, body)
+        self.assertEqual(filename, '/foo/bar')
+        self.assertEqual(content, b'content')
+        self.assertEqual(req, {
+            'action': 'write',
+            'files': [{
+                'path': '/foo/bar',
+                'user-id': 12,
+                'group-id': 34,
+            }],
+        })
+
+    def test_write_content_path_error(self):
+        self.client.responses.append((
+            {'Content-Type': 'application/json'},
+            b"""
+{
+    "result": [
+        {"path": "/foo/bar", "error": {"kind": "not-found", "message": "not found"}}
+    ],
+    "status": "OK",
+    "status-code": 200,
+    "type": "sync"
+}
+""",
+        ))
+
+        with self.assertRaises(pebble.PathError) as cm:
+            self.client.write_content('/foo/bar', 'content')
+        self.assertEqual(cm.exception.kind, 'not-found')
+        self.assertEqual(cm.exception.message, 'not found')
+
+        self.assertEqual(len(self.client.requests), 1)
+        request = self.client.requests[0]
+        self.assertEqual(request[:3], ('POST', '/v1/files', None))
+
+        headers, body = request[3:]
+        content_type = headers['Content-Type']
+        req, filename, content = self._parse_write_multipart(content_type, body)
+        self.assertEqual(filename, '/foo/bar')
+        self.assertEqual(content, b'content')
+        self.assertEqual(req, {
+            'action': 'write',
+            'files': [{'path': '/foo/bar'}],
+        })
+
+    def _parse_write_multipart(self, content_type, body):
+        ctype, options = cgi.parse_header(content_type)
+        self.assertEqual(ctype, 'multipart/form-data')
+        boundary = options['boundary']
+
+        # We have to manually write the Content-Type with boundary, because
+        # email.parser expects the entire multipart message with headers.
+        parser = email.parser.BytesFeedParser()
+        parser.feed(b'Content-Type: multipart/form-data; boundary=' +
+                    boundary.encode('utf-8') + b'\r\n\r\n')
+        parser.feed(body)
+        message = parser.close()
+
+        req = None
+        filename = None
+        content = None
+        for part in message.walk():
+            name = part.get_param('name', header='Content-Disposition')
+            if name == 'request':
+                req = json.loads(part.get_payload())
+            elif name == 'files':
+                # decode=True, ironically, avoids decoding bytes to str
+                content = part.get_payload(decode=True)
+                filename = part.get_filename()
+        return (req, filename, content)
+
+    def test_list_files_path(self):
+        self.client.responses.append({
+            "result": [
+                {
+                    'path': '/etc/hosts',
+                    'name': 'hosts',
+                    'type': 'file',
+                    'size': 123,
+                    'permissions': '644',
+                    'last-modified': '2021-01-28T14:37:04.291517768+13:00',
+                    'user-id': 12,
+                    'user': 'bob',
+                    'group-id': 34,
+                    'group': 'staff',
+                },
+                {
+                    'path': '/etc/nginx',
+                    'name': 'nginx',
+                    'type': 'directory',
+                    'permissions': '755',
+                    'last-modified': '2020-01-01T01:01:01.000000+13:00',
+                },
+            ],
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+        infos = self.client.list_files('/etc')
+
+        self.assertEqual(len(infos), 2)
+        self.assertEqual(infos[0].path, '/etc/hosts')
+        self.assertEqual(infos[0].name, 'hosts')
+        self.assertEqual(infos[0].type, pebble.FileType.FILE)
+        self.assertEqual(infos[0].size, 123)
+        self.assertEqual(infos[0].permissions, 0o644)
+        self.assertEqual(infos[0].last_modified, datetime_nzdt(2021, 1, 28, 14, 37, 4, 291518))
+        self.assertEqual(infos[0].user_id, 12)
+        self.assertEqual(infos[0].user, 'bob')
+        self.assertEqual(infos[0].group_id, 34)
+        self.assertEqual(infos[0].group, 'staff')
+        self.assertEqual(infos[1].path, '/etc/nginx')
+        self.assertEqual(infos[1].name, 'nginx')
+        self.assertEqual(infos[1].type, pebble.FileType.DIRECTORY)
+        self.assertEqual(infos[1].size, None)
+        self.assertEqual(infos[1].permissions, 0o755)
+        self.assertEqual(infos[1].last_modified, datetime_nzdt(2020, 1, 1, 1, 1, 1, 0))
+        self.assertIs(infos[1].user_id, None)
+        self.assertIs(infos[1].user, None)
+        self.assertIs(infos[1].group_id, None)
+        self.assertIs(infos[1].group, None)
+
+        self.assertEqual(self.client.requests, [
+            ('GET', '/v1/files', {'action': 'list', 'path': '/etc'}, None),
+        ])
+
+    def test_list_files_pattern(self):
+        self.client.responses.append({
+            "result": [],
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+
+        infos = self.client.list_files('/etc', pattern='*.conf')
+
+        self.assertEqual(len(infos), 0)
+        self.assertEqual(self.client.requests, [
+            ('GET', '/v1/files', {'action': 'list', 'path': '/etc', 'pattern': '*.conf'}, None),
+        ])
+
+    def test_list_files_itself(self):
+        self.client.responses.append({
+            "result": [],
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+
+        infos = self.client.list_files('/etc', itself=True)
+
+        self.assertEqual(len(infos), 0)
+        self.assertEqual(self.client.requests, [
+            ('GET', '/v1/files', {'action': 'list', 'path': '/etc', 'itself': 'true'}, None),
+        ])
+
+    def test_make_dir_basic(self):
+        self.client.responses.append({
+            "result": [{'path': '/foo/bar'}],
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+        self.client.make_dir('/foo/bar')
+        req = {'action': 'make-dirs', 'dirs': [{
+            'path': '/foo/bar',
+        }]}
+        self.assertEqual(self.client.requests, [
+            ('POST', '/v1/files', None, req),
+        ])
+
+    def test_make_dir_all_options(self):
+        self.client.responses.append({
+            "result": [{'path': '/foo/bar'}],
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+        self.client.make_dir('/foo/bar', make_parents=True, permissions=0o600,
+                             user_id=12, user='bob', group_id=34, group='staff')
+
+        req = {'action': 'make-dirs', 'dirs': [{
+            'path': '/foo/bar',
+            'make-parents': True,
+            'permissions': '600',
+            'user-id': 12,
+            'user': 'bob',
+            'group-id': 34,
+            'group': 'staff',
+        }]}
+        self.assertEqual(self.client.requests, [
+            ('POST', '/v1/files', None, req),
+        ])
+
+    def test_make_dir_error(self):
+        self.client.responses.append({
+            "result": [{
+                'path': '/foo/bar',
+                'error': {
+                    'kind': 'permission-denied',
+                    'message': 'permission denied',
+                },
+            }],
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+        with self.assertRaises(pebble.PathError) as cm:
+            self.client.make_dir('/foo/bar')
+        self.assertIsInstance(cm.exception, pebble.Error)
+        self.assertEqual(cm.exception.kind, 'permission-denied')
+        self.assertEqual(cm.exception.message, 'permission denied')
+
+    def test_remove_path_basic(self):
+        self.client.responses.append({
+            "result": [{'path': '/boo/far'}],
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+        self.client.remove_path('/boo/far')
+        req = {'action': 'remove', 'paths': [{
+            'path': '/boo/far',
+        }]}
+        self.assertEqual(self.client.requests, [
+            ('POST', '/v1/files', None, req),
+        ])
+
+    def test_remove_path_recursive(self):
+        self.client.responses.append({
+            "result": [{'path': '/boo/far'}],
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+        self.client.remove_path('/boo/far', recursive=True)
+
+        req = {'action': 'remove', 'paths': [{
+            'path': '/boo/far',
+            'recursive': True,
+        }]}
+        self.assertEqual(self.client.requests, [
+            ('POST', '/v1/files', None, req),
+        ])
+
+    def test_remove_path_error(self):
+        self.client.responses.append({
+            "result": [{
+                'path': '/boo/far',
+                'error': {
+                    'kind': 'generic-file-error',
+                    'message': 'some other error',
+                },
+            }],
+            'status': 'OK',
+            'status-code': 200,
+            'type': 'sync',
+        })
+        with self.assertRaises(pebble.PathError) as cm:
+            self.client.remove_path('/boo/far')
+        self.assertIsInstance(cm.exception, pebble.Error)
+        self.assertEqual(cm.exception.kind, 'generic-file-error')
+        self.assertEqual(cm.exception.message, 'some other error')
+
 
 class TestSocketClient(unittest.TestCase):
     @unittest.skipIf(sys.platform == 'win32', "Unix sockets don't work on Windows")