text: import os
  import string
  
  import requests
  from requests.exceptions import HTTPError
  
  from .auth import MythicAuth
  from .pi import Pi
  from .utils import parse_ssh_keys
  from .exc import HostedPiException
  
  
  class PiCloud:
      """
      A connection to the Mythic Beasts Pi Cloud API for creating and managing
      cloud Pi services.
  
      Set up API keys at https://www.mythic-beasts.com/customer/api-users
  
      :type api_id: str or None
      :param api_id:
          Your Mythic Beasts API ID (alternatively, the environment variable
          ``HOSTEDPI_ID`` can be used)
  
      :type api_secret: str or None
      :param api_secret:
          Your Mythic Beasts API secret (alternatively, the environment variable
          ``HOSTEDPI_SECRET`` can be used)
  
      :type ssh_keys: list or set or None
      :param ssh_keys:
          A list/set of SSH key strings (keyword-only argument)
  
      :type ssh_key_path: str or None
      :param ssh_key_path:
          The path to your SSH public key (keyword-only argument)
  
      :type ssh_import_github: list or set or None
      :param ssh_import_github:
          A list/set of GitHub usernames to import SSH keys from (keyword-only
          argument)
  
      :type ssh_import_launchpad: list or set or None
      :param ssh_import_launchpad:
          A list/set of Launchpad usernames to import SSH keys from (keyword-only
          argument)
  
      .. note::
          If any SSH keys are provided on class initialisation, they will be used
          when creating Pis but are overriden by any passed to the
          :meth:`~hostedpi.picloud.PiCloud.create_pi` method.
  
          All SSH arguments provided will be used in combination.
      """
      _API_URL = 'https://api.mythic-beasts.com/beta/pi'
  
      def __init__(self, api_id=None, api_secret=None, *, ssh_keys=None,
                   ssh_key_path=None, ssh_import_github=None,
                   ssh_import_launchpad=None):
          if api_id is None:
              api_id = os.environ.get('HOSTEDPI_ID')
  
          if api_secret is None:
              api_secret = os.environ.get('HOSTEDPI_SECRET')
  
          if api_id is None or api_secret is None:
              raise HostedPiException(
                  "Environment variables HOSTEDPI_ID and HOSTEDPI_SECRET must be "
                  "set or api_id and api_secret passed as arguments"
              )
  
          self.ssh_keys = parse_ssh_keys(ssh_keys, ssh_key_path,
                                         ssh_import_github, ssh_import_launchpad)
  
          self._auth = MythicAuth(api_id, api_secret)
  
      def __repr__(self):
          return "<PiCloud>"
  
      def __str__(self):
          "String of information about all the Pis in the account"
          print(*self.pis, sep='\n\n')
  
      @property
      def headers(self):
          return self._auth.headers
  
      @property
      def pis(self):
          "A dictionary of :class:`~hostedpi.pi.Pi` objects keyed by their names."
          url = '{self._API_URL}/servers'.format(self=self)
          r = requests.get(url, headers=self.headers)
  
          try:
              r.raise_for_status()
          except HTTPError as e:
              raise HostedPiException(e) from e
  
          pis = r.json()['servers']
  
          return {
              name: Pi(cloud=self, name=name, model=data['model'])
              for name, data in sorted(pis.items())
          }
  
      @property
      def ipv4_ssh_config(self):
          """
          A string containing the IPv4 SSH config for all Pis within the account.
          The contents could be added to an SSH config file for easy access to the
          Pis in the account.
          """
          return '\n'.join(pi.ipv4_ssh_config for pi in self.pis.values())
  
      @property
      def ipv6_ssh_config(self):
          """
          A string containing the IPv6 SSH config for all Pis within the account.
          The contents could be added to an SSH config file for easy access to the
          Pis in the account.
          """
          return '\n'.join(pi.ipv6_ssh_config for pi in self.pis.values())
  
      def create_pi(self, name, *, model=3, disk_size=10,
                    os_image=None, ssh_keys=None, ssh_key_path=None,
                    ssh_import_github=None, ssh_import_launchpad=None):
          """
          Provision a new cloud Pi with the specified name, model, disk size and
          SSH keys. Return a new :class:`~hostedpi.pi.Pi` instance.
  
          :type name: str
          :param name:
              A unique identifier for the server. This will form part of the
              hostname for the server, and must consist only of alphanumeric
              characters and hyphens.
  
          :type model: int or None
          :param model:
              The Raspberry Pi model to provision (3 or 4) - defaults to 3
              (keyword-only argument)
  
          :type disk_size: int or None
          :param disk_size:
              The amount of disk space (in GB) attached to the Pi - must be a
              multiple of 10 - defaults to 10 (keyword-only argument)
  
          :type os_image: str
          :param os_image:
              The name of the operating system image to boot from. Defaults to
              ``None`` which falls back to Mythic's default (Raspbian/PiOS
              stable). If given, this must be a string which appears as a key in
              the return value of
              :meth:`~hostedpi.picloud.PiCloud.get_operating_systems` for the
              relevant Pi model.
  
          :type ssh_keys: list or set or None
          :param ssh_keys:
              A list/set of SSH key strings (keyword-only argument)
  
          :type ssh_key_path: str or None
          :param ssh_key_path:
              The path to your SSH public key (keyword-only argument)
  
          :type ssh_import_github: list or set or None
          :param ssh_import_github:
              A list/set of GitHub usernames to import SSH keys from (keyword-only
              argument)
  
          :type ssh_import_launchpad: list or set or None
          :param ssh_import_launchpad:
              A list/set of Launchpad usernames to import SSH keys from
              (keyword-only argument)
  
          .. note::
              If any SSH keys are provided on class initialisation, they will be
              used here but are overriden by any passed to this method.
  
          .. note::
              When requesting a Pi 3, you will either get a model 3B or 3B+. It is
              not possible to request a particular model beyond 3 or 4. The Pi 4
              is the 4GB RAM model.
          """
          ssh_keys_set = parse_ssh_keys(ssh_keys, ssh_key_path, ssh_import_github,
                                        ssh_import_launchpad)
          ssh_keys_str = "\r\n".join(ssh_keys_set)
  
          server_name = name.lower()
          model = str(model)
          self._validate_server_name(server_name)
          self._validate_model(model)
          self._validate_disk_size(disk_size)
  
          url = "{self._API_URL}/servers/{name}".format(self=self, name=name)
          data = {
              'disk': disk_size,
              'model': model,
              'os_image': os_image,
          }
  
          if ssh_keys:
              data['ssh_key'] = ssh_keys_str
  
          r = requests.post(url, headers=self.headers, json=data)
  
          try:
              r.raise_for_status()
          except HTTPError as e:
              if r.status_code == 403:
                  raise HostedPiException("Not authorised to provision server") from e
              if r.status_code == 409:
                  raise HostedPiException("Server name already exists") from e
              if r.status_code == 503:
                  raise HostedPiException(
                      "Out of stock of Pi Model {model}".format(model=model)) from e
              raise HostedPiException(e) from e
  
          return Pi(cloud=self, name=name, model=model)
  
      def _validate_server_name(self, server_name):
          valid_chars = string.ascii_lowercase + string.digits + '-_'
          if not all(c in valid_chars for c in server_name):
              raise HostedPiException(
                  "Server name must consist of alphanumeric characters and "
                  "hyphens")
  
      def _validate_model(self, model):
          if model not in ('3', '4'):
              raise HostedPiException("Model must be 3 or 4")
  
      def _validate_disk_size(self, disk_size):
          if disk_size < 10 or disk_size % 10 > 0:
              raise HostedPiException("Disk size must be a multiple of 10")
  
      def get_operating_systems(self, *, model):
          """
          Return a dict of operating systems supported by the given Pi *model* (3
          or 4). Dict keys are identifiers (e.g. "raspbian-buster") which can be
          used when provisioning a new Pi with
          :meth:`~hostedpi.picloud.PiCloud.create_pi`; dict values are text labels
          of the OS/distro names (e.g. "Raspbian Buster").
  
          :type model: int
          :param model:
              The Raspberry Pi model (3 or 4) to get operating systems for
              (keyword-only argument)
          """
          model = str(model)
          if model not in ('3', '4'):
              raise HostedPiException("model must be 3 or 4")
          url = "{self._API_URL}/images/{model}".format(self=self, model=model)
          r = requests.get(url, headers=self.headers)
  
          try:
              r.raise_for_status()
          except HTTPError as e:
              raise HostedPiException(e) from e
  
          return r.json()
  