Source code for stencila.host

import collections
import datetime
import json
import os
import platform
import random
import signal
import string
import subprocess
import sys
import tempfile
import time

from .version import __version__
from .python_context import PythonContext
from .sqlite_context import SqliteContext
from .host_http_server import HostHttpServer

# Resource types available
TYPES = {
    'PythonContext': PythonContext,
    'SqliteContext': SqliteContext
}
# Resource types specifications
TYPES_SPECS = {
    name: clas.spec for (name, clas) in TYPES.items()
}


[docs]class Host(object): """ A `Host` allows you to create, get, run methods of, and delete instances of various types. The types can be thought of a "services" provided by the host e.g. `NoteContext`, `FilesystemStorer` The API of a host is similar to that of a HTTP server. It's methods names (e.g. `post`, `get`) are similar to HTTP methods (e.g. `POST`, `GET`) but the sematics sometimes differ (e.g. a host's `put()` method is used to call an instance method) A `Host` is not limited to beng served by HTTP and it's methods are exposed by both `HostHttpServer` and `HostWebsocketServer`. Those other classes are responsible for tasks associated with their communication protocol (e.g. serialising and deserialising objects). This is a singleton class. There should only ever be one `Host` in memory in each process (although, for purposes of testing, this is not enforced) """ def __init__(self): self._id = 'py-' + ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(64)) self._servers = {} self._started = None self._heartbeat = None self._instances = {} @property def id(self): return self._id
[docs] def user_dir(self): """ Get the current user's Stencila data directory. This is the directory that Stencila configuration settings, such as the installed Stencila hosts, and document buffers get stored. """ osn = platform.system().lower() if osn == 'darwin': return os.path.join(os.getenv("HOME"), 'Library', 'Application Support', 'Stencila') elif osn == 'linux': return os.path.join(os.getenv("HOME"), '.local', 'share', 'stencila') elif osn == 'windows': return os.path.join(os.getenv("APPDATA"), 'Stencila') else: return os.path.join(os.getenv("HOME"), 'stencila')
[docs] def temp_dir(self): """ Get the current Stencila temporary directory """ return os.path.join(tempfile.gettempdir(), 'stencila')
[docs] def environ(self): """ Get the environment of this host including the version of Node.js and versions of installed packages (local and globals) :returns: The environment as a dictionary of dictionaries """ # TODO dictionary of loaded package names and versions return { 'version': sys.version, 'platform': platform.system().lower(), 'arch': platform.machine(), 'packages': {} }
[docs] def manifest(self): """ Get a manifest for this host The manifest describes the host and it's capabilities. It is used by peer hosts to determine which "types" this host provides and which "instances" have already been instantiated. :returns: A manifest object """ od = collections.OrderedDict manifest = od([ ('stencila', od([ ('package', 'py'), ('version', __version__) ])), ('run', [sys.executable, '-c', 'import stencila; stencila.run(echo=True)']), ('types', TYPES_SPECS), # For compatability with 0.27 API ('schemes', { 'new': TYPES_SPECS }) ]) if self._started: manifest.update([ ('id', self._id), ('process', os.getpid()), ('servers', self.servers), ('instances', list(self._instances.keys())), # For compatability with 0.27 API ('urls', self.urls) ]) return manifest
[docs] def install(self): """ Installation of a host involves creating a file `py.json` inside of the user's Stencila data (see `user_dir()`) directory which describes the capabilities of this host. """ dir = os.path.join(self.user_dir(), 'hosts') if not os.path.exists(dir): os.makedirs(dir) with open(os.path.join(dir, 'py.json'), 'w') as file: file.write(json.dumps(self.manifest(), indent=True))
[docs] def post(self, type, name=None, options={}): """ Create a new instance of a type :param type: Type of instance :param name: Name of new instance :param options: Additional arguments to pass to constructor :returns: Address of newly created instance """ Class = TYPES.get(type) if Class: instance = Class(**options) if not name: name = ''.join(random.choice(string.ascii_lowercase + string.digits) for char in range(12)) address = 'name://' + name self._instances[address] = instance return address else: raise Exception('Unknown type: %s' % type)
[docs] def get(self, id): """ Get an instance :param id: ID of instance :returns: The instance """ instance = self._instances.get(id) if instance: return instance else: raise Exception('Unknown instance: %s' % id)
[docs] def put(self, id, method, kwargs={}): """ Call a method of an instance :param id: ID of instance :param method: Name of instance method :param kwargs: A dictionary of method arguments :returns: Result of method call """ instance = self._instances.get(id) if instance: try: func = getattr(instance, method) except AttributeError: raise Exception('Unknown method: %s' % method) else: return func(**kwargs) else: raise Exception('Unknown instance: %s' % id)
[docs] def delete(self, id): """ Delete an instance :param id: ID of instance """ if id in self._instances: del self._instances[id] else: raise Exception('Unknown instance: %s' % id)
[docs] def start(self, address='127.0.0.1', port=2000, authorization=True, quiet=False): """ Start serving this host Currently, HTTP is the only server available for hosts. We plan to implement a `HostWebsocketServer` soon. :returns: self """ if 'http' not in self._servers: # Start HTTP server server = HostHttpServer(self, address, port, authorization) self._servers['http'] = server server.start() # Record start times self._started = datetime.datetime.now() self._heartbeat = datetime.datetime.now() # Register as a running host by creating a run file dir = os.path.join(self.temp_dir(), 'hosts') if not os.path.exists(dir): os.makedirs(dir) with open(os.path.join(dir, self.id + '.json'), 'w', 0o600) as file: file.write(json.dumps(self.manifest(), indent=True)) if not quiet: urls = [s.ticketed_url() for s in self._servers.values()] print('Host has started at: %s' % ', '.join(urls)) return self
[docs] def stop(self, quiet=False): """ Stop serving this host :returns: self """ server = self._servers.get('http') if server: server.stop() del self._servers['http'] # Deregister as a running host path = os.path.join(self.temp_dir(), 'hosts', self.id + '.json') if os.path.exists(path): os.remove(path) if not quiet: print('Host has stopped') return self
[docs] def run(self, address='127.0.0.1', port=2000, authorization=True, quiet=False, echo=False): """ Start serving this host and wait for connections indefinitely """ if echo: quiet = True self.start(address=address, port=port, authorization=authorization, quiet=quiet) if echo: print(json.dumps(self.manifest(), indent=True)) sys.stdout.flush() if not quiet: print('Use Ctrl+C to stop') # Handle SIGINT if this process is killed somehow other than by # Ctrl+C (e.g. by a parent peer) def stop(signum, frame): self.stop() sys.exit(0) signal.signal(signal.SIGINT, stop) while True: try: time.sleep(0x7FFFFFFF) except KeyboardInterrupt: self.stop() break
@property def servers(self): servers = {} for name in self._servers.keys(): server = self._servers[name] servers[name] = { 'url': server.url, 'ticket': server.ticket_create() } return servers @property def urls(self): return [server.url for server in self._servers.values()]
[docs] def view(self): # pragma: no cover """ View this host in the browser Opens the default browser at the URL of this host """ self.start() url = self._servers['http'].url if platform.system() == 'Linux': subprocess.call('2>/dev/null 1>&2 xdg-open "%s"' % url, shell=True) else: subprocess.call('open "%s"' % url, shell=True)
host = Host()