Source code for neokit

#!/usr/bin/env python
# -*- encoding: utf-8 -*-

# Copyright 2011-2016, Nigel Small
#
# 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.


"""
********************************************
``neokit`` -- Command Line Toolkit for Neo4j
********************************************

Neokit is a standalone module for managing one or more Neo4j server
installations. The Neokit classes may be used programmatically but
will generally be invoked via the command line interface. If Neokit
has been installed as part of the Py2neo package, the command line
tool will be available as `neokit`; otherwise, it can be called
as a Python module: `python -m neokit`.


Command Line Usage
==================

Installing a Neo4j archive
--------------------------
::

    $ neokit install 3.0


API
===
"""


from argparse import ArgumentParser
from base64 import b64encode
from contextlib import contextmanager
from json import dumps as json_dumps
from os import chdir, curdir, getenv, linesep, listdir, makedirs, rename
from os.path import basename, exists as path_exists, expanduser, isdir, isfile, join as path_join, abspath
import re
from shutil import rmtree
from socket import create_connection
from subprocess import call, check_call, check_output, CalledProcessError
from sys import argv, stdout, stderr
from tarfile import TarFile, ReadError
from textwrap import dedent
from time import sleep
try:
    from urllib.request import Request, urlopen, urlretrieve, HTTPError
except ImportError:
    from urllib import urlretrieve
    from urllib2 import Request, urlopen, HTTPError

try:
    from configparser import SafeConfigParser

    class PropertiesParser(SafeConfigParser):

        def read_properties(self, filename, section=None):
            if not section:
                b = basename(filename)
                if b.endswith(".properties"):
                    section = b[:-11]
                else:
                    section = b
            with open(filename) as f:
                data = f.read()
            self.read_string("[%s]\n%s" % (section, data), filename)

except ImportError:
    from ConfigParser import SafeConfigParser
    from io import StringIO
    from codecs import open as codecs_open
    from os import SEEK_SET

[docs] class PropertiesParser(SafeConfigParser): def read_properties(self, filename, section=None): if not section: b = basename(filename) if b.endswith(".properties"): section = b[:-11] else: section = b data = StringIO() data.write("[%s]\n" % section) with codecs_open(filename, encoding="utf-8") as f: data.write(f.read()) data.seek(0, SEEK_SET) self.readfp(data)
SERVER_AUTH_FAILURE = 9 SERVER_NOT_RUNNING = 10 SERVER_ALREADY_RUNNING = 11 number_in_brackets = re.compile("\[(\d+)\]") editions = [ "community", "enterprise", ] versions = [ "2.0.0", "2.0.1", "2.0.2", "2.0.3", "2.0.4", "2.1.2", "2.1.3", "2.1.4", "2.1.5", "2.1.6", "2.1.7", "2.1.8", "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.2.4", "2.2.5", "2.2.6", "2.2.7", "2.2.8", "2.2.8", "2.2.10", "2.3.0", "2.3.1", "2.3.2", "2.3.3", "2.3.4", "2.3.5", "2.3.6", "3.0.0", "3.0.1", "3.0.2", "3.0.3", "3.1.0-M05", ] version_aliases = { "2.0": "2.0.4", "2.0-LATEST": "2.0.4", "2.1": "2.1.8", "2.1-LATEST": "2.1.8", "2.2": "2.2.10", "2.2-LATEST": "2.2.10", "2.3": "2.3.6", "2.3-LATEST": "2.3.6", "3.0": "3.0.3", "3.0-LATEST": "3.0.3", "LATEST": "3.0.3", "3.1-MILESTONE": "3.1.0-M05", "MILESTONE": "3.1.0-M05", } dist = "http://dist.neo4j.org" dist_overrides = { # "3.0.0-NIGHTLY": "http://alpha.neohq.net/dist", } @contextmanager def move_file(file_name): temp_file_name = file_name + ".backup" try: rename(file_name, temp_file_name) except OSError: renamed = False else: renamed = True yield temp_file_name if renamed: rename(temp_file_name, file_name)
[docs]class Unauthorized(Exception): """ Raised when auth fails. """
[docs]class Package(object): """ Represents a Neo4j archive. """ def __init__(self, edition=None, version=None): edition = edition.lower() if edition else "community" if edition in editions: self.edition = edition else: raise ValueError("Unknown edition %r" % edition) version = version.upper() if version else "LATEST" self.snapshot = "SNAPSHOT" in version if version in version_aliases: version = version_aliases[version] if version in versions: self.version = version else: raise ValueError("Unknown version %r" % version) @property def key(self): """ The unique key that identifies the archive, e.g. ``community-2.3.2``. """ return "%s-%s" % (self.edition, self.version) @property def name(self): """ The full name of the archive file, e.g. ``neo4j-community-2.3.2-unix.tar.gz``. """ return "neo4j-%s-unix.tar.gz" % self.key @property def uri(self): """ The URI from which this archive may be downloaded, e.g. ``http://dist.neo4j.org/neo4j-community-2.3.2-unix.tar.gz``. """ if self.version in dist_overrides: return "%s/%s" % (dist_overrides[self.version], self.name) else: return "%s/%s" % (dist, self.name)
[docs] def download(self, path=".", overwrite=False): """ Download a Neo4j distribution to the specified path. :param path: :param overwrite: :return: the name of the downloaded file """ file_name = path_join(path, self.name) if overwrite: if path_exists(file_name) and not isfile(file_name): raise IOError("Cannot overwrite directory %r" % file_name) elif not self.snapshot and path_exists(file_name): return file_name try: makedirs(path) except OSError: pass urlretrieve(self.uri, file_name) return file_name
[docs]class Warehouse(object): """ A local storage area for Neo4j installations. """ def __init__(self, home=None): self.home = home or getenv("NEOKIT_HOME") or expanduser("~/.neokit") self.dist = path_join(self.home, "dist") self.run = path_join(self.home, "run")
[docs] def get(self, name): """ Obtain a Neo4j installation by name. :param name: :return: """ container = path_join(self.run, name) for dir_name in listdir(container): dir_path = path_join(container, dir_name) if isdir(dir_path): return GraphServer(dir_path) raise IOError("Could not locate server directory")
[docs] def install(self, name, edition=None, version=None): """ Install Neo4j. :param name: :param edition: :param version: :return: """ container = path_join(self.run, name) rmtree(container, ignore_errors=True) makedirs(container) archive_file = Package(edition, version).download(self.dist) try: with TarFile.open(archive_file, "r:gz") as archive: archive.extractall(container) except ReadError: # The tarfile module sometimes has trouble with certain tar # files for unknown reasons. This workaround falls back to # command line. check_call(["tar", "x", "-C", container, "-f", archive_file]) return self.get(name)
[docs] def uninstall(self, name): """ Remove a Neo4j installation. :param name: :return: """ container = path_join(self.run, name) rmtree(container)
[docs] def directory(self): """ Fetch a dictionary of :class:`.GraphServer` objects, keyed by name, for all available Neo4j installations. """ try: return {name: self.get(name) for name in listdir(self.run) if not name.startswith(".")} except OSError: return {}
[docs] def rename(self, name, new_name): """ Rename a Neo4j installation. :param name: :param new_name: :return: """ rename(path_join(self.run, name), path_join(self.run, new_name))
[docs]class GraphServer(object): """ A Neo4j server installation. """ config_file = None # overridden in subclasses default_http_port = 7474 def __new__(cls, home=None): home = home or abspath(curdir) instance = super(GraphServer, cls).__new__(cls) # Here follows a dirty hack to find out which version of Neo4j we're running. # If you are of a nervous disposition, look away now. lib = path_join(home, "lib") kernel_jars = [f for f in listdir(lib) if f.startswith("neo4j-kernel-") and f.endswith(".jar")] if kernel_jars: kernel_jar = kernel_jars[0] kernel_version = kernel_jar[13:-4] major_version = int(kernel_version.partition(".")[0]) if major_version >= 3: instance.__class__ = GraphServerV3 else: instance.__class__ = GraphServerV2 else: # Kernel jar not found, assume 3.0+ instance.__class__ = GraphServerV3 return instance def __init__(self, home=None): self.home = home or abspath(curdir) def __repr__(self): return "<%s home=%r>" % (self.__class__.__name__, self.home) @property def control_script(self): """ The file name of the control script for this server installation. """ return path_join(self.home, "bin", "neo4j") @property def store_path(self): """ The location of the graph database store on disk. """ return NotImplemented
[docs] def config(self, key, default=None): """ Retrieve the value of a configuration item. :param key: :param default: :return: """ config_file_path = path_join(self.home, "conf", self.config_file) with open(config_file_path, "r") as f_in: for line in f_in: if line.startswith(key + "="): return line.strip().partition("=")[-1] return default
[docs] def set_config(self, key, value): """ Update a single configuration value. :param key: :param value: """ self.update_config({key: value})
[docs] def update_config(self, properties): """ Update multiple configuration values. :param properties: """ config_file_path = path_join(self.home, "conf", self.config_file) with open(config_file_path, "r") as f_in: lines = f_in.readlines() with open(config_file_path, "w") as f_out: for line in lines: for key, value in properties.items(): if line.startswith(key + "=") or \ (line.startswith("#") and line[1:].lstrip().startswith(key + "=")): if value is True: value = "true" if value is False: value = "false" f_out.write("%s=%s\n" % (key, value)) break else: f_out.write(line)
@property def auth_enabled(self): """ Settable boolean property for enabling and disabling auth on this server. """ return self.config("dbms.security.auth_enabled") == "true" @auth_enabled.setter def auth_enabled(self, value): self.set_config("dbms.security.auth_enabled", value)
[docs] def update_password(self, user, password, new_password): """ Update the password for this server. :param user: :param password: :param new_password: :return: """ request = Request("%suser/neo4j/password" % self.http_uri, json_dumps({"password": new_password}, ensure_ascii=True).encode("utf-8"), {"Authorization": "Basic " + b64encode((user + ":" + password).encode("utf-8")).decode("ascii"), "Content-Type": "application/json"}) try: urlopen(request).read() except HTTPError as error: raise Unauthorized("Cannot update password [%s]" % error)
@property def http_port(self): """ The port on which this server expects HTTP communication. """ return NotImplemented @http_port.setter def http_port(self, port): """ Set the port on which this server expects HTTP communication. """ @property def http_uri(self): """ The full HTTP URI for this server. """ return "http://localhost:%d/" % self.http_port
[docs] def delete_store(self, force=False): """ Delete the store directory for this server. :param force: """ if force or not self.running(): try: rmtree(self.store_path, ignore_errors=force) except FileNotFoundError: pass else: raise RuntimeError("Refusing to drop database store while server is running")
[docs] def start(self): """ Start the server. """ try: out = check_output("%s start" % self.control_script, shell=True) except CalledProcessError as error: if error.returncode == 2: raise OSError("Another process is listening on the server port") elif error.returncode == 512: raise OSError("Another server process is already running") else: raise OSError("An error occurred while trying to start " "the server [%s]" % error.returncode) else: pid = None for line in out.decode("utf-8").splitlines(False): if line.startswith("process"): numbers = number_in_brackets.search(line).groups() if numbers: pid = int(numbers[0]) elif "(pid " in line: pid = int(line.partition("(pid ")[-1].partition(")")[0]) running = False port = self.http_port t = 0 while not running and t < 30: try: s = create_connection(("localhost", port)) except IOError: sleep(1) t += 1 else: s.close() running = True return pid
[docs] def stop(self): """ Stop the server. """ try: check_output(("%s stop" % self.control_script), shell=True) except CalledProcessError as error: raise OSError("An error occurred while trying to stop the server " "[%s]" % error.returncode)
[docs] def restart(self): """ Restart the server. """ self.stop() return self.start()
[docs] def running(self): """ The PID of the current executing process for this server. """ try: out = check_output(("%s status" % self.control_script), shell=True) except CalledProcessError as error: if error.returncode == 3: return None else: raise OSError("An error occurred while trying to query the " "server status [%s]" % error.returncode) else: p = None for line in out.decode("utf-8").splitlines(False): if "running" in line: p = int(line.rpartition(" ")[-1]) return p
[docs] def info(self, key): """ Look up an item of server information from a running server. :param key: the key of the item to look up """ try: out = check_output("%s info" % self.control_script, shell=True) except CalledProcessError as error: if error.returncode == 3: return None else: raise OSError("An error occurred while trying to fetch server " "info [%s]" % error.returncode) else: for line in out.decode("utf-8").splitlines(False): try: colon = line.index(":") except ValueError: pass else: k = line[:colon] v = line[colon+1:].lstrip() if k == "CLASSPATH": v = v.split(":") if k == key: return v
[docs]class GraphServerV2(GraphServer): config_file = "neo4j-server.properties" @property def http_port(self): port = None if self.running(): port = self.info("NEO4J_SERVER_PORT") if port is None: port = self.config("org.neo4j.server.webserver.port") try: return int(port) except (TypeError, ValueError): return None @http_port.setter def http_port(self, port): self.set_config("org.neo4j.server.webserver.port", port) @property def store_path(self): return path_join(self.home, self.config("org.neo4j.server.database.location"))
[docs]class GraphServerV3(GraphServer): config_file = "neo4j.conf" @property def http_port(self): port = None if self.running(): port = self.info("NEO4J_SERVER_PORT") if port is None: http_address = self.config("dbms.connector.http.address") if http_address: host, _, port = http_address.partition(":") else: port = self.default_http_port try: return int(port) except (TypeError, ValueError): return None @http_port.setter def http_port(self, port): http_address = self.config("dbms.connector.http.address") if http_address: host, _, _ = http_address.partition(":") else: host = "localhost" self.set_config("dbms.connector.http.address", "%s:%d" % (host, port)) @property def store_path(self): return path_join(self.home, "data", "databases", self.config("dbms.active_database", "graph.db"))
class Commander(object): epilog = "Report bugs to nigel@py2neo.org" def __init__(self, out=None, err=None): self.out = out or stdout self.err = err or stderr def write(self, s): self.out.write(s) def write_line(self, s): self.out.write(s) self.out.write(linesep) def write_err(self, s): self.err.write(s) def write_err_line(self, s): self.err.write(s) self.err.write(linesep) def usage(self, script): script = basename(script) self.write_line("usage: %s <command> <arguments>" % script) self.write_line(" %s help <command>" % script) self.write_line("") self.write_line("commands:") for attr in sorted(dir(self)): method = getattr(self, attr) if callable(method) and not attr.startswith("_") and method.__doc__: doc = dedent(method.__doc__).strip() self.write_line(" " + doc[6:].strip()) self.write_line("") self.write_line( "Many commands can take '.' as a server name. This operates on the server\n" "located in the current directory. For example:\n" "\n" " neokit disable-auth .") if self.epilog: self.write_line("") self.write_line(self.epilog) def execute(self, *args): try: command = args[1] except IndexError: self.usage(args[0]) return command = command.replace("-", "_") if command == "help": command = args[2] args = [args[0], command, "--help"] try: method = getattr(self, command) except AttributeError: self.write_err_line("Unknown command %r" % command) exit(1) else: try: return method(*args[1:]) or 0 except Exception as err: self.write_err_line("Error: %s" % err) exit(1) def parser(self, script): return ArgumentParser(prog=script, epilog=self.epilog) def versions(self, *args): """ usage: versions """ parser = self.parser(args[0]) parser.description = "List all available Neo4j versions" parser.parse_args(args[1:]) for version in versions: self.write(version) aliases = [] for alias, original in version_aliases.items(): if original == version: aliases.append(alias) if aliases: self.write(" (%s)" % ", ".join(sorted(aliases))) self.write(linesep) def download(self, *args): """ usage: download [<version>] """ parser = self.parser(args[0]) parser.description = "Download a Neo4j server package" parser.add_argument("version", nargs="?", help="Neo4j version") parsed = parser.parse_args(args[1:]) self.write_line(Package(version=parsed.version).download()) def install(self, *args): """ usage: install <server> [<version>] """ parser = self.parser(args[0]) parser.description = "Install a Neo4j server" parser.add_argument("server", help="server name") parser.add_argument("version", nargs="?", help="Neo4j version") parsed = parser.parse_args(args[1:]) server = Warehouse().install(parsed.server, version=parsed.version) self.write_line(server.home) def uninstall(self, *args): """ usage: uninstall <server> """ parser = self.parser(args[0]) parser.description = "Uninstall a Neo4j server" parser.add_argument("server", help="server name") parsed = parser.parse_args(args[1:]) server_name = parsed.server warehouse = Warehouse() server = warehouse.get(server_name) if server.running(): server.stop() warehouse.uninstall(server_name) def list(self, *args): """ usage: list """ parser = self.parser(args[0]) parser.description = "List all installed Neo4j servers" parser.parse_args(args[1:]) for name in sorted(Warehouse().directory()): self.write_line(name) def rename(self, *args): """ usage: rename <server> <new-name> """ parser = self.parser(args[0]) parser.description = "Rename a Neo4j server" parser.add_argument("server", help="server name") parser.add_argument("new_name", help="new server name") parsed = parser.parse_args(args[1:]) Warehouse().rename(parsed.server, parsed.new_name) def start(self, *args): """ usage: start <server> """ parser = self.parser(args[0]) parser.description = "Start a Neo4j server" parser.add_argument("server", help="server name") parsed = parser.parse_args(args[1:]) if parsed.server == ".": server = GraphServer() else: server = Warehouse().get(parsed.server) if server.running(): self.write_err_line("Server already running") return SERVER_ALREADY_RUNNING else: pid = server.start() self.write_line("%d" % pid) def stop(self, *args): """ usage: stop <server> """ parser = self.parser(args[0]) parser.description = "Stop a Neo4j server" parser.add_argument("server", help="server name") parsed = parser.parse_args(args[1:]) if parsed.server == ".": server = GraphServer() else: server = Warehouse().get(parsed.server) if server.running(): server.stop() else: self.write_err_line("Server not running") return SERVER_NOT_RUNNING def restart(self, *args): """ usage: restart <server> """ parser = self.parser(args[0]) parser.description = "Start or restart a Neo4j server" parser.add_argument("server", help="server name") parsed = parser.parse_args(args[1:]) if parsed.server == ".": server = GraphServer() else: server = Warehouse().get(parsed.server) if server.running(): pid = server.restart() else: pid = server.start() self.write_line("%d" % pid) def run(self, *args): """ usage: run <server> <command> """ parser = self.parser(args[0]) parser.description = "Run a command against a Neo4j server" parser.add_argument("server", help="server name") parser.add_argument("command", nargs="+", help="command to run") parsed = parser.parse_args(args[1:]) with move_file(path_join(expanduser("~"), ".neo4j", "known_hosts")): if parsed.server == ".": server = GraphServer() else: server = Warehouse().get(parsed.server) if server.running(): self.write_err_line("Server already running") return SERVER_ALREADY_RUNNING else: server.start() try: return call(parsed.command) finally: server.stop() def enable_auth(self, *args): """ usage: enable-auth <server> """ parser = self.parser(args[0]) parser.description = "Enable auth on a Neo4j server" parser.add_argument("server", help="server name") parsed = parser.parse_args(args[1:]) if parsed.server == ".": server = GraphServer() else: server = Warehouse().get(parsed.server) server.auth_enabled = True def disable_auth(self, *args): """ usage: disable-auth <server> """ parser = self.parser(args[0]) parser.description = "Disable auth on a Neo4j server" parser.add_argument("server", help="server name") parsed = parser.parse_args(args[1:]) if parsed.server == ".": server = GraphServer() else: server = Warehouse().get(parsed.server) server.auth_enabled = False def update_password(self, *args): """ usage: update-password <server> <user> <password> <new_password> """ parser = self.parser(args[0]) parser.description = "Update a password for a Neo4j server" parser.add_argument("server", help="server name") parser.add_argument("user", help="user name") parser.add_argument("password", help="current password") parser.add_argument("new_password", help="new password") parsed = parser.parse_args(args[1:]) if parsed.server == ".": server = GraphServer() else: server = Warehouse().get(parsed.server) already_running = server.running() if not already_running: server.start() try: server.update_password(parsed.user, parsed.password, parsed.new_password) except Unauthorized as error: self.write_err_line("%s" % error) return SERVER_AUTH_FAILURE finally: if not already_running: server.stop() def main(args=None, out=None, err=None): exit_status = Commander(out, err).execute(*args or argv) exit(exit_status) if __name__ == "__main__": main()