diff --git a/docs/modules/cli.rst b/docs/modules/cli.rst index c3ea7186..74f7458b 100644 --- a/docs/modules/cli.rst +++ b/docs/modules/cli.rst @@ -6,12 +6,12 @@ octoprint.cli .. automodule:: octoprint.cli :members: -.. _sec-modules-cli-devel: +.. _sec-modules-cli-dev: -octoprint.cli.devel -------------------- +octoprint.cli.dev +----------------- -.. automodule:: octoprint.cli.devel +.. automodule:: octoprint.cli.dev :members: .. _sec-modules-cli-plugins: diff --git a/docs/plugins/gettingstarted.rst b/docs/plugins/gettingstarted.rst index 3c6c60d2..7ef79fa1 100644 --- a/docs/plugins/gettingstarted.rst +++ b/docs/plugins/gettingstarted.rst @@ -10,6 +10,37 @@ Over the course of this little tutorial we'll build a full fledged, installable at some locations throughout OctoPrint and also offers some other basic functionality to give you an idea of what you can achieve with OctoPrint's plugin system. +First of all let use make sure that you have OctoPrint checked out and set up for development on your local +development environment:: + + $ cd ~/devel + $ git clone https://github.com/foosel/OctoPrint + [...] + $ cd OctoPrint + $ virtualenv venv + [...] + $ source venv/bin/activate + (venv) $ pip install -e[develop] + [...] + (venv) $ octoprint --help + Usage: octoprint [OPTIONS] COMMAND [ARGS]... + + [...] + +.. note:: + + You can also develop your plugin directly on your Raspberry Pi running OctoPi of course. In that + case please ignore the above instructions, you'll only need to activate the ``oprint`` + virtual environment: + + .. code-block:: none + + $ source ~/oprint/bin/activate + (oprint) $ octoprint --help + Usage: octoprint [OPTIONS] COMMAND [ARGS]... + + [...] + We'll start at the most basic form a plugin can take - just a couple of simple lines of Python code: .. code-block:: python @@ -24,14 +55,15 @@ We'll start at the most basic form a plugin can take - just a couple of simple l Saving this as ``helloworld.py`` in ``~/.octoprint/plugins`` yields you something resembling these log entries upon server startup:: + (venv) $ octoprint serve 2015-01-27 11:14:35,124 - octoprint.server - INFO - Starting OctoPrint 1.2.0-dev-448-gd96e56e (devel branch) [...] 2015-01-27 11:14:35,124 - octoprint.plugin.core - INFO - Loading plugins from /home/pi/.octoprint/plugins, /home/pi/OctoPrint/src/octoprint/plugins and installed plugin packages... [...] 2015-01-27 11:14:36,135 - octoprint.plugin.core - INFO - 3 plugin(s) registered with the system: - | CuraEngine (bundled) = /home/pi/OctoPrint/src/octoprint/plugins/cura - | Discovery (bundled) = /home/pi/OctoPrint/src/octoprint/plugins/discovery + [...] | Hello World (1.0.0) = /home/pi/.octoprint/plugins/helloworld.py + [...] OctoPrint found that plugin in the folder and took a look into it. The name and the version it displays in that log entry it got from the ``__plugin_name__`` and ``__plugin_version__`` lines. It also read the description from @@ -101,31 +133,35 @@ or alternatively manually utilizing Python's standard package manager ``pip`` di So let's begin. We'll use the `cookiecutter `_ template for OctoPrint plugins here, so we'll first need to install that:: - $ pip install cookiecutter + (venv) $ pip install cookiecutter -Then we can use the ``cookiecutter`` command to generate a new OctoPrint plugin skeleton for us:: +Then we can use the ``octoprint dev:plugin new`` command [#f1]_ to generate a new OctoPrint plugin skeleton for us:: - $ cookiecutter gh:OctoPrint/cookiecutter-octoprint-plugin + (venv) $ cd ~/devel + (venv) $ octoprint dev:plugin new helloworld Cloning into 'cookiecutter-octoprint-plugin'... - remote: Counting objects: 70, done. - remote: Compressing objects: 100% (17/17), done. - emote: Total 70 (delta 0), reused 0 (delta 0), pack-reused 51 - Unpacking objects: 100% (70/70), done. - Checking connectivity... done. - plugin_identifier (default is "skeleton")? helloworld - plugin_package (default is "octoprint_helloworld")? - plugin_name (default is "OctoPrint-Helloworld")? - repo_name (default is "OctoPrint-Helloworld")? - full_name (default is "You")? Your Name - email (default is "you@example.com")? you@somewhere.net - github_username (default is "you")? yourGithubName - plugin_version (default is "0.1.0")? 1.0.0 - plugin_description (default is "TODO")? A quick "Hello World" example plugin for OctoPrint - plugin_license (default is "AGPLv3")? - plugin_homepage (default is "https://github.com/yourGithubName/OctoPrint-Helloworld")? - plugin_source (default is "https://github.com/yourGithubName/OctoPrint-Helloworld")? - plugin_installurl (default is "https://github.com/yourGithubName/OctoPrint-Helloworld/archive/master.zip")? - $ cd OctoPrint-HelloWorld + remote: Counting objects: 101, done. + remote: Total 101 (delta 0), reused 0 (delta 0), pack-reused 101 + Receiving objects: 100% (101/101), 53.69 KiB, done. + Resolving deltas: 100% (35/35), done. + plugin_package [octoprint_helloworld]: + plugin_name [OctoPrint-Helloworld]: + repo_name [OctoPrint-Helloworld]: + full_name [You]: Your Name + email [you@example.com]: you@somewhere.net + github_username [you]: yourGithubName + plugin_version [0.1.0]: 1.0.0 + plugin_description [TODO]: A quick "Hello World" example plugin for OCtoPrint + plugin_license [AGPLv3]: + plugin_homepage [https://github.com/yourGithubName/OctoPrint-Helloworld]: + plugin_source [https://github.com/yourGithubName/OctoPrint-Helloworld]: + plugin_installurl [https://github.com/yourGithubName/OctoPrint-Helloworld/archive/master.zip]: + (venv) $ cd OctoPrint-HelloWorld + +.. note:: + + If ``octoprint dev:plugin new`` isn't recognized as a command (and also doesn't show up in the output of + ``octoprint --help``, make sure you installed cookiecutter into the same python environment as OctoPrint. This will create a project structure in the ``OctoPrint-HelloWorld`` folder we just changed to that looks like this:: @@ -193,9 +229,11 @@ Now all that's left to do is to move our ``helloworld.py`` into the ``octoprint_ The plugin is now ready to be installed via ``python setup.py install``. However, since we are still working on our plugin, it makes more sense to use ``python setup.py develop`` for now -- this way the plugin becomes -discoverable by OctoPrint, however we don't have to reinstall it after any changes we will still do:: +discoverable by OctoPrint, however we don't have to reinstall it after any changes we will still do. We can have the +``octoprint dev:plugin install`` command do everything for us here, it will ensure to use the python binary belonging +to your OctoPrint installation:: - $ python setup.py develop + (venv) $ octoprint dev:plugin install running develop running egg_info creating OctoPrint_HelloWorld.egg-info @@ -209,9 +247,8 @@ Restart OctoPrint. Your plugin should still be properly discovered and the log l 2015-01-27 13:43:34,134 - octoprint.plugin.core - INFO - Loading plugins from /home/pi/.octoprint/plugins, /home/pi/OctoPrint/src/octoprint/plugins and installed plugin packages... [...] 2015-01-27 13:43:34,818 - octoprint.plugin.core - INFO - 3 plugin(s) registered with the system: - | CuraEngine (bundled) = /home/pi/OctoPrint/src/octoprint/plugins/cura - | Discovery (bundled) = /home/pi/OctoPrint/src/octoprint/plugins/discovery - | Hello World (1.0.0) = /home/pi/OctoPrint-HelloWorld/octoprint_helloworld + [...] + | Hello World (1.0.0) = /home/pi/devel/OctoPrint-HelloWorld/octoprint_helloworld [...] 2015-01-27 13:43:38,997 - octoprint.plugins.helloworld - INFO - Hello World! @@ -257,9 +294,9 @@ and ``__plugin_description__`` from ``__init__.py``: and restart OctoPrint:: 2015-01-27 13:46:33,786 - octoprint.plugin.core - INFO - 3 plugin(s) registered with the system: - | CuraEngine (bundled) = /home/pi/OctoPrint/src/octoprint/plugins/cura - | Discovery (bundled) = /home/pi/OctoPrint/src/octoprint/plugins/discovery - | OctoPrint-HelloWorld (1.0.0) = /home/pi/OctoPrint-HelloWorld/octoprint_helloworld + [...] + | OctoPrint-HelloWorld (1.0.0) = /home/pi/devel/OctoPrint-HelloWorld/octoprint_helloworld + [...] Our "Hello World" Plugin still gets detected fine, but it's now listed under the same name it's installed under, "OctoPrint-HelloWorld". That's a bit redundant and squashed, so we'll override that bit via ``__plugin_name__`` again: @@ -284,9 +321,9 @@ Our "Hello World" Plugin still gets detected fine, but it's now listed under the Restart OctoPrint again:: 2015-01-27 13:48:54,122 - octoprint.plugin.core - INFO - 3 plugin(s) registered with the system: - | CuraEngine (bundled) = /home/pi/OctoPrint/src/octoprint/plugins/cura - | Discovery (bundled) = /home/pi/OctoPrint/src/octoprint/plugins/discovery + [...] | Hello World (1.0.0) = /home/pi/OctoPrint-HelloWorld/octoprint_helloworld + [...] Much better! You can override pretty much all of the metadata defined within ``setup.py`` from within your Plugin itself -- take a look at :ref:`the available control properties ` for all available @@ -295,7 +332,7 @@ overrides. Following the README of the `Plugin Skeleton `_ you could now already publish your plugin on Github and it would be directly installable by others using pip:: - pip install https://github.com/yourGithubName/OctoPrint-HelloWorld/archive/master.zip + (venv) $ pip install https://github.com/yourGithubName/OctoPrint-HelloWorld/archive/master.zip But let's add some more features instead. @@ -857,7 +894,7 @@ generated CSS files (and compiles them on the fly in your browser using `lessjs which makes development so much easier. Let's try that, so you know how it works for future bigger projects. Add another folder to our ``static`` folder called ``less`` and within that create a file ``helloworld.less``. Put -into that the same content as into our CSS file. Compile that LESS file to CSS [#f1]_, overwriting our old ``helloworld.css`` +into that the same content as into our CSS file. Compile that LESS file to CSS [#f2]_, overwriting our old ``helloworld.css`` in the process. The folder structure of our plugin should now look like this:: octoprint_helloworld/ @@ -1038,6 +1075,12 @@ looking for examples. .. rubric:: Footnotes -.. [#f1] Refer to the `LESS documentation `_ on how to do that. If you are developing +.. [#f1] Instead of the ``octoprint dev:plugin new`` you could also have manually called cookiecutter with the + template's repository URL shortcut: ``cookiecutter gh:OctoPrint/cookiecutter-octoprint-plugin``. The + ``devel:newplugin`` command already does this for you, makes sure cookiecutter always uses a fresh + checkout without prompting you for it and also allows to pre-specify a bunch of settings (like the + plugin's identifier) directly from the command line. Take a look at ``octoprint dev:plugin new --help`` + for the usage details. +.. [#f2] Refer to the `LESS documentation `_ on how to do that. If you are developing your plugin under Windows you might also want to give `WinLESS `_ a look which will run in the background and keep your CSS files up to date with your various project's LESS files automatically. diff --git a/src/octoprint/cli/__init__.py b/src/octoprint/cli/__init__.py index 9e63b12f..547a9e59 100644 --- a/src/octoprint/cli/__init__.py +++ b/src/octoprint/cli/__init__.py @@ -118,10 +118,10 @@ legacy_options = bulk_options([ from .server import server_commands from .plugins import plugin_commands -from .devel import devel_commands +from .dev import dev_commands @click.group(name="octoprint", invoke_without_command=True, cls=click.CommandCollection, - sources=[server_commands, plugin_commands, devel_commands]) + sources=[server_commands, plugin_commands, dev_commands]) @standard_options() @legacy_options @click.version_option(version=octoprint.__version__) diff --git a/src/octoprint/cli/devel.py b/src/octoprint/cli/dev.py similarity index 60% rename from src/octoprint/cli/devel.py rename to src/octoprint/cli/dev.py index 4c3ced4e..42240193 100644 --- a/src/octoprint/cli/devel.py +++ b/src/octoprint/cli/dev.py @@ -16,8 +16,53 @@ class OctoPrintDevelCommands(click.MultiCommand): based on availability of development dependencies. """ + prefix = "dev" sep = ":" + def __init__(self, *args, **kwargs): + click.MultiCommand.__init__(self, *args, **kwargs) + + from octoprint.util.commandline import CommandlineCaller + from octoprint.util.pip import LocalPipCaller + from functools import partial + + def log_util(f): + def log(*lines): + for line in lines: + f(line) + return log + + log_call = log_util(lambda x: click.echo(">> {}".format(x))) + log_stdout = log_util(click.echo) + log_stderr = log_util(partial(click.echo, err=True)) + + self.pip_caller = LocalPipCaller() + self.pip_caller.on_log_call = log_call + self.pip_caller.on_log_stdout = log_stdout + self.pip_caller.on_log_stderr = log_stderr + + self.command_caller = CommandlineCaller() + self.command_caller.on_log_call = log_call + self.command_caller.on_log_stdout = log_stdout + self.command_caller.on_log_stderr = log_stderr + + def _get_prefix_methods(self, method_prefix): + for name in [x for x in dir(self) if x.startswith(method_prefix)]: + method = getattr(self, name) + yield method + + def _get_commands_from_prefix_methods(self, method_prefix): + for method in self._get_prefix_methods(method_prefix): + result = method() + if result is not None and isinstance(result, click.Command): + yield result + + def _get_commands(self): + commands = dict() + for command in self._get_commands_from_prefix_methods("group_"): + commands[self.prefix + self.sep + command.name] = command + return commands + def list_commands(self, ctx): result = [name for name in self._get_commands()] result.sort() @@ -27,22 +72,14 @@ class OctoPrintDevelCommands(click.MultiCommand): commands = self._get_commands() return commands.get(cmd_name, None) - def _get_commands(self): - commands = dict() + def group_plugin(self): + command_group = click.Group("plugin", + help="Helpers for plugin developers") + for command in self._get_commands_from_prefix_methods("plugin_"): + command_group.add_command(command) + return command_group - for name in [x for x in dir(self) if x.startswith("command_")]: - method = getattr(self, name) - - try: - result = method() - if result is not None: - commands["devel" + self.sep + result.name] = result - except: - logging.getLogger(__name__).exception("There was an error registering one of the devel commands ({})".format(name)) - - return commands - - def command_newplugin(self): + def plugin_new(self): try: import cookiecutter.main except ImportError: @@ -102,7 +139,7 @@ class OctoPrintDevelCommands(click.MultiCommand): finally: cookiecutter.main.prompt_for_config = original_prompt_for_config - @click.command("newplugin") + @click.command("new") @click.option("--name", "-n", help="The name of the plugin") @click.option("--package", "-p", help="The plugin package") @click.option("--author", "-a", help="The plugin author's name") @@ -153,6 +190,54 @@ class OctoPrintDevelCommands(click.MultiCommand): return command + def plugin_install(self): + @click.command("install") + @click.option("--path", help="Path of the local plugin development folder to install") + def command(path): + """ + Installs the local plugin in development mode. + + Note: This can NOT be used to install plugins from remote locations + such as the plugin repository! It is strictly for local development + of plugins, to ensure the plugin is installed (editable) into the + same python environment that OctoPrint is installed under. + """ + + import os + import sys + + if not path: + path = os.getcwd() + + # check if this really looks like a plugin + if not os.path.isfile(os.path.join(path, "setup.py")): + click.echo("This doesn't look like an OctoPrint plugin folder") + sys.exit(1) + + self.command_caller.call([sys.executable, "setup.py", "develop"], cwd=path) + + return command + + def plugin_uninstall(self): + if not self.pip_caller.available: + return + + @click.command("uninstall") + @click.argument("name") + def command(name): + """Uninstalls the plugin with the given name.""" + import sys + + lower_name = name.lower() + if not lower_name.startswith("octoprint_") and not lower_name.startswith("octoprint-"): + click.echo("This doesn't look like an OctoPrint plugin name") + sys.exit(1) + + call = ["uninstall", "--yes", name] + self.pip_caller.execute(*call) + + return command + @click.group(cls=OctoPrintDevelCommands) -def devel_commands(): +def dev_commands(): pass diff --git a/src/octoprint/util/pip.py b/src/octoprint/util/pip.py index 4b5b19b9..9fc75baf 100644 --- a/src/octoprint/util/pip.py +++ b/src/octoprint/util/pip.py @@ -114,7 +114,7 @@ class PipCaller(CommandlineCaller): self._version = None self.refresh = False - def execute(self, *args): + def execute(self, *args, **kwargs): if self.refresh: self.trigger_refresh() @@ -151,20 +151,10 @@ class PipCaller(CommandlineCaller): if self._use_sudo or self.force_sudo: command = ["sudo"] + command - return self.call(command) + return self.call(command, **kwargs) def _setup_pip(self): - pip_command = self.configured - - if pip_command is not None and pip_command.startswith("sudo "): - pip_command = pip_command[len("sudo "):] - pip_sudo = True - else: - pip_sudo = False - - if pip_command is None: - pip_command = self._autodetect_pip() - + pip_command, pip_sudo = self._get_pip_command() if pip_command is None: return @@ -211,6 +201,20 @@ class PipCaller(CommandlineCaller): self._virtual_env = pip_virtual_env self._install_dir = pip_install_dir + def _get_pip_command(self): + pip_command = self.configured + + if pip_command is not None and pip_command.startswith("sudo "): + pip_command = pip_command[len("sudo "):] + pip_sudo = True + else: + pip_sudo = False + + if pip_command is None: + pip_command = self._autodetect_pip() + + return pip_command, pip_sudo + def _autodetect_pip(self): import os python_command = sys.executable @@ -325,3 +329,22 @@ class PipCaller(CommandlineCaller): else: self._logger.debug("Could not detect desired output from testballoon install, got this instead: {}".format(" ".join(sarge_command), output)) return False, False, False, None + +class LocalPipCaller(PipCaller): + + def _get_pip_command(self): + return self._autodetect_pip(), False + + def _check_pip_setup(self, pip_command): + import sys + import os + from distutils.sysconfig import get_python_lib + + virtual_env = hasattr(sys, "real_prefix") + install_dir = get_python_lib() + writable = os.access(install_dir, os.W_OK) + + return writable or not virtual_env, \ + not writable and not virtual_env and site.ENABLE_USER_SITE, \ + virtual_env, \ + install_dir